From aba9a92fb7d4475d921a678618383fe4c44cba70 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 5 Oct 2024 16:12:31 +0800 Subject: [PATCH 001/148] feat: container providers can bind additional contracts --- ghostos/container.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ghostos/container.py b/ghostos/container.py index 2eaca66f..1c4e0056 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -216,6 +216,15 @@ def register(self, provider: Provider) -> None: contract = provider.contract() self._bind_contract(contract) + self._register_provider(contract, provider) + + # additional bindings + for b in provider.additional_contracts(): + if b not in self._bound: + self._register_provider(contract, provider) + + def _register_provider(self, contract: Type[ABSTRACT], provider: Provider) -> None: + # remove singleton instance that already bound if contract in self._instances: del self._instances[contract] self._providers[contract] = provider @@ -300,6 +309,12 @@ def contract(self) -> Type[ABSTRACT]: """ pass + def additional_contracts(self) -> Iterable[Type[ABSTRACT]]: + """ + additional contracts that shall bind to this provider if the binding contract is not Bound. + """ + return [] + @abstractmethod def factory(self, con: Container) -> Optional[ABSTRACT]: """ From 3ddc9c682ed83bdbdc2fa1645cde31b70d6f42fd Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 5 Oct 2024 16:14:27 +0800 Subject: [PATCH 002/148] feat: Storage contract add FileStorage that contains abspath --- ghostos/contracts/storage.py | 22 +++++++++++++++++++++- ghostos/framework/storage/__init__.py | 2 +- ghostos/framework/storage/filestorage.py | 18 ++++++++++++------ 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/ghostos/contracts/storage.py b/ghostos/contracts/storage.py index d75eed9a..7465a551 100644 --- a/ghostos/contracts/storage.py +++ b/ghostos/contracts/storage.py @@ -1,7 +1,7 @@ from typing import Optional, AnyStr, Iterable from abc import ABC, abstractmethod -__all__ = ['Storage' ] +__all__ = ['Storage', 'FileStorage'] class Storage(ABC): @@ -46,3 +46,23 @@ def dir(self, prefix_dir: str, recursive: bool, patten: Optional[str] = None) -> :return: 多个文件路径名. """ pass + + +class FileStorage(Storage, ABC): + """ + Storage Based on FileSystem. + """ + + @abstractmethod + def abspath(self) -> str: + """ + storage root directory's absolute path + """ + pass + + @abstractmethod + def sub_storage(self, relative_path: str) -> "FileStorage": + """ + FileStorage's sub storage is still FileStorage + """ + pass diff --git a/ghostos/framework/storage/__init__.py b/ghostos/framework/storage/__init__.py index 58f1cbac..cf4511d2 100644 --- a/ghostos/framework/storage/__init__.py +++ b/ghostos/framework/storage/__init__.py @@ -1,2 +1,2 @@ -from ghostos.framework.storage.filestorage import FileStorageProvider, FileStorage +from ghostos.framework.storage.filestorage import FileStorageProvider, FileStorageImpl from ghostos.framework.storage.memstorage import MemStorage diff --git a/ghostos/framework/storage/filestorage.py b/ghostos/framework/storage/filestorage.py index d7f4675b..cbb51f6d 100644 --- a/ghostos/framework/storage/filestorage.py +++ b/ghostos/framework/storage/filestorage.py @@ -1,15 +1,18 @@ import os import re from typing import Optional, AnyStr, Type, Iterable -from ghostos.container import Provider, Container -from ghostos.contracts.storage import Storage +from ghostos.container import Provider, Container, ABSTRACT +from ghostos.contracts.storage import Storage, FileStorage -class FileStorage(Storage): +class FileStorageImpl(FileStorage): def __init__(self, dir_: str): self._dir: str = os.path.abspath(dir_) + def abspath(self) -> str: + return self._dir + def get(self, file_path: str) -> bytes: file_path = self._join_file_path(file_path) with open(file_path, 'rb') as f: @@ -36,11 +39,11 @@ def put(self, file_path: str, content: bytes) -> None: with open(file_path, 'wb') as f: f.write(content) - def sub_storage(self, relative_path: str) -> "Storage": + def sub_storage(self, relative_path: str) -> "FileStorage": if not relative_path: return self dir_path = self._join_file_path(relative_path) - return FileStorage(dir_path) + return FileStorageImpl(dir_path) def dir(self, prefix_dir: str, recursive: bool, patten: Optional[str] = None) -> Iterable[str]: dir_path = self._join_file_path(prefix_dir) @@ -77,5 +80,8 @@ def singleton(self) -> bool: def contract(self) -> Type[Storage]: return Storage + def additional_contracts(self) -> Iterable[Type[ABSTRACT]]: + yield FileStorage + def factory(self, con: Container) -> Optional[Storage]: - return FileStorage(self._dir) + return FileStorageImpl(self._dir) From 86e9a031887ca614c43ae2b25eb0d9febdb14277 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 5 Oct 2024 16:27:49 +0800 Subject: [PATCH 003/148] dev: make workspace based on file storage that it can detect root path itself --- ghostos/container.py | 15 +++++++++++---- ghostos/contracts/storage.py | 4 ++++ ghostos/core/ghosts/workspace.py | 15 +++++++++++---- ghostos/framework/storage/filestorage.py | 6 +++--- ghostos/framework/workspaces/basic.py | 17 ++++++++++------- 5 files changed, 39 insertions(+), 18 deletions(-) diff --git a/ghostos/container.py b/ghostos/container.py index 1c4e0056..5efb39aa 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -128,6 +128,7 @@ def __init__(self, parent: Optional[Container] = None): self._bound: Set = set() self._bootstrapper: List["Bootstrapper"] = [] self._bootstrapped: bool = False + self._aliases: Dict[Any, Any] = {} def bootstrap(self) -> None: """ @@ -187,7 +188,12 @@ def get(self, abstract: Type[ABSTRACT]) -> Optional[ABSTRACT]: self._set_instance(abstract, made) return made - # 第三优先级. + # search aliases if the real contract exists + if abstract in self._aliases: + contract = self._aliases[abstract] + return self.get(contract) + + # at last if self.parent is not None: return self.parent.get(abstract) return None @@ -219,9 +225,9 @@ def register(self, provider: Provider) -> None: self._register_provider(contract, provider) # additional bindings - for b in provider.additional_contracts(): + for b in provider.aliases(): if b not in self._bound: - self._register_provider(contract, provider) + self._aliases[b] = contract def _register_provider(self, contract: Type[ABSTRACT], provider: Provider) -> None: # remove singleton instance that already bound @@ -288,6 +294,7 @@ def destroy(self) -> None: del self._bound del self._bootstrapper del self._bootstrapped + del self._aliases Factory = Callable[[Container], Any] @@ -309,7 +316,7 @@ def contract(self) -> Type[ABSTRACT]: """ pass - def additional_contracts(self) -> Iterable[Type[ABSTRACT]]: + def aliases(self) -> Iterable[Type[ABSTRACT]]: """ additional contracts that shall bind to this provider if the binding contract is not Bound. """ diff --git a/ghostos/contracts/storage.py b/ghostos/contracts/storage.py index 7465a551..cdc29cfb 100644 --- a/ghostos/contracts/storage.py +++ b/ghostos/contracts/storage.py @@ -25,6 +25,10 @@ def get(self, file_path: str) -> bytes: @abstractmethod def exists(self, file_path: str) -> bool: + """ + if the object exists + :param file_path: file_path or directory path + """ pass @abstractmethod diff --git a/ghostos/core/ghosts/workspace.py b/ghostos/core/ghosts/workspace.py index 0382da1a..e3abf85a 100644 --- a/ghostos/core/ghosts/workspace.py +++ b/ghostos/core/ghosts/workspace.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from ghostos.contracts.storage import Storage +from ghostos.contracts.storage import FileStorage class Workspace(ABC): @@ -8,21 +8,28 @@ class Workspace(ABC): """ @abstractmethod - def runtime(self) -> Storage: + def root(self) -> FileStorage: + """ + the root storage of the workspace + """ + pass + + @abstractmethod + def runtime(self) -> FileStorage: """ runtime that save data by filesystem """ pass @abstractmethod - def configs(self) -> Storage: + def configs(self) -> FileStorage: """ config path that configs located """ pass @abstractmethod - def source(self) -> Storage: + def source(self) -> FileStorage: """ source code path """ diff --git a/ghostos/framework/storage/filestorage.py b/ghostos/framework/storage/filestorage.py index cbb51f6d..c600480d 100644 --- a/ghostos/framework/storage/filestorage.py +++ b/ghostos/framework/storage/filestorage.py @@ -78,10 +78,10 @@ def singleton(self) -> bool: return True def contract(self) -> Type[Storage]: - return Storage + return FileStorage - def additional_contracts(self) -> Iterable[Type[ABSTRACT]]: - yield FileStorage + def aliases(self) -> Iterable[Type[ABSTRACT]]: + yield Storage def factory(self, con: Container) -> Optional[Storage]: return FileStorageImpl(self._dir) diff --git a/ghostos/framework/workspaces/basic.py b/ghostos/framework/workspaces/basic.py index 2ddbd397..83d085cf 100644 --- a/ghostos/framework/workspaces/basic.py +++ b/ghostos/framework/workspaces/basic.py @@ -1,7 +1,7 @@ from typing import Optional, Type from ghostos.core.ghosts.workspace import Workspace -from ghostos.contracts.storage import Storage +from ghostos.contracts.storage import FileStorage from ghostos.container import Provider, Container, ABSTRACT @@ -9,23 +9,26 @@ class BasicWorkspace(Workspace): def __init__( self, - root_storage: Storage, + root_storage: FileStorage, runtime_path: str = "runtime", configs_path="configs", source_path="sources", ): - self._root_storage = root_storage + self._root_storage: FileStorage = root_storage self._runtime_storage = root_storage.sub_storage(runtime_path) self._configs_storage = root_storage.sub_storage(configs_path) self._source_storage = root_storage.sub_storage(source_path) - def runtime(self) -> Storage: + def root(self) -> FileStorage: + return self._root_storage + + def runtime(self) -> FileStorage: return self._runtime_storage - def configs(self) -> Storage: + def configs(self) -> FileStorage: return self._configs_storage - def source(self) -> Storage: + def source(self) -> FileStorage: return self._source_storage @@ -53,7 +56,7 @@ def contract(self) -> Type[ABSTRACT]: return Workspace def factory(self, con: Container) -> Optional[ABSTRACT]: - storage = con.force_fetch(Storage) + storage = con.force_fetch(FileStorage) root_storage = storage.sub_storage(self._root_path) return BasicWorkspace( root_storage, From 2a8271747bcb0a2e966a028a098ff67b773fa0f9 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 5 Oct 2024 16:44:20 +0800 Subject: [PATCH 004/148] dev: poetry add streamlit package --- poetry.lock | 262 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 259 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index b377f9d5..2f385150 100644 --- a/poetry.lock +++ b/poetry.lock @@ -137,6 +137,29 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "altair" +version = "5.4.1" +description = "Vega-Altair: A declarative statistical visualization library for Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "altair-5.4.1-py3-none-any.whl", hash = "sha256:0fb130b8297a569d08991fb6fe763582e7569f8a04643bbd9212436e3be04aef"}, + {file = "altair-5.4.1.tar.gz", hash = "sha256:0ce8c2e66546cb327e5f2d7572ec0e7c6feece816203215613962f0ec1d76a82"}, +] + +[package.dependencies] +jinja2 = "*" +jsonschema = ">=3.0" +narwhals = ">=1.5.2" +packaging = "*" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} + +[package.extras] +all = ["altair-tiles (>=0.3.0)", "anywidget (>=0.9.0)", "numpy", "pandas (>=0.25.3)", "pyarrow (>=11)", "vega-datasets (>=0.9.0)", "vegafusion[embed] (>=1.6.6)", "vl-convert-python (>=1.6.0)"] +dev = ["geopandas", "hatch", "ibis-framework[polars]", "ipython[kernel]", "mistune", "mypy", "pandas (>=0.25.3)", "pandas-stubs", "polars (>=0.20.3)", "pytest", "pytest-cov", "pytest-xdist[psutil] (>=3.5,<4.0)", "ruff (>=0.6.0)", "types-jsonschema", "types-setuptools"] +doc = ["docutils", "jinja2", "myst-parser", "numpydoc", "pillow (>=9,<10)", "pydata-sphinx-theme (>=0.14.1)", "scipy", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinxext-altair"] + [[package]] name = "annotated-types" version = "0.7.0" @@ -240,6 +263,28 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +[[package]] +name = "blinker" +version = "1.8.2" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.8" +files = [ + {file = "blinker-1.8.2-py3-none-any.whl", hash = "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01"}, + {file = "blinker-1.8.2.tar.gz", hash = "sha256:8f77b09d3bf7c795e969e9486f39c2c5e9c39d4ee07424be2bc594ece9642d83"}, +] + +[[package]] +name = "cachetools" +version = "5.5.0" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, + {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, +] + [[package]] name = "certifi" version = "2024.8.30" @@ -660,6 +705,38 @@ test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask-expr", "dask[dataframe, test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard"] tqdm = ["tqdm"] +[[package]] +name = "gitdb" +version = "4.0.11" +description = "Git Object Database" +optional = false +python-versions = ">=3.7" +files = [ + {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"}, + {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"}, +] + +[package.dependencies] +smmap = ">=3.0.1,<6" + +[[package]] +name = "gitpython" +version = "3.1.43" +description = "GitPython is a Python library used to interact with Git repositories" +optional = false +python-versions = ">=3.7" +files = [ + {file = "GitPython-3.1.43-py3-none-any.whl", hash = "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff"}, + {file = "GitPython-3.1.43.tar.gz", hash = "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c"}, +] + +[package.dependencies] +gitdb = ">=4.0.1,<5" + +[package.extras] +doc = ["sphinx (==4.3.2)", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "sphinxcontrib-applehelp (>=1.0.2,<=1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (>=2.0.0,<=2.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)"] +test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions"] + [[package]] name = "greenlet" version = "3.1.1" @@ -1610,6 +1687,25 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "narwhals" +version = "1.9.0" +description = "Extremely lightweight compatibility layer between dataframe libraries" +optional = false +python-versions = ">=3.8" +files = [ + {file = "narwhals-1.9.0-py3-none-any.whl", hash = "sha256:914cde513487341fe1e3b8cb09d3b79083530141c570e45d42150796b8d87a01"}, + {file = "narwhals-1.9.0.tar.gz", hash = "sha256:bfd8ab5abb87cfeca9cc72af4af47bf9d73a2f0fda97cffa2223a535bc65b5e5"}, +] + +[package.extras] +cudf = ["cudf (>=23.08.00)"] +dask = ["dask[dataframe] (>=2024.7)"] +modin = ["modin"] +pandas = ["pandas (>=0.25.3)"] +polars = ["polars (>=0.20.3)"] +pyarrow = ["pyarrow (>=11.0.0)"] + [[package]] name = "nest-asyncio" version = "1.6.0" @@ -1711,13 +1807,13 @@ files = [ [[package]] name = "openai" -version = "1.47.0" +version = "1.51.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.7.1" files = [ - {file = "openai-1.47.0-py3-none-any.whl", hash = "sha256:9ccc8737dfa791f7bd903db4758c176b8544a8cd89d3a3d2add3cea02a34c3a0"}, - {file = "openai-1.47.0.tar.gz", hash = "sha256:6e14d6f77c8cf546646afcd87a2ef752505b3710d2564a2e433e17307dfa86a0"}, + {file = "openai-1.51.0-py3-none-any.whl", hash = "sha256:d9affafb7e51e5a27dce78589d4964ce4d6f6d560307265933a94b2e3f3c5d2c"}, + {file = "openai-1.51.0.tar.gz", hash = "sha256:8dc4f9d75ccdd5466fc8c99a952186eddceb9fd6ba694044773f3736a847149d"}, ] [package.dependencies] @@ -2021,6 +2117,26 @@ files = [ [package.dependencies] wcwidth = "*" +[[package]] +name = "protobuf" +version = "5.28.2" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "protobuf-5.28.2-cp310-abi3-win32.whl", hash = "sha256:eeea10f3dc0ac7e6b4933d32db20662902b4ab81bf28df12218aa389e9c2102d"}, + {file = "protobuf-5.28.2-cp310-abi3-win_amd64.whl", hash = "sha256:2c69461a7fcc8e24be697624c09a839976d82ae75062b11a0972e41fd2cd9132"}, + {file = "protobuf-5.28.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a8b9403fc70764b08d2f593ce44f1d2920c5077bf7d311fefec999f8c40f78b7"}, + {file = "protobuf-5.28.2-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:35cfcb15f213449af7ff6198d6eb5f739c37d7e4f1c09b5d0641babf2cc0c68f"}, + {file = "protobuf-5.28.2-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:5e8a95246d581eef20471b5d5ba010d55f66740942b95ba9b872d918c459452f"}, + {file = "protobuf-5.28.2-cp38-cp38-win32.whl", hash = "sha256:87317e9bcda04a32f2ee82089a204d3a2f0d3c8aeed16568c7daf4756e4f1fe0"}, + {file = "protobuf-5.28.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0ea0123dac3399a2eeb1a1443d82b7afc9ff40241433296769f7da42d142ec3"}, + {file = "protobuf-5.28.2-cp39-cp39-win32.whl", hash = "sha256:ca53faf29896c526863366a52a8f4d88e69cd04ec9571ed6082fa117fac3ab36"}, + {file = "protobuf-5.28.2-cp39-cp39-win_amd64.whl", hash = "sha256:8ddc60bf374785fb7cb12510b267f59067fa10087325b8e1855b898a0d81d276"}, + {file = "protobuf-5.28.2-py3-none-any.whl", hash = "sha256:52235802093bd8a2811abbe8bf0ab9c5f54cca0a751fdd3f6ac2a21438bffece"}, + {file = "protobuf-5.28.2.tar.gz", hash = "sha256:59379674ff119717404f7454647913787034f03fe7049cbef1d74a97bb4593f0"}, +] + [[package]] name = "pyarrow" version = "17.0.0" @@ -2193,6 +2309,25 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydeck" +version = "0.9.1" +description = "Widget for deck.gl maps" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038"}, + {file = "pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605"}, +] + +[package.dependencies] +jinja2 = ">=2.10.1" +numpy = ">=1.16.4" + +[package.extras] +carto = ["pydeck-carto"] +jupyter = ["ipykernel (>=5.1.2)", "ipython (>=5.8.0)", "ipywidgets (>=7,<8)", "traitlets (>=4.3.2)"] + [[package]] name = "pygments" version = "2.18.0" @@ -2787,6 +2922,17 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "smmap" +version = "5.0.1" +description = "A pure Python implementation of a sliding window memory map manager" +optional = false +python-versions = ">=3.7" +files = [ + {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, + {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -2885,6 +3031,41 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "streamlit" +version = "1.39.0" +description = "A faster way to build and share data apps" +optional = false +python-versions = "!=3.9.7,>=3.8" +files = [ + {file = "streamlit-1.39.0-py2.py3-none-any.whl", hash = "sha256:a359fc54ed568b35b055ff1d453c320735539ad12e264365a36458aef55a5fba"}, + {file = "streamlit-1.39.0.tar.gz", hash = "sha256:fef9de7983c4ee65c08e85607d7ffccb56b00482b1041fa62f90e4815d39df3a"}, +] + +[package.dependencies] +altair = ">=4.0,<6" +blinker = ">=1.0.0,<2" +cachetools = ">=4.0,<6" +click = ">=7.0,<9" +gitpython = ">=3.0.7,<3.1.19 || >3.1.19,<4" +numpy = ">=1.20,<3" +packaging = ">=20,<25" +pandas = ">=1.4.0,<3" +pillow = ">=7.1.0,<11" +protobuf = ">=3.20,<6" +pyarrow = ">=7.0" +pydeck = ">=0.8.0b4,<1" +requests = ">=2.27,<3" +rich = ">=10.14.0,<14" +tenacity = ">=8.1.0,<10" +toml = ">=0.10.1,<2" +tornado = ">=6.0.3,<7" +typing-extensions = ">=4.3.0,<5" +watchdog = {version = ">=2.1.5,<6", markers = "platform_system != \"Darwin\""} + +[package.extras] +snowflake = ["snowflake-connector-python (>=2.8.0)", "snowflake-snowpark-python[modin] (>=1.17.0)"] + [[package]] name = "sympy" version = "1.13.3" @@ -3086,6 +3267,17 @@ dev = ["tokenizers[testing]"] docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"] testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests", "ruff"] +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + [[package]] name = "tomli" version = "2.0.1" @@ -3097,6 +3289,26 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "tornado" +version = "6.4.1" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +optional = false +python-versions = ">=3.8" +files = [ + {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8"}, + {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ae50a504a740365267b2a8d1a90c9fbc86b780a39170feca9bcc1787ff80842"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25486eb223babe3eed4b8aecbac33b37e3dd6d776bc730ca14e1bf93888b979f"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:454db8a7ecfcf2ff6042dde58404164d969b6f5d58b926da15e6b23817950fc4"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a02a08cc7a9314b006f653ce40483b9b3c12cda222d6a46d4ac63bb6c9057698"}, + {file = "tornado-6.4.1-cp38-abi3-win32.whl", hash = "sha256:d9a566c40b89757c9aa8e6f032bcdb8ca8795d7c1a9762910c722b1635c9de4d"}, + {file = "tornado-6.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:b24b8982ed444378d7f21d563f4180a2de31ced9d8d84443907a0a64da2072e7"}, + {file = "tornado-6.4.1.tar.gz", hash = "sha256:92d3ab53183d8c50f8204a51e6f91d18a15d5ef261e84d452800d4ff6fc504e9"}, +] + [[package]] name = "tqdm" version = "4.66.5" @@ -3301,6 +3513,48 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "watchdog" +version = "5.0.3" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.9" +files = [ + {file = "watchdog-5.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:85527b882f3facda0579bce9d743ff7f10c3e1e0db0a0d0e28170a7d0e5ce2ea"}, + {file = "watchdog-5.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:53adf73dcdc0ef04f7735066b4a57a4cd3e49ef135daae41d77395f0b5b692cb"}, + {file = "watchdog-5.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e25adddab85f674acac303cf1f5835951345a56c5f7f582987d266679979c75b"}, + {file = "watchdog-5.0.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f01f4a3565a387080dc49bdd1fefe4ecc77f894991b88ef927edbfa45eb10818"}, + {file = "watchdog-5.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91b522adc25614cdeaf91f7897800b82c13b4b8ac68a42ca959f992f6990c490"}, + {file = "watchdog-5.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d52db5beb5e476e6853da2e2d24dbbbed6797b449c8bf7ea118a4ee0d2c9040e"}, + {file = "watchdog-5.0.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d11b07c64f63f49876e0ab8042ae034674c8653bfcdaa8c4b32e71cfff87e8"}, + {file = "watchdog-5.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:349c9488e1d85d0a58e8cb14222d2c51cbc801ce11ac3936ab4c3af986536926"}, + {file = "watchdog-5.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:53a3f10b62c2d569e260f96e8d966463dec1a50fa4f1b22aec69e3f91025060e"}, + {file = "watchdog-5.0.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:950f531ec6e03696a2414b6308f5c6ff9dab7821a768c9d5788b1314e9a46ca7"}, + {file = "watchdog-5.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6deb336cba5d71476caa029ceb6e88047fc1dc74b62b7c4012639c0b563906"}, + {file = "watchdog-5.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1021223c08ba8d2d38d71ec1704496471ffd7be42cfb26b87cd5059323a389a1"}, + {file = "watchdog-5.0.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:752fb40efc7cc8d88ebc332b8f4bcbe2b5cc7e881bccfeb8e25054c00c994ee3"}, + {file = "watchdog-5.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a2e8f3f955d68471fa37b0e3add18500790d129cc7efe89971b8a4cc6fdeb0b2"}, + {file = "watchdog-5.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b8ca4d854adcf480bdfd80f46fdd6fb49f91dd020ae11c89b3a79e19454ec627"}, + {file = "watchdog-5.0.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:90a67d7857adb1d985aca232cc9905dd5bc4803ed85cfcdcfcf707e52049eda7"}, + {file = "watchdog-5.0.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:720ef9d3a4f9ca575a780af283c8fd3a0674b307651c1976714745090da5a9e8"}, + {file = "watchdog-5.0.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:223160bb359281bb8e31c8f1068bf71a6b16a8ad3d9524ca6f523ac666bb6a1e"}, + {file = "watchdog-5.0.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:560135542c91eaa74247a2e8430cf83c4342b29e8ad4f520ae14f0c8a19cfb5b"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:dd021efa85970bd4824acacbb922066159d0f9e546389a4743d56919b6758b91"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_armv7l.whl", hash = "sha256:78864cc8f23dbee55be34cc1494632a7ba30263951b5b2e8fc8286b95845f82c"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_i686.whl", hash = "sha256:1e9679245e3ea6498494b3028b90c7b25dbb2abe65c7d07423ecfc2d6218ff7c"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_ppc64.whl", hash = "sha256:9413384f26b5d050b6978e6fcd0c1e7f0539be7a4f1a885061473c5deaa57221"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:294b7a598974b8e2c6123d19ef15de9abcd282b0fbbdbc4d23dfa812959a9e05"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:26dd201857d702bdf9d78c273cafcab5871dd29343748524695cecffa44a8d97"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:0f9332243355643d567697c3e3fa07330a1d1abf981611654a1f2bf2175612b7"}, + {file = "watchdog-5.0.3-py3-none-win32.whl", hash = "sha256:c66f80ee5b602a9c7ab66e3c9f36026590a0902db3aea414d59a2f55188c1f49"}, + {file = "watchdog-5.0.3-py3-none-win_amd64.whl", hash = "sha256:f00b4cf737f568be9665563347a910f8bdc76f88c2970121c86243c8cfdf90e9"}, + {file = "watchdog-5.0.3-py3-none-win_ia64.whl", hash = "sha256:49f4d36cb315c25ea0d946e018c01bb028048023b9e103d3d3943f58e109dd45"}, + {file = "watchdog-5.0.3.tar.gz", hash = "sha256:108f42a7f0345042a854d4d0ad0834b741d421330d5f575b81cb27b883500176"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + [[package]] name = "wcwidth" version = "0.2.13" @@ -3650,4 +3904,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10, <3.13" -content-hash = "40be9244276197c5d21b27cf0a64f67bee2aff3c2ca1b76d5fb51a9a6fe08b6c" +content-hash = "ca152301868c2b0a9504ff70fb5a5bba072cc1c3b4c95302b3e7ec34909c2d82" diff --git a/pyproject.toml b/pyproject.toml index ba1e9aa7..382ec84b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ prompt-toolkit = "^3.0.47" arxiv = "^2.1.3" llama-index-core = "^0.11.9" llama-index-llms-openai = "^0.2.7" +streamlit = "^1.39.0" [tool.poetry.scripts] init = "ghostos.scripts.init:main" From 521b4c057d4ed5e5ac19b515aed731f18683fbea Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 5 Oct 2024 16:45:38 +0800 Subject: [PATCH 005/148] dev: test streamlit about it's basic features --- ghostos/prototypes/streamlitapp/__init__.py | 0 .../prototypes/streamlitapp/design/README.md | 4 + .../streamlitapp/design/apps/autobots.py | 6 + .../streamlitapp/design/apps/chatbots.py | 6 + .../streamlitapp/design/apps/code_project.py | 6 + .../streamlitapp/design/apps/streamlit_app.py | 6 + .../streamlitapp/design/apps/talk_to_db.py | 6 + .../streamlitapp/design/apps/talk_to_files.py | 6 + .../streamlitapp/design/home/configs.py | 15 +++ .../streamlitapp/design/home/docs.py | 16 +++ .../streamlitapp/design/home/ghostos_bot.py | 16 +++ .../streamlitapp/design/home/home.py | 10 ++ .../streamlitapp/design/home/tools.py | 10 ++ .../streamlitapp/design/homepage.py | 111 ++++++++++++++++++ .../streamlitapp/design/resources/ai_funcs.py | 6 + .../design/resources/data_objects.py | 6 + .../design/resources/knowledge.py | 6 + .../design/resources/libraries.py | 6 + .../streamlitapp/design/resources/llms.py | 6 + .../design/resources/moss_files.py | 6 + .../streamlitapp/design/resources/thoughts.py | 6 + .../prototypes/streamlitapp/tests/README.md | 4 + .../tests/chat_render_by_messages.py | 50 ++++++++ .../streamlitapp/tests/pages_test.py | 56 +++++++++ .../streamlitapp/tests/reports/alerts.py | 4 + .../streamlitapp/tests/reports/bugs.py | 4 + .../streamlitapp/tests/reports/dashboard.py | 4 + .../prototypes/streamlitapp/tests/slider.py | 4 + .../streamlitapp/tests/tools/history.py | 6 + .../streamlitapp/tests/tools/search.py | 4 + 30 files changed, 396 insertions(+) create mode 100644 ghostos/prototypes/streamlitapp/__init__.py create mode 100644 ghostos/prototypes/streamlitapp/design/README.md create mode 100644 ghostos/prototypes/streamlitapp/design/apps/autobots.py create mode 100644 ghostos/prototypes/streamlitapp/design/apps/chatbots.py create mode 100644 ghostos/prototypes/streamlitapp/design/apps/code_project.py create mode 100644 ghostos/prototypes/streamlitapp/design/apps/streamlit_app.py create mode 100644 ghostos/prototypes/streamlitapp/design/apps/talk_to_db.py create mode 100644 ghostos/prototypes/streamlitapp/design/apps/talk_to_files.py create mode 100644 ghostos/prototypes/streamlitapp/design/home/configs.py create mode 100644 ghostos/prototypes/streamlitapp/design/home/docs.py create mode 100644 ghostos/prototypes/streamlitapp/design/home/ghostos_bot.py create mode 100644 ghostos/prototypes/streamlitapp/design/home/home.py create mode 100644 ghostos/prototypes/streamlitapp/design/home/tools.py create mode 100644 ghostos/prototypes/streamlitapp/design/homepage.py create mode 100644 ghostos/prototypes/streamlitapp/design/resources/ai_funcs.py create mode 100644 ghostos/prototypes/streamlitapp/design/resources/data_objects.py create mode 100644 ghostos/prototypes/streamlitapp/design/resources/knowledge.py create mode 100644 ghostos/prototypes/streamlitapp/design/resources/libraries.py create mode 100644 ghostos/prototypes/streamlitapp/design/resources/llms.py create mode 100644 ghostos/prototypes/streamlitapp/design/resources/moss_files.py create mode 100644 ghostos/prototypes/streamlitapp/design/resources/thoughts.py create mode 100644 ghostos/prototypes/streamlitapp/tests/README.md create mode 100644 ghostos/prototypes/streamlitapp/tests/chat_render_by_messages.py create mode 100644 ghostos/prototypes/streamlitapp/tests/pages_test.py create mode 100644 ghostos/prototypes/streamlitapp/tests/reports/alerts.py create mode 100644 ghostos/prototypes/streamlitapp/tests/reports/bugs.py create mode 100644 ghostos/prototypes/streamlitapp/tests/reports/dashboard.py create mode 100644 ghostos/prototypes/streamlitapp/tests/slider.py create mode 100644 ghostos/prototypes/streamlitapp/tests/tools/history.py create mode 100644 ghostos/prototypes/streamlitapp/tests/tools/search.py diff --git a/ghostos/prototypes/streamlitapp/__init__.py b/ghostos/prototypes/streamlitapp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/streamlitapp/design/README.md b/ghostos/prototypes/streamlitapp/design/README.md new file mode 100644 index 00000000..67e46cf3 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/README.md @@ -0,0 +1,4 @@ +# About this directory + +The raw design of the ghostos streamlit application. +remove later. \ No newline at end of file diff --git a/ghostos/prototypes/streamlitapp/design/apps/autobots.py b/ghostos/prototypes/streamlitapp/design/apps/autobots.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/apps/autobots.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design/apps/chatbots.py b/ghostos/prototypes/streamlitapp/design/apps/chatbots.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/apps/chatbots.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design/apps/code_project.py b/ghostos/prototypes/streamlitapp/design/apps/code_project.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/apps/code_project.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design/apps/streamlit_app.py b/ghostos/prototypes/streamlitapp/design/apps/streamlit_app.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/apps/streamlit_app.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design/apps/talk_to_db.py b/ghostos/prototypes/streamlitapp/design/apps/talk_to_db.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/apps/talk_to_db.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design/apps/talk_to_files.py b/ghostos/prototypes/streamlitapp/design/apps/talk_to_files.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/apps/talk_to_files.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design/home/configs.py b/ghostos/prototypes/streamlitapp/design/home/configs.py new file mode 100644 index 00000000..fcc647e2 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/home/configs.py @@ -0,0 +1,15 @@ +import streamlit as st + +st.set_page_config( + page_title="GhostOS", + page_icon="🧊", + layout="wide", + initial_sidebar_state="expanded", + menu_items={ + 'Get Help': 'https://www.extremelycoolapp.com/help', + 'Report a bug': "https://www.extremelycoolapp.com/bug", + 'About': "# This is a header. This is an *extremely* cool app!", + } +) + +st.markdown("# hello world", unsafe_allow_html=True) diff --git a/ghostos/prototypes/streamlitapp/design/home/docs.py b/ghostos/prototypes/streamlitapp/design/home/docs.py new file mode 100644 index 00000000..d948f6b0 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/home/docs.py @@ -0,0 +1,16 @@ +import streamlit as st + +st.title("Chat") +if "messages" not in st.session_state: + st.session_state.messages = [{"role": "assistant", "content": "hello world"}] +for message in st.session_state.messages: + with st.chat_message(message["role"]): + st.markdown(message["content"]) +if prompt := st.chat_input("say something"): + st.chat_message("user").markdown(prompt) + st.session_state.messages.append({"role": "user", "content": prompt}) + response = f"Echo: {prompt}" + with st.chat_message("assistant"): + st.write_stream([c for c in response]) + st.session_state.messages.append({"role": "assistant", "content": response}) + diff --git a/ghostos/prototypes/streamlitapp/design/home/ghostos_bot.py b/ghostos/prototypes/streamlitapp/design/home/ghostos_bot.py new file mode 100644 index 00000000..13461c9c --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/home/ghostos_bot.py @@ -0,0 +1,16 @@ +import streamlit as st + + +st.title("Chat") +if "messages" not in st.session_state: + st.session_state.messages = [{"role": "assistant", "content": "hello world"}] +for message in st.session_state.messages: + with st.chat_message(message["role"]): + st.markdown(message["content"]) +if prompt := st.chat_input("say something"): + st.chat_message("user").markdown(prompt) + st.session_state.messages.append({"role": "user", "content": prompt}) + response = f"Echo: {prompt}" + with st.chat_message("assistant"): + st.write_stream([c for c in response]) + st.session_state.messages.append({"role": "assistant", "content": response}) diff --git a/ghostos/prototypes/streamlitapp/design/home/home.py b/ghostos/prototypes/streamlitapp/design/home/home.py new file mode 100644 index 00000000..8abab724 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/home/home.py @@ -0,0 +1,10 @@ +import streamlit as st + +from os.path import join, dirname +from ghostos import demo + +readme_file = join(dirname(dirname(dirname(demo.__file__))), "README.md") + +with open(readme_file, 'r') as f: + content = f.read() + st.markdown(content, unsafe_allow_html=True) diff --git a/ghostos/prototypes/streamlitapp/design/home/tools.py b/ghostos/prototypes/streamlitapp/design/home/tools.py new file mode 100644 index 00000000..8abab724 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/home/tools.py @@ -0,0 +1,10 @@ +import streamlit as st + +from os.path import join, dirname +from ghostos import demo + +readme_file = join(dirname(dirname(dirname(demo.__file__))), "README.md") + +with open(readme_file, 'r') as f: + content = f.read() + st.markdown(content, unsafe_allow_html=True) diff --git a/ghostos/prototypes/streamlitapp/design/homepage.py b/ghostos/prototypes/streamlitapp/design/homepage.py new file mode 100644 index 00000000..39321d61 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/homepage.py @@ -0,0 +1,111 @@ +import streamlit as st + +pages = { + # 需要有系统自带的 bots. + "GhostOS": [ + st.Page( + "home/ghostos_bot.py", + icon=":material/robot:", + title="Host Bot", + default=True, + ), + st.Page( + "home/docs.py", + title="Documents", + icon=":material/book:", + ), + st.Page( + "home/home.py", + title="Readme", + icon=":material/home:", + ), + st.Page( + "home/configs.py", + title="Configs", + icon=":material/home:", + ), + st.Page( + "home/tools.py", + title="Tools", + icon=":material/control_point:", + ), + ], + "Applications": [ + st.Page( + "apps/chatbots.py", + title="ChatBots", + icon=":material/chat:", + ), + st.Page( + "apps/autobots.py", + title="Autonomous Bots", + icon=":material/chat:", + ), + st.Page( + "apps/code_project.py", + title="Project Manager", + icon=":material/chat:", + ), + st.Page( + "apps/talk_to_files.py", + title="Talk to Files", + icon=":material/description:", + ), + st.Page( + "apps/streamlit_app.py", + title="Streamlit Expert", + icon=":material/description:", + ), + st.Page( + "apps/talk_to_db.py", + title="Talk to Database", + icon=":material/database:", + ), + ], + "Resources": [ + st.Page( + "resources/llms.py", + title="Large Language Models", + icon=":material/database:", + ), + st.Page( + "resources/moss_files.py", + title="Moss Files", + icon=":material/functions:", + ), + st.Page( + "resources/ai_funcs.py", + title="AI Functions", + icon=":material/functions:", + ), + st.Page( + "resources/thoughts.py", + title="Thoughts", + icon=":material/functions:", + ), + st.Page( + "resources/libraries.py", + title="Libraries", + icon=":material/functions:", + ), + st.Page( + "resources/knowledge.py", + title="Knowledge", + icon=":material/functions:", + ), + st.Page( + "resources/data_objects.py", + title="Data Objects", + icon=":material/database:", + ), + ], +} + + +ng = st.navigation( + pages, + expanded=False, +) + +ng.run() + diff --git a/ghostos/prototypes/streamlitapp/design/resources/ai_funcs.py b/ghostos/prototypes/streamlitapp/design/resources/ai_funcs.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/resources/ai_funcs.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design/resources/data_objects.py b/ghostos/prototypes/streamlitapp/design/resources/data_objects.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/resources/data_objects.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design/resources/knowledge.py b/ghostos/prototypes/streamlitapp/design/resources/knowledge.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/resources/knowledge.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design/resources/libraries.py b/ghostos/prototypes/streamlitapp/design/resources/libraries.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/resources/libraries.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design/resources/llms.py b/ghostos/prototypes/streamlitapp/design/resources/llms.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/resources/llms.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design/resources/moss_files.py b/ghostos/prototypes/streamlitapp/design/resources/moss_files.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/resources/moss_files.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design/resources/thoughts.py b/ghostos/prototypes/streamlitapp/design/resources/thoughts.py new file mode 100644 index 00000000..ea5ce290 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design/resources/thoughts.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.write(st.query_params.to_dict()) + +with st.chat_message("assistant"): + st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/tests/README.md b/ghostos/prototypes/streamlitapp/tests/README.md new file mode 100644 index 00000000..a95dfb52 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/README.md @@ -0,0 +1,4 @@ +# About this directory + +test the basic features of the streamlit. +remove later. diff --git a/ghostos/prototypes/streamlitapp/tests/chat_render_by_messages.py b/ghostos/prototypes/streamlitapp/tests/chat_render_by_messages.py new file mode 100644 index 00000000..8b874f90 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/chat_render_by_messages.py @@ -0,0 +1,50 @@ +from typing import List, Any, Dict +import streamlit as st +import pandas as pd +import numpy as np +from pydantic import BaseModel, Field + +st.session_state.messages = [] + +chart_data = pd.DataFrame( + { + "col1": np.random.randn(20), + "col2": np.random.randn(20), + "col3": np.random.choice(["A", "B", "C"], 20), + } +) + + +class Item(BaseModel): + method: str = "write" + args: List[Any] = Field(default_factory=list) + kwargs: Dict[str, Any] = Field(default_factory=dict) + + +st.session_state.messages.append(Item( + method="write", + args=["hello world!"], +)) + +st.session_state.messages.append(Item( + method="area_chart", + args=[chart_data], + kwargs=dict(x="col1", y="col2", color="col3"), +)) + +messages: List[Item] = st.session_state.messages + +for item in messages: + with st.chat_message("assistant"): + method = getattr(st, item.method) + method(*item.args, **item.kwargs) + + +# 可以用数据结构来定义, 但并不优雅. +# 更好的办法是, 数据保存在文件, 同时定义一个函数. 函数接受回调. +# like: + + +class RenderObject(BaseModel): + data_path: str = Field(description="the path to save the data") + data_type: str = Field(description="the data type that follow with a callback func to render the data") diff --git a/ghostos/prototypes/streamlitapp/tests/pages_test.py b/ghostos/prototypes/streamlitapp/tests/pages_test.py new file mode 100644 index 00000000..aeabcd0a --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/pages_test.py @@ -0,0 +1,56 @@ +import streamlit as st + +if "logged_in" not in st.session_state: + st.session_state.logged_in = False + + +def login(): + if st.button("Log in"): + st.session_state.logged_in = True + st.rerun() + + +def logout(): + if st.button("Log out"): + st.session_state.logged_in = False + # 关键的逻辑, 触发重新渲染. + st.rerun() + + +login_page = st.Page(login, title="Log in", icon=":material/login:") +logout_page = st.Page(logout, title="Log out", icon=":material/logout:") + +dashboard = st.Page( + "reports/dashboard.py", title="Dashboard", icon=":material/dashboard:", default=True +) +bugs = st.Page("reports/bugs.py", title="Bug reports", icon=":material/bug_report:") +alerts = st.Page( + "reports/alerts.py", title="System alerts", icon=":material/notification_important:" +) + +search = st.Page("tools/search.py", title="Search", icon=":material/search:") +history = st.Page( + "tools/history.py", + title="History", + icon=":material/history:", + url_path="history", +) +history2 = st.Page( + "tools/history.py", + title="History2", + icon=":material/history:", + url_path="history2?hello=world", +) + +if st.session_state.logged_in or True: + pg = st.navigation( + { + "Account": [logout_page], + "Reports": [dashboard, bugs, alerts], + "Tools": [search, history, history2], + } + ) +else: + pg = st.navigation([login_page]) + +pg.run() diff --git a/ghostos/prototypes/streamlitapp/tests/reports/alerts.py b/ghostos/prototypes/streamlitapp/tests/reports/alerts.py new file mode 100644 index 00000000..78d76467 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/reports/alerts.py @@ -0,0 +1,4 @@ +import streamlit as st + +x = st.slider("Select a value") +st.write(x, "squared is", x * x) diff --git a/ghostos/prototypes/streamlitapp/tests/reports/bugs.py b/ghostos/prototypes/streamlitapp/tests/reports/bugs.py new file mode 100644 index 00000000..78d76467 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/reports/bugs.py @@ -0,0 +1,4 @@ +import streamlit as st + +x = st.slider("Select a value") +st.write(x, "squared is", x * x) diff --git a/ghostos/prototypes/streamlitapp/tests/reports/dashboard.py b/ghostos/prototypes/streamlitapp/tests/reports/dashboard.py new file mode 100644 index 00000000..78d76467 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/reports/dashboard.py @@ -0,0 +1,4 @@ +import streamlit as st + +x = st.slider("Select a value") +st.write(x, "squared is", x * x) diff --git a/ghostos/prototypes/streamlitapp/tests/slider.py b/ghostos/prototypes/streamlitapp/tests/slider.py new file mode 100644 index 00000000..78d76467 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/slider.py @@ -0,0 +1,4 @@ +import streamlit as st + +x = st.slider("Select a value") +st.write(x, "squared is", x * x) diff --git a/ghostos/prototypes/streamlitapp/tests/tools/history.py b/ghostos/prototypes/streamlitapp/tests/tools/history.py new file mode 100644 index 00000000..878891ba --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/tools/history.py @@ -0,0 +1,6 @@ +import streamlit as st + +x = st.slider("Select a value") +st.write(x, "squared is", x * x) +values = {k: v for k, v in st.query_params.items()} +st.write(values) diff --git a/ghostos/prototypes/streamlitapp/tests/tools/search.py b/ghostos/prototypes/streamlitapp/tests/tools/search.py new file mode 100644 index 00000000..78d76467 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/tools/search.py @@ -0,0 +1,4 @@ +import streamlit as st + +x = st.slider("Select a value") +st.write(x, "squared is", x * x) From 1f8b4471eaff876f3a52ea86cdb62b0a933a1016 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 5 Oct 2024 18:08:59 +0800 Subject: [PATCH 006/148] dev: aifunc streamlit app test --- ghostos/app/__init__.py | 0 ghostos/app/console.py | 0 ghostos/app/os.py | 0 ghostos/app/src/aifuncs/__init__.py | 0 ghostos/app/src/aifuncs/agentic.py | 35 +++++++++++ ghostos/app/src/aifuncs/baseline.py | 32 ++++++++++ ghostos/app/src/aifuncs/news.py | 41 +++++++++++++ ghostos/app/src/aifuncs/utils.py | 22 +++++++ ghostos/app/src/aifuncs/weather.py | 39 ++++++++++++ ghostos/app/src/thoughts/__init__.py | 0 ghostos/app/st_app.py | 0 ghostos/app/workspace/configs/ghosts.yml | 18 ++++++ ghostos/app/workspace/configs/llms_conf.yml | 59 ++++++++++++++++++ ghostos/app/workspace/configs/logging.yml | 28 +++++++++ ghostos/app/workspace/memories/.gitkeep.py | 0 .../app/workspace/runtime/cache/.gitignore | 2 + .../app/workspace/runtime/events/.gitignore | 2 + .../workspace/runtime/processes/.gitignore | 2 + .../app/workspace/runtime/tasks/.gitignore | 2 + .../app/workspace/runtime/threads/.gitignore | 2 + ghostos/core/aifunc/__init__.py | 6 +- ghostos/core/aifunc/interfaces.py | 2 +- .../{design => design1}/README.md | 0 .../{design => design1}/apps/autobots.py | 0 .../{design => design1}/apps/chatbots.py | 0 .../{design => design1}/apps/code_project.py | 0 .../{design => design1}/apps/streamlit_app.py | 0 .../{design => design1}/apps/talk_to_db.py | 0 .../{design => design1}/apps/talk_to_files.py | 0 .../{design => design1}/home/configs.py | 0 .../{design => design1}/home/docs.py | 0 .../{design => design1}/home/ghostos_bot.py | 0 .../{design => design1}/home/home.py | 0 .../{design => design1}/home/tools.py | 0 .../{design => design1}/homepage.py | 0 .../{design => design1}/resources/ai_funcs.py | 0 .../resources/data_objects.py | 0 .../resources/knowledge.py | 0 .../resources/libraries.py | 0 .../{design => design1}/resources/llms.py | 0 .../resources/moss_files.py | 0 .../{design => design1}/resources/thoughts.py | 0 .../streamlitapp/patches/__init__.py | 1 + .../patches/streamlit_pydantic_patch.py | 11 ++++ .../tests/aifunc/aifunc_elements.py | 59 ++++++++++++++++++ poetry.lock | 60 ++++++++++++++++++- pyproject.toml | 2 + 47 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 ghostos/app/__init__.py create mode 100644 ghostos/app/console.py create mode 100644 ghostos/app/os.py create mode 100644 ghostos/app/src/aifuncs/__init__.py create mode 100644 ghostos/app/src/aifuncs/agentic.py create mode 100644 ghostos/app/src/aifuncs/baseline.py create mode 100644 ghostos/app/src/aifuncs/news.py create mode 100644 ghostos/app/src/aifuncs/utils.py create mode 100644 ghostos/app/src/aifuncs/weather.py create mode 100644 ghostos/app/src/thoughts/__init__.py create mode 100644 ghostos/app/st_app.py create mode 100644 ghostos/app/workspace/configs/ghosts.yml create mode 100644 ghostos/app/workspace/configs/llms_conf.yml create mode 100644 ghostos/app/workspace/configs/logging.yml create mode 100644 ghostos/app/workspace/memories/.gitkeep.py create mode 100644 ghostos/app/workspace/runtime/cache/.gitignore create mode 100644 ghostos/app/workspace/runtime/events/.gitignore create mode 100644 ghostos/app/workspace/runtime/processes/.gitignore create mode 100644 ghostos/app/workspace/runtime/tasks/.gitignore create mode 100644 ghostos/app/workspace/runtime/threads/.gitignore rename ghostos/prototypes/streamlitapp/{design => design1}/README.md (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/apps/autobots.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/apps/chatbots.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/apps/code_project.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/apps/streamlit_app.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/apps/talk_to_db.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/apps/talk_to_files.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/home/configs.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/home/docs.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/home/ghostos_bot.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/home/home.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/home/tools.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/homepage.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/resources/ai_funcs.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/resources/data_objects.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/resources/knowledge.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/resources/libraries.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/resources/llms.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/resources/moss_files.py (100%) rename ghostos/prototypes/streamlitapp/{design => design1}/resources/thoughts.py (100%) create mode 100644 ghostos/prototypes/streamlitapp/patches/__init__.py create mode 100644 ghostos/prototypes/streamlitapp/patches/streamlit_pydantic_patch.py create mode 100644 ghostos/prototypes/streamlitapp/tests/aifunc/aifunc_elements.py diff --git a/ghostos/app/__init__.py b/ghostos/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/app/console.py b/ghostos/app/console.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/app/os.py b/ghostos/app/os.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/app/src/aifuncs/__init__.py b/ghostos/app/src/aifuncs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/app/src/aifuncs/agentic.py b/ghostos/app/src/aifuncs/agentic.py new file mode 100644 index 00000000..6e99e9de --- /dev/null +++ b/ghostos/app/src/aifuncs/agentic.py @@ -0,0 +1,35 @@ +from typing import Optional +from ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx +from ghostos.core.moss import Moss as Parent +from ghostos.app.src.aifuncs.weather import WeatherAIFunc +from ghostos.app.src.aifuncs.news import NewsAIFunc +from pydantic import Field + + +class AgentFn(AIFunc): + """ + AIFunc that act like an agent + """ + request: str = Field(description="raw request for the agent") + + +class AgentFnResult(AIFuncResult): + """ + the result that follow the agent request + """ + result: str = Field(description="response from the agent") + err: Optional[str] = Field(default=None, description="error message") + + +class Moss(Parent): + ai_func_ctx: AIFuncCtx + """useful to run AIFunc""" + + +# + + +def __aifunc_instruction__(fn: AgentFn) -> str: + return fn.request + +# diff --git a/ghostos/app/src/aifuncs/baseline.py b/ghostos/app/src/aifuncs/baseline.py new file mode 100644 index 00000000..2e86fb62 --- /dev/null +++ b/ghostos/app/src/aifuncs/baseline.py @@ -0,0 +1,32 @@ +from typing import Optional +from ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx +from ghostos.core.moss import Moss as Parent +from pydantic import Field + + +class AgentFunc(AIFunc): + """ + agent func that act like an agent + """ + pass + + +class AgentFuncResult(AIFuncResult): + """ + the result that follow the agent func instruction + """ + result: str = Field(description="response from the agent func") + err: Optional[str] = Field(default=None, description="error message") + + +class Moss(Parent): + ai_func_ctx: AIFuncCtx + """useful to run AIFunc""" + + +# + + +baseline_case = AgentFunc() + +# diff --git a/ghostos/app/src/aifuncs/news.py b/ghostos/app/src/aifuncs/news.py new file mode 100644 index 00000000..4acc9743 --- /dev/null +++ b/ghostos/app/src/aifuncs/news.py @@ -0,0 +1,41 @@ +from typing import Optional, List +from ghostos.core.aifunc import AIFunc, AIFuncResult +from pydantic import BaseModel, Field +from ghostos.core.moss import Moss + + +class NewsAIFunc(AIFunc): + """ + search news + """ + query: str = Field(description="required news query.") + limit: int = Field(default=5, description="how many news you want.") + + +class NewsAIFuncResult(AIFuncResult): + """ + news result + """ + + class News(BaseModel): + summary: str = Field(description="summary of the news.") + title: str = Field(description="title of the news.") + date: str = Field(description="date of the news.") + media: str = Field(description="media of the news.") + + results: List[News] = Field(default_factory=list) + + +# + + +def __aifunc_instruction__(fn: NewsAIFunc) -> str: + return ( + "Your task is **MOCKING** a result from the function arguments, make it seems real." + f"the limit of fn is {fn.limit}" + ) + + +example = NewsAIFunc(query="我想知道黑神话悟空这款游戏的媒体评分。") + +# diff --git a/ghostos/app/src/aifuncs/utils.py b/ghostos/app/src/aifuncs/utils.py new file mode 100644 index 00000000..078eb2a6 --- /dev/null +++ b/ghostos/app/src/aifuncs/utils.py @@ -0,0 +1,22 @@ +# fake methods for aifunc +from typing import Dict + + +def get_weather(city: str, date: str) -> Dict: + """ + :param city: the city that you want + :param date: the date you want + :return: Dict that contains weather information: + temperature: Optional[float] + humidity: Optional[float] + pressure: Optional[float] + wind_speed: Optional[float] + wind_dir: Optional[int] 0~360 degrees wind direction. 0 is North, 90 is East, etc. + """ + return { + "temperature": 30, + "humidity": 80, + "pressure": 100, + "wind_speed": 6, + "wind_dir": 95, + } diff --git a/ghostos/app/src/aifuncs/weather.py b/ghostos/app/src/aifuncs/weather.py new file mode 100644 index 00000000..501c76bd --- /dev/null +++ b/ghostos/app/src/aifuncs/weather.py @@ -0,0 +1,39 @@ +from typing import Optional +from ghostos.core.aifunc import AIFunc, AIFuncResult +from ghostos.app.src.aifuncs.utils import get_weather +from pydantic import Field + + +class WeatherAIFunc(AIFunc): + """ + tell about weather + """ + city: str = Field(default="", description="the city name that you want weather forecast. empty means local") + date: str = Field(default="today", description="the date of weather forecast") + + +class WeatherAIFuncResult(AIFuncResult): + """ + weather result + """ + result: str = Field( + default="", + description="the full result describing weather details in nature language form.", + ) + date: str = Field(default="today", description="the date of weather forecast") + city: str = Field(default="", description="the city name that you want weather forecast. empty means local") + temperature: Optional[float] = Field(default=None, description="the temperature of the weather") + humidity: Optional[float] = Field(default=None, description="the humidity of the weather") + pressure: Optional[float] = Field(default=None, description="the pressure of the weather") + wind_speed: Optional[float] = Field(default=None, description="the wind speed of the weather") + wind_dir: Optional[float] = Field(default=None, description="the wind direction of the weather") + + +# + +def __aifunc_instruction__(fn: WeatherAIFunc) -> str: + return "Your task is using get_weather function to get weather information fit the input" + + +example = WeatherAIFunc() +# diff --git a/ghostos/app/src/thoughts/__init__.py b/ghostos/app/src/thoughts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/app/st_app.py b/ghostos/app/st_app.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/app/workspace/configs/ghosts.yml b/ghostos/app/workspace/configs/ghosts.yml new file mode 100644 index 00000000..0bc0ea1c --- /dev/null +++ b/ghostos/app/workspace/configs/ghosts.yml @@ -0,0 +1,18 @@ +ghosts: + baseline: + type: "ghostos.framework.ghosts:DemoGhostConf" + data: + id: baseline + name: jojo + description: simple agent that can talk with user. + meta_prompt: |+ + You are an assistant named JoJo. + You shall chat with user friendly. + thought_meta: + type: "ghostos.thoughts:ChatThought" + data: + task_name: "chat" + task_desc: "chat with user" + llm_api: "" + instruction: Let's chat! + diff --git a/ghostos/app/workspace/configs/llms_conf.yml b/ghostos/app/workspace/configs/llms_conf.yml new file mode 100644 index 00000000..6024fe64 --- /dev/null +++ b/ghostos/app/workspace/configs/llms_conf.yml @@ -0,0 +1,59 @@ +# DetailConfigs ghostos.framework.llms.llms::LLMsYamlConfig +services: + - name: moonshot + base_url: https://api.moonshot.cn/v1 + token: $MOONSHOT_API_KEY + - name: openai + base_url: https://api.openai.com/v1 + token: $OPENAI_API_KEY + proxy: $OPENAI_PROXY + - name: anthropic + token: $ANTHROPIC_API_KEY + proxy: $OPENAI_PROXY + base_url: https://api.anthropic.com/v1 + - name: deepseek + token: $DEEPSEEK_API_KEY + base_url: https://api.deepseek.com/beta + # proxy: $OPENAI_PROXY +# Configure default LLM API here. +default: + # service: moonshot + # model: moonshot-v1-32k + service: openai + model: gpt-4o +# The models below can be edited as you want, see details: ghostos.core.llms.configs:ModelConf +# the key of models is a `llm_api_name`, value is a ModelConf instance. +models: + moonshot-v1-8k: + service: moonshot + model: moonshot-v1-8k + moonshot-v1-32k: + service: moonshot + model: moonshot-v1-32k + moonshot-v1-128k: + service: moonshot + model: moonshot-v1-128k + gpt-3.5-turbo: + service: openai + model: gpt-3.5-turbo + gpt-4: + service: openai + model: gpt-4 + gpt-4-turbo: + service: openai + model: gpt-4-turbo + gpt-4o: + service: openai + model: gpt-4o + claude-3-5-sonnet: # 200K context window, 3$/M input, 3.75$/M cache write, 0.3$/M cache read, 15$/M output + service: anthropic + model: claude-3-5-sonnet-20240620 + claude-3-haiku: # 200K context window, 0.25$/M input, 0.3$/M cache write, 0.03$/M cache read, 1.25$/M output + service: anthropic + model: claude-3-haiku-20240307 + deepseek-chat: # 128k context window, 4k output window. 1Y/M input, 0.1Y/M cache hit, 2Y/M output + service: deepseek + model: deepseek/deepseek-chat + deepseek-coder: # 128k context window, 8k output window. 1Y/M input, 0.1Y/M cache hit, 2Y/M output + service: deepseek + model: deepseek/deepseek-coder diff --git a/ghostos/app/workspace/configs/logging.yml b/ghostos/app/workspace/configs/logging.yml new file mode 100644 index 00000000..bcb03d8e --- /dev/null +++ b/ghostos/app/workspace/configs/logging.yml @@ -0,0 +1,28 @@ +# logging_config.yml + +version: 1 + +formatters: + default: + format: "%(asctime)s - %(name)s - %(levelname)s: %(message)s" + ghost: + format: "%(asctime)s - %(name)s - %(levelname)s: %(message)s - %(trace)s" + +handlers: + debug_file: + class: logging.FileHandler + formatter: default + filename: debug.log + console: + class: logging.StreamHandler + level: DEBUG + formatter: default + stream: ext://sys.stdout + +loggers: + debug: + handlers: [ debug_file ] + level: DEBUG + console: + handlers: [ console ] + level: DEBUG diff --git a/ghostos/app/workspace/memories/.gitkeep.py b/ghostos/app/workspace/memories/.gitkeep.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/app/workspace/runtime/cache/.gitignore b/ghostos/app/workspace/runtime/cache/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/ghostos/app/workspace/runtime/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ghostos/app/workspace/runtime/events/.gitignore b/ghostos/app/workspace/runtime/events/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/ghostos/app/workspace/runtime/events/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ghostos/app/workspace/runtime/processes/.gitignore b/ghostos/app/workspace/runtime/processes/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/ghostos/app/workspace/runtime/processes/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ghostos/app/workspace/runtime/tasks/.gitignore b/ghostos/app/workspace/runtime/tasks/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/ghostos/app/workspace/runtime/tasks/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ghostos/app/workspace/runtime/threads/.gitignore b/ghostos/app/workspace/runtime/threads/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/ghostos/app/workspace/runtime/threads/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ghostos/core/aifunc/__init__.py b/ghostos/core/aifunc/__init__.py index 0d000183..3690d6ed 100644 --- a/ghostos/core/aifunc/__init__.py +++ b/ghostos/core/aifunc/__init__.py @@ -1,3 +1,7 @@ from ghostos.core.aifunc.driver import DefaultAIFuncDriverImpl -from ghostos.core.aifunc.interfaces import AIFunc, AIFuncResult, AIFuncCtx, AIFuncDriver, AIFuncManager +from ghostos.core.aifunc.interfaces import ( + AIFunc, AIFuncResult, AIFuncCtx, AIFuncDriver, AIFuncManager, +) from ghostos.core.aifunc.manager import DefaultAIFuncManagerImpl, DefaultAIFuncManagerProvider + +from ghostos.core.aifunc.func import get_aifunc_result_type diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index ab189f4a..dd729da3 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -9,7 +9,7 @@ __all__ = [ 'AIFunc', 'AIFuncResult', - 'AIFuncManager', 'AIFuncCtx', 'AIFuncDriver' + 'AIFuncManager', 'AIFuncCtx', 'AIFuncDriver', ] diff --git a/ghostos/prototypes/streamlitapp/design/README.md b/ghostos/prototypes/streamlitapp/design1/README.md similarity index 100% rename from ghostos/prototypes/streamlitapp/design/README.md rename to ghostos/prototypes/streamlitapp/design1/README.md diff --git a/ghostos/prototypes/streamlitapp/design/apps/autobots.py b/ghostos/prototypes/streamlitapp/design1/apps/autobots.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/apps/autobots.py rename to ghostos/prototypes/streamlitapp/design1/apps/autobots.py diff --git a/ghostos/prototypes/streamlitapp/design/apps/chatbots.py b/ghostos/prototypes/streamlitapp/design1/apps/chatbots.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/apps/chatbots.py rename to ghostos/prototypes/streamlitapp/design1/apps/chatbots.py diff --git a/ghostos/prototypes/streamlitapp/design/apps/code_project.py b/ghostos/prototypes/streamlitapp/design1/apps/code_project.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/apps/code_project.py rename to ghostos/prototypes/streamlitapp/design1/apps/code_project.py diff --git a/ghostos/prototypes/streamlitapp/design/apps/streamlit_app.py b/ghostos/prototypes/streamlitapp/design1/apps/streamlit_app.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/apps/streamlit_app.py rename to ghostos/prototypes/streamlitapp/design1/apps/streamlit_app.py diff --git a/ghostos/prototypes/streamlitapp/design/apps/talk_to_db.py b/ghostos/prototypes/streamlitapp/design1/apps/talk_to_db.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/apps/talk_to_db.py rename to ghostos/prototypes/streamlitapp/design1/apps/talk_to_db.py diff --git a/ghostos/prototypes/streamlitapp/design/apps/talk_to_files.py b/ghostos/prototypes/streamlitapp/design1/apps/talk_to_files.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/apps/talk_to_files.py rename to ghostos/prototypes/streamlitapp/design1/apps/talk_to_files.py diff --git a/ghostos/prototypes/streamlitapp/design/home/configs.py b/ghostos/prototypes/streamlitapp/design1/home/configs.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/home/configs.py rename to ghostos/prototypes/streamlitapp/design1/home/configs.py diff --git a/ghostos/prototypes/streamlitapp/design/home/docs.py b/ghostos/prototypes/streamlitapp/design1/home/docs.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/home/docs.py rename to ghostos/prototypes/streamlitapp/design1/home/docs.py diff --git a/ghostos/prototypes/streamlitapp/design/home/ghostos_bot.py b/ghostos/prototypes/streamlitapp/design1/home/ghostos_bot.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/home/ghostos_bot.py rename to ghostos/prototypes/streamlitapp/design1/home/ghostos_bot.py diff --git a/ghostos/prototypes/streamlitapp/design/home/home.py b/ghostos/prototypes/streamlitapp/design1/home/home.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/home/home.py rename to ghostos/prototypes/streamlitapp/design1/home/home.py diff --git a/ghostos/prototypes/streamlitapp/design/home/tools.py b/ghostos/prototypes/streamlitapp/design1/home/tools.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/home/tools.py rename to ghostos/prototypes/streamlitapp/design1/home/tools.py diff --git a/ghostos/prototypes/streamlitapp/design/homepage.py b/ghostos/prototypes/streamlitapp/design1/homepage.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/homepage.py rename to ghostos/prototypes/streamlitapp/design1/homepage.py diff --git a/ghostos/prototypes/streamlitapp/design/resources/ai_funcs.py b/ghostos/prototypes/streamlitapp/design1/resources/ai_funcs.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/resources/ai_funcs.py rename to ghostos/prototypes/streamlitapp/design1/resources/ai_funcs.py diff --git a/ghostos/prototypes/streamlitapp/design/resources/data_objects.py b/ghostos/prototypes/streamlitapp/design1/resources/data_objects.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/resources/data_objects.py rename to ghostos/prototypes/streamlitapp/design1/resources/data_objects.py diff --git a/ghostos/prototypes/streamlitapp/design/resources/knowledge.py b/ghostos/prototypes/streamlitapp/design1/resources/knowledge.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/resources/knowledge.py rename to ghostos/prototypes/streamlitapp/design1/resources/knowledge.py diff --git a/ghostos/prototypes/streamlitapp/design/resources/libraries.py b/ghostos/prototypes/streamlitapp/design1/resources/libraries.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/resources/libraries.py rename to ghostos/prototypes/streamlitapp/design1/resources/libraries.py diff --git a/ghostos/prototypes/streamlitapp/design/resources/llms.py b/ghostos/prototypes/streamlitapp/design1/resources/llms.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/resources/llms.py rename to ghostos/prototypes/streamlitapp/design1/resources/llms.py diff --git a/ghostos/prototypes/streamlitapp/design/resources/moss_files.py b/ghostos/prototypes/streamlitapp/design1/resources/moss_files.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/resources/moss_files.py rename to ghostos/prototypes/streamlitapp/design1/resources/moss_files.py diff --git a/ghostos/prototypes/streamlitapp/design/resources/thoughts.py b/ghostos/prototypes/streamlitapp/design1/resources/thoughts.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design/resources/thoughts.py rename to ghostos/prototypes/streamlitapp/design1/resources/thoughts.py diff --git a/ghostos/prototypes/streamlitapp/patches/__init__.py b/ghostos/prototypes/streamlitapp/patches/__init__.py new file mode 100644 index 00000000..5ec0aec3 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/patches/__init__.py @@ -0,0 +1 @@ +from ghostos.prototypes.streamlitapp.patches.streamlit_pydantic_patch import streamlit_pydantic diff --git a/ghostos/prototypes/streamlitapp/patches/streamlit_pydantic_patch.py b/ghostos/prototypes/streamlitapp/patches/streamlit_pydantic_patch.py new file mode 100644 index 00000000..482f27f7 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/patches/streamlit_pydantic_patch.py @@ -0,0 +1,11 @@ +from pydantic_settings import BaseSettings +import pydantic + +pydantic.BaseSettings = BaseSettings + +# after pydantic patch +import streamlit_pydantic + +__all__ = [ + 'streamlit_pydantic' +] diff --git a/ghostos/prototypes/streamlitapp/tests/aifunc/aifunc_elements.py b/ghostos/prototypes/streamlitapp/tests/aifunc/aifunc_elements.py new file mode 100644 index 00000000..0abad0a3 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/aifunc/aifunc_elements.py @@ -0,0 +1,59 @@ +import streamlit as st +from ghostos.prototypes.streamlitapp.patches import streamlit_pydantic as sp +import inspect + +from ghostos.core.aifunc import get_aifunc_result_type +from ghostos.app.src.aifuncs.weather import WeatherAIFunc, WeatherAIFuncResult + +# source code +st.title("Source Code") +filepath = inspect.getmodule(WeatherAIFunc).__file__ +with open(filepath, "r") as f: + code = f.read() + st.code(code, language="python", line_numbers=True) + +# AiFunc code +aifunc_code = inspect.getsource(WeatherAIFunc) +st.title("AiFunc code") +st.code(aifunc_code, language="python", line_numbers=True) + +# AiFunc result type Code +aifunc_result_type = get_aifunc_result_type(WeatherAIFunc) +aifunc_result_code = inspect.getsource(aifunc_result_type) +st.title("AiFunc result code") +st.code(aifunc_result_code, language="python", line_numbers=True) + +# json schema +st.title("AiFuncs JSON Schema") +st.json(WeatherAIFunc.model_json_schema()) + +# aifunc pydantic output +request = WeatherAIFunc() +st.title("AiFunc output") +sp.pydantic_output(request) + +# aifunc result pydantic output +result = WeatherAIFuncResult() +st.title("AiFuncs Result output") +sp.pydantic_output(result.model_copy()) + +# input form +st.title("Aifunc Input Form") +if input_data := sp.pydantic_input( + "weather_input", + model=WeatherAIFunc, +): + st.json(input_data) + +st.title("Aifunc Input Submit Form") +with st.form(key="aifunc_form"): + data = sp.pydantic_input(key="my_custom_form_model", model=WeatherAIFunc) + submitted = st.form_submit_button(label="Submit") + st.write(f"submitted: {submitted}") + st.json(data) + +st.title("Aifunc Input Submit With Button") +data = sp.pydantic_input(key="aifunc model", model=WeatherAIFunc) +if st.button("run the func"): + obj = WeatherAIFunc(**data) + st.json(obj.model_dump()) diff --git a/poetry.lock b/poetry.lock index 2f385150..b874df85 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1005,6 +1005,25 @@ perf = ["ipython"] test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] +[[package]] +name = "importlib-resources" +version = "6.4.5" +description = "Read resources from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, + {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] +type = ["pytest-mypy"] + [[package]] name = "iniconfig" version = "2.0.0" @@ -2309,6 +2328,26 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-settings" +version = "2.5.2" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_settings-2.5.2-py3-none-any.whl", hash = "sha256:2c912e55fd5794a59bf8c832b9de832dcfdf4778d79ff79b708744eed499a907"}, + {file = "pydantic_settings-2.5.2.tar.gz", hash = "sha256:f90b139682bee4d2065273d5185d71d37ea46cfe57e1b5ae184fc6a0b2484ca0"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" + +[package.extras] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "pydeck" version = "0.9.1" @@ -3066,6 +3105,25 @@ watchdog = {version = ">=2.1.5,<6", markers = "platform_system != \"Darwin\""} [package.extras] snowflake = ["snowflake-connector-python (>=2.8.0)", "snowflake-snowpark-python[modin] (>=1.17.0)"] +[[package]] +name = "streamlit-pydantic" +version = "0.6.0" +description = "Auto-generate Streamlit UI from Pydantic Models & Dataclasses." +optional = false +python-versions = ">=3.7" +files = [ + {file = "streamlit-pydantic-0.6.0.tar.gz", hash = "sha256:3bc5d51af085eb6791b360f569f1a541681ddcc51579b09a1e2ab54639b39d49"}, + {file = "streamlit_pydantic-0.6.0-py3-none-any.whl", hash = "sha256:7a69ec6519f5de1b21bd9737891c61d8fea33d7727824ab19c4c65d49f136304"}, +] + +[package.dependencies] +importlib-resources = "*" +pydantic = ">=1.9" +streamlit = ">=1.14.0" + +[package.extras] +dev = ["black", "build", "colorama", "flake8", "isort", "lazydocs", "mypy", "pydocstyle", "pytest", "pytest-cov", "pytest-mock", "rope", "setuptools", "twine", "types-dataclasses", "universal-build", "wheel"] + [[package]] name = "sympy" version = "1.13.3" @@ -3904,4 +3962,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10, <3.13" -content-hash = "ca152301868c2b0a9504ff70fb5a5bba072cc1c3b4c95302b3e7ec34909c2d82" +content-hash = "dad744a680f96bf5ffb495120e851c688a237d512eb2751a75d15a5e794124b0" diff --git a/pyproject.toml b/pyproject.toml index 382ec84b..647f531b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ arxiv = "^2.1.3" llama-index-core = "^0.11.9" llama-index-llms-openai = "^0.2.7" streamlit = "^1.39.0" +streamlit-pydantic = "^0.6.0" +pydantic-settings = "^2.5.2" [tool.poetry.scripts] init = "ghostos.scripts.init:main" From 537bcb329111c898ac9080af3b9928ef2850e62a Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 5 Oct 2024 23:22:15 +0800 Subject: [PATCH 007/148] dev: add streamlit config to app directory --- ghostos/app/.streamlit/config.toml | 317 +++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 ghostos/app/.streamlit/config.toml diff --git a/ghostos/app/.streamlit/config.toml b/ghostos/app/.streamlit/config.toml new file mode 100644 index 00000000..75cef043 --- /dev/null +++ b/ghostos/app/.streamlit/config.toml @@ -0,0 +1,317 @@ +[global] + +# By default, Streamlit displays a warning when a user sets both a widget +# default value in the function defining the widget and a widget value via +# the widget's key in `st.session_state`. + +# If you'd like to turn off this warning, set this to True. + +# Default: false +# disableWidgetStateDuplicationWarning = false + +# If True, will show a warning when you run a Streamlit-enabled script +# via "python my_script.py". + +# Default: true +# showWarningOnDirectExecution = true + + +[logger] + +# Level of logging for Streamlit's internal logger: "error", "warning", +# "info", or "debug". + +# Default: "info" +# level = "info" + +# String format for logging messages. If logger.datetimeFormat is set, +# logger messages will default to `%(asctime)s.%(msecs)03d %(message)s`. See +# Python's documentation for available attributes: +# https://docs.python.org/3/library/logging.html#formatter-objects + +# Default: "%(asctime)s %(message)s" +# messageFormat = "%(asctime)s %(message)s" + + +[client] + +# Controls whether uncaught app exceptions and deprecation warnings +# are displayed in the browser. By default, this is set to True and +# Streamlit displays app exceptions and associated tracebacks, and +# deprecation warnings, in the browser. + +# If set to False, deprecation warnings and full exception messages +# will print to the console only. Exceptions will still display in the +# browser with a generic error message. For now, the exception type and +# traceback show in the browser also, but they will be removed in the +# future. + +# Default: true +# showErrorDetails = true + +# Change the visibility of items in the toolbar, options menu, +# and settings dialog (top right of the app). + +# Allowed values: +# * "auto" : Show the developer options if the app is accessed through +# localhost or through Streamlit Community Cloud as a developer. +# Hide them otherwise. +# * "developer" : Show the developer options. +# * "viewer" : Hide the developer options. +# * "minimal" : Show only options set externally (e.g. through +# Streamlit Community Cloud) or through st.set_page_config. +# If there are no options left, hide the menu. + +# Default: "auto" +# toolbarMode = "auto" + +# Controls whether to display the default sidebar page navigation in a +# multi-page app. This only applies when app's pages are defined by the +# `pages/` directory. + +# Default: true +# showSidebarNavigation = true + + +[runner] + +# Allows you to type a variable or string by itself in a single line of +# Python code to write it to the app. + +# Default: true +# magicEnabled = true + +# Handle script rerun requests immediately, rather than waiting for script +# execution to reach a yield point. This makes Streamlit much more +# responsive to user interaction, but it can lead to race conditions in +# apps that mutate session_state data outside of explicit session_state +# assignment statements. + +# Default: true +# fastReruns = true + +# Raise an exception after adding unserializable data to Session State. +# Some execution environments may require serializing all data in Session +# State, so it may be useful to detect incompatibility during development, +# or when the execution environment will stop supporting it in the future. + +# Default: false +# enforceSerializableSessionState = false + +# Adjust how certain 'options' widgets like radio, selectbox, and +# multiselect coerce Enum members when the Enum class gets re-defined +# during a script re-run. For more information, check out the docs: +# https://docs.streamlit.io/develop/concepts/design/custom-classes#enums + +# Allowed values: +# * "off": Disables Enum coercion. +# * "nameOnly": Enum classes can be coerced if their member names match. +# * "nameAndValue": Enum classes can be coerced if their member names AND +# member values match. + +# Default: "nameOnly" +# enumCoercion = "nameOnly" + + +[server] + +# List of folders that should not be watched for changes. + +# Relative paths will be taken as relative to the current working directory. + +# Example: ['/home/user1/env', 'relative/path/to/folder'] + +# Default: [] +# folderWatchBlacklist = [] + +# Change the type of file watcher used by Streamlit, or turn it off +# completely. + +# Allowed values: +# * "auto" : Streamlit will attempt to use the watchdog module, and +# falls back to polling if watchdog is not available. +# * "watchdog" : Force Streamlit to use the watchdog module. +# * "poll" : Force Streamlit to always use polling. +# * "none" : Streamlit will not watch files. + +# Default: "auto" +# fileWatcherType = "auto" + +# Symmetric key used to produce signed cookies. If deploying on multiple +# replicas, this should be set to the same value across all replicas to ensure +# they all share the same secret. + +# Default: randomly generated secret key. +# cookieSecret = "9c09822ee0342aa1a2e2e52256b088c48f7f73ba90f97201feeb915856905ec7" + +# If false, will attempt to open a browser window on start. + +# Default: false unless (1) we are on a Linux box where DISPLAY is unset, or +# (2) we are running in the Streamlit Atom plugin. +# headless = false + +# Automatically rerun script when the file is modified on disk. + +# Default: false +# runOnSave = false + +# The address where the server will listen for client and browser +# connections. Use this if you want to bind the server to a specific address. +# If set, the server will only be accessible from this address, and not from +# any aliases (like localhost). + +# Default: (unset) +# address = + +# The port where the server will listen for browser connections. + +# Don't use port 3000 which is reserved for internal development. + +# Default: 8501 +# port = 8501 + +# The base path for the URL where Streamlit should be served from. + +# Default: "" +# baseUrlPath = "" + +# Enables support for Cross-Origin Resource Sharing (CORS) protection, for +# added security. + +# Due to conflicts between CORS and XSRF, if `server.enableXsrfProtection` is +# on and `server.enableCORS` is off at the same time, we will prioritize +# `server.enableXsrfProtection`. + +# Default: true +# enableCORS = true + +# Enables support for Cross-Site Request Forgery (XSRF) protection, for +# added security. + +# Due to conflicts between CORS and XSRF, if `server.enableXsrfProtection` is +# on and `server.enableCORS` is off at the same time, we will prioritize +# `server.enableXsrfProtection`. + +# Default: true +# enableXsrfProtection = true + +# Max size, in megabytes, for files uploaded with the file_uploader. + +# Default: 200 +# maxUploadSize = 200 + +# Max size, in megabytes, of messages that can be sent via the WebSocket +# connection. + +# Default: 200 +# maxMessageSize = 200 + +# Enables support for websocket compression. + +# Default: false +# enableWebsocketCompression = false + +# Enable serving files from a `static` directory in the running app's +# directory. + +# Default: false +# enableStaticServing = false + +# TTL in seconds for sessions whose websockets have been disconnected. The server +# may choose to clean up session state, uploaded files, etc for a given session +# with no active websocket connection at any point after this time has passed. + +# Default: 120 +# disconnectedSessionTTL = 120 + +# Server certificate file for connecting via HTTPS. +# Must be set at the same time as "server.sslKeyFile". + +# ['DO NOT USE THIS OPTION IN A PRODUCTION ENVIRONMENT. It has not gone through security audits or performance tests. For the production environment, we recommend performing SSL termination by the load balancer or the reverse proxy.'] +# sslCertFile = + +# Cryptographic key file for connecting via HTTPS. +# Must be set at the same time as "server.sslCertFile". + +# ['DO NOT USE THIS OPTION IN A PRODUCTION ENVIRONMENT. It has not gone through security audits or performance tests. For the production environment, we recommend performing SSL termination by the load balancer or the reverse proxy.'] +# sslKeyFile = + + +[browser] + +# Internet address where users should point their browsers in order to +# connect to the app. Can be IP address or DNS name and path. + +# This is used to: +# - Set the correct URL for CORS and XSRF protection purposes. +# - Show the URL on the terminal +# - Open the browser + +# Default: "localhost" +# serverAddress = "localhost" + +# Whether to send usage statistics to Streamlit. + +# Default: true +# gatherUsageStats = true + +# Port where users should point their browsers in order to connect to the +# app. + +# This is used to: +# - Set the correct URL for XSRF protection purposes. +# - Show the URL on the terminal (part of `streamlit run`). +# - Open the browser automatically (part of `streamlit run`). + +# This option is for advanced use cases. To change the port of your app, use +# `server.Port` instead. Don't use port 3000 which is reserved for internal +# development. + +# Default: whatever value is set in server.port. +# serverPort = 8501 + + +[mapbox] + +# Configure Streamlit to use a custom Mapbox +# token for elements like st.pydeck_chart and st.map. +# To get a token for yourself, create an account at +# https://mapbox.com. It's free (for moderate usage levels)! + +# Default: "" +# token = "" + + +[theme] + +# The preset Streamlit theme that your custom theme inherits from. +# One of "light" or "dark". +# base = + +# Primary accent color for interactive elements. +# primaryColor = + +# Background color for the main content area. +# backgroundColor = + +# Background color used for the sidebar and most interactive widgets. +# secondaryBackgroundColor = + +# Color used for almost all text. +# textColor = + +# Font family for all text in the app, except code blocks. One of "sans serif", +# "serif", or "monospace". +# font = + + +[secrets] + +# List of locations where secrets are searched. An entry can be a path to a +# TOML file or directory path where Kubernetes style secrets are saved. +# Order is important, import is first to last, so secrets in later files +# will take precedence over earlier ones. + +# Default: [ "/Users/BrightRed/.streamlit/secrets.toml", "/Users/BrightRed/Develop/github.com/ghost-in-moss/GhostOS/.streamlit/secrets.toml",] +# files = [ "/Users/BrightRed/.streamlit/secrets.toml", "/Users/BrightRed/Develop/github.com/ghost-in-moss/GhostOS/.streamlit/secrets.toml",] + From 9868ef38740417564b7117c33e673eaa0c73e1ee Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 7 Oct 2024 15:45:03 +0800 Subject: [PATCH 008/148] refact: move ghostos/app to root directory, and remove poetry.lock --- .gitignore | 2 +- {ghostos/app => app}/.streamlit/config.toml | 0 app/__init__.py | 22 + .../app => app}/workspace/configs/ghosts.yml | 0 .../workspace/configs/llms_conf.yml | 0 .../app => app}/workspace/configs/logging.yml | 0 .../workspace/memories/.gitkeep.py | 0 .../workspace/memories}/__init__.py | 0 .../workspace/runtime/cache/.gitignore | 0 .../workspace/runtime/events/.gitignore | 0 .../workspace/runtime/processes/.gitignore | 0 .../workspace/runtime/tasks/.gitignore | 0 .../workspace/runtime/threads/.gitignore | 0 ghostos/app/console.py | 0 ghostos/app/os.py | 0 ghostos/app/src/aifuncs/__init__.py | 0 ghostos/app/src/aifuncs/agentic.py | 35 - ghostos/app/src/aifuncs/baseline.py | 32 - ghostos/app/src/aifuncs/news.py | 41 - ghostos/app/src/aifuncs/utils.py | 22 - ghostos/app/src/aifuncs/weather.py | 39 - ghostos/app/src/thoughts/__init__.py | 0 ghostos/app/st_app.py | 0 poetry.lock | 3965 ----------------- tests/python/test_restrictedpython.py | 16 - 25 files changed, 23 insertions(+), 4151 deletions(-) rename {ghostos/app => app}/.streamlit/config.toml (100%) create mode 100644 app/__init__.py rename {ghostos/app => app}/workspace/configs/ghosts.yml (100%) rename {ghostos/app => app}/workspace/configs/llms_conf.yml (100%) rename {ghostos/app => app}/workspace/configs/logging.yml (100%) rename {ghostos/app => app}/workspace/memories/.gitkeep.py (100%) rename {ghostos/app => app/workspace/memories}/__init__.py (100%) rename {ghostos/app => app}/workspace/runtime/cache/.gitignore (100%) rename {ghostos/app => app}/workspace/runtime/events/.gitignore (100%) rename {ghostos/app => app}/workspace/runtime/processes/.gitignore (100%) rename {ghostos/app => app}/workspace/runtime/tasks/.gitignore (100%) rename {ghostos/app => app}/workspace/runtime/threads/.gitignore (100%) delete mode 100644 ghostos/app/console.py delete mode 100644 ghostos/app/os.py delete mode 100644 ghostos/app/src/aifuncs/__init__.py delete mode 100644 ghostos/app/src/aifuncs/agentic.py delete mode 100644 ghostos/app/src/aifuncs/baseline.py delete mode 100644 ghostos/app/src/aifuncs/news.py delete mode 100644 ghostos/app/src/aifuncs/utils.py delete mode 100644 ghostos/app/src/aifuncs/weather.py delete mode 100644 ghostos/app/src/thoughts/__init__.py delete mode 100644 ghostos/app/st_app.py delete mode 100644 poetry.lock delete mode 100644 tests/python/test_restrictedpython.py diff --git a/.gitignore b/.gitignore index 53591c3c..0ec92d17 100644 --- a/.gitignore +++ b/.gitignore @@ -103,7 +103,7 @@ ipython_config.py # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock +poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. diff --git a/ghostos/app/.streamlit/config.toml b/app/.streamlit/config.toml similarity index 100% rename from ghostos/app/.streamlit/config.toml rename to app/.streamlit/config.toml diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 00000000..5c9c9d8c --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,22 @@ +from os.path import dirname, join +import sys + +__all__ = [ + 'app_dir', 'workspace_dir', + 'logging_conf_path', 'logger_name', +] + +app_dir = dirname(__file__) +"""application root path""" + +workspace_dir = join(app_dir, 'workspace') +"""workspace root path""" + +logging_conf_path = join(workspace_dir, 'configs/logging.yml') +"""logging configuration file""" + +logger_name = "debug" +"""default logger name for GhostOS""" + +sys.path.append(join(app_dir, 'src')) +"""add application source code to PYTHONPATH""" diff --git a/ghostos/app/workspace/configs/ghosts.yml b/app/workspace/configs/ghosts.yml similarity index 100% rename from ghostos/app/workspace/configs/ghosts.yml rename to app/workspace/configs/ghosts.yml diff --git a/ghostos/app/workspace/configs/llms_conf.yml b/app/workspace/configs/llms_conf.yml similarity index 100% rename from ghostos/app/workspace/configs/llms_conf.yml rename to app/workspace/configs/llms_conf.yml diff --git a/ghostos/app/workspace/configs/logging.yml b/app/workspace/configs/logging.yml similarity index 100% rename from ghostos/app/workspace/configs/logging.yml rename to app/workspace/configs/logging.yml diff --git a/ghostos/app/workspace/memories/.gitkeep.py b/app/workspace/memories/.gitkeep.py similarity index 100% rename from ghostos/app/workspace/memories/.gitkeep.py rename to app/workspace/memories/.gitkeep.py diff --git a/ghostos/app/__init__.py b/app/workspace/memories/__init__.py similarity index 100% rename from ghostos/app/__init__.py rename to app/workspace/memories/__init__.py diff --git a/ghostos/app/workspace/runtime/cache/.gitignore b/app/workspace/runtime/cache/.gitignore similarity index 100% rename from ghostos/app/workspace/runtime/cache/.gitignore rename to app/workspace/runtime/cache/.gitignore diff --git a/ghostos/app/workspace/runtime/events/.gitignore b/app/workspace/runtime/events/.gitignore similarity index 100% rename from ghostos/app/workspace/runtime/events/.gitignore rename to app/workspace/runtime/events/.gitignore diff --git a/ghostos/app/workspace/runtime/processes/.gitignore b/app/workspace/runtime/processes/.gitignore similarity index 100% rename from ghostos/app/workspace/runtime/processes/.gitignore rename to app/workspace/runtime/processes/.gitignore diff --git a/ghostos/app/workspace/runtime/tasks/.gitignore b/app/workspace/runtime/tasks/.gitignore similarity index 100% rename from ghostos/app/workspace/runtime/tasks/.gitignore rename to app/workspace/runtime/tasks/.gitignore diff --git a/ghostos/app/workspace/runtime/threads/.gitignore b/app/workspace/runtime/threads/.gitignore similarity index 100% rename from ghostos/app/workspace/runtime/threads/.gitignore rename to app/workspace/runtime/threads/.gitignore diff --git a/ghostos/app/console.py b/ghostos/app/console.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/app/os.py b/ghostos/app/os.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/app/src/aifuncs/__init__.py b/ghostos/app/src/aifuncs/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/app/src/aifuncs/agentic.py b/ghostos/app/src/aifuncs/agentic.py deleted file mode 100644 index 6e99e9de..00000000 --- a/ghostos/app/src/aifuncs/agentic.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Optional -from ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx -from ghostos.core.moss import Moss as Parent -from ghostos.app.src.aifuncs.weather import WeatherAIFunc -from ghostos.app.src.aifuncs.news import NewsAIFunc -from pydantic import Field - - -class AgentFn(AIFunc): - """ - AIFunc that act like an agent - """ - request: str = Field(description="raw request for the agent") - - -class AgentFnResult(AIFuncResult): - """ - the result that follow the agent request - """ - result: str = Field(description="response from the agent") - err: Optional[str] = Field(default=None, description="error message") - - -class Moss(Parent): - ai_func_ctx: AIFuncCtx - """useful to run AIFunc""" - - -# - - -def __aifunc_instruction__(fn: AgentFn) -> str: - return fn.request - -# diff --git a/ghostos/app/src/aifuncs/baseline.py b/ghostos/app/src/aifuncs/baseline.py deleted file mode 100644 index 2e86fb62..00000000 --- a/ghostos/app/src/aifuncs/baseline.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Optional -from ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx -from ghostos.core.moss import Moss as Parent -from pydantic import Field - - -class AgentFunc(AIFunc): - """ - agent func that act like an agent - """ - pass - - -class AgentFuncResult(AIFuncResult): - """ - the result that follow the agent func instruction - """ - result: str = Field(description="response from the agent func") - err: Optional[str] = Field(default=None, description="error message") - - -class Moss(Parent): - ai_func_ctx: AIFuncCtx - """useful to run AIFunc""" - - -# - - -baseline_case = AgentFunc() - -# diff --git a/ghostos/app/src/aifuncs/news.py b/ghostos/app/src/aifuncs/news.py deleted file mode 100644 index 4acc9743..00000000 --- a/ghostos/app/src/aifuncs/news.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import Optional, List -from ghostos.core.aifunc import AIFunc, AIFuncResult -from pydantic import BaseModel, Field -from ghostos.core.moss import Moss - - -class NewsAIFunc(AIFunc): - """ - search news - """ - query: str = Field(description="required news query.") - limit: int = Field(default=5, description="how many news you want.") - - -class NewsAIFuncResult(AIFuncResult): - """ - news result - """ - - class News(BaseModel): - summary: str = Field(description="summary of the news.") - title: str = Field(description="title of the news.") - date: str = Field(description="date of the news.") - media: str = Field(description="media of the news.") - - results: List[News] = Field(default_factory=list) - - -# - - -def __aifunc_instruction__(fn: NewsAIFunc) -> str: - return ( - "Your task is **MOCKING** a result from the function arguments, make it seems real." - f"the limit of fn is {fn.limit}" - ) - - -example = NewsAIFunc(query="我想知道黑神话悟空这款游戏的媒体评分。") - -# diff --git a/ghostos/app/src/aifuncs/utils.py b/ghostos/app/src/aifuncs/utils.py deleted file mode 100644 index 078eb2a6..00000000 --- a/ghostos/app/src/aifuncs/utils.py +++ /dev/null @@ -1,22 +0,0 @@ -# fake methods for aifunc -from typing import Dict - - -def get_weather(city: str, date: str) -> Dict: - """ - :param city: the city that you want - :param date: the date you want - :return: Dict that contains weather information: - temperature: Optional[float] - humidity: Optional[float] - pressure: Optional[float] - wind_speed: Optional[float] - wind_dir: Optional[int] 0~360 degrees wind direction. 0 is North, 90 is East, etc. - """ - return { - "temperature": 30, - "humidity": 80, - "pressure": 100, - "wind_speed": 6, - "wind_dir": 95, - } diff --git a/ghostos/app/src/aifuncs/weather.py b/ghostos/app/src/aifuncs/weather.py deleted file mode 100644 index 501c76bd..00000000 --- a/ghostos/app/src/aifuncs/weather.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Optional -from ghostos.core.aifunc import AIFunc, AIFuncResult -from ghostos.app.src.aifuncs.utils import get_weather -from pydantic import Field - - -class WeatherAIFunc(AIFunc): - """ - tell about weather - """ - city: str = Field(default="", description="the city name that you want weather forecast. empty means local") - date: str = Field(default="today", description="the date of weather forecast") - - -class WeatherAIFuncResult(AIFuncResult): - """ - weather result - """ - result: str = Field( - default="", - description="the full result describing weather details in nature language form.", - ) - date: str = Field(default="today", description="the date of weather forecast") - city: str = Field(default="", description="the city name that you want weather forecast. empty means local") - temperature: Optional[float] = Field(default=None, description="the temperature of the weather") - humidity: Optional[float] = Field(default=None, description="the humidity of the weather") - pressure: Optional[float] = Field(default=None, description="the pressure of the weather") - wind_speed: Optional[float] = Field(default=None, description="the wind speed of the weather") - wind_dir: Optional[float] = Field(default=None, description="the wind direction of the weather") - - -# - -def __aifunc_instruction__(fn: WeatherAIFunc) -> str: - return "Your task is using get_weather function to get weather information fit the input" - - -example = WeatherAIFunc() -# diff --git a/ghostos/app/src/thoughts/__init__.py b/ghostos/app/src/thoughts/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/app/st_app.py b/ghostos/app/st_app.py deleted file mode 100644 index e69de29b..00000000 diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index b874df85..00000000 --- a/poetry.lock +++ /dev/null @@ -1,3965 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. - -[[package]] -name = "aiohappyeyeballs" -version = "2.4.0" -description = "Happy Eyeballs for asyncio" -optional = false -python-versions = ">=3.8" -files = [ - {file = "aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd"}, - {file = "aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2"}, -] - -[[package]] -name = "aiohttp" -version = "3.10.5" -description = "Async http client/server framework (asyncio)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3"}, - {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6"}, - {file = "aiohttp-3.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f1f1c75c395991ce9c94d3e4aa96e5c59c8356a15b1c9231e783865e2772699"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7acae3cf1a2a2361ec4c8e787eaaa86a94171d2417aae53c0cca6ca3118ff6"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94c4381ffba9cc508b37d2e536b418d5ea9cfdc2848b9a7fea6aebad4ec6aac1"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c31ad0c0c507894e3eaa843415841995bf8de4d6b2d24c6e33099f4bc9fc0d4f"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0912b8a8fadeb32ff67a3ed44249448c20148397c1ed905d5dac185b4ca547bb"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d93400c18596b7dc4794d48a63fb361b01a0d8eb39f28800dc900c8fbdaca91"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3c5e0d764a5c9aa5a62d99728c56d455310bcc288a79cab10157b3af426f"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d742c36ed44f2798c8d3f4bc511f479b9ceef2b93f348671184139e7d708042c"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:814375093edae5f1cb31e3407997cf3eacefb9010f96df10d64829362ae2df69"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8224f98be68a84b19f48e0bdc14224b5a71339aff3a27df69989fa47d01296f3"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9a487ef090aea982d748b1b0d74fe7c3950b109df967630a20584f9a99c0683"}, - {file = "aiohttp-3.10.5-cp310-cp310-win32.whl", hash = "sha256:d9ef084e3dc690ad50137cc05831c52b6ca428096e6deb3c43e95827f531d5ef"}, - {file = "aiohttp-3.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:66bf9234e08fe561dccd62083bf67400bdbf1c67ba9efdc3dac03650e97c6088"}, - {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8c6a4e5e40156d72a40241a25cc226051c0a8d816610097a8e8f517aeacd59a2"}, - {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c634a3207a5445be65536d38c13791904fda0748b9eabf908d3fe86a52941cf"}, - {file = "aiohttp-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4aff049b5e629ef9b3e9e617fa6e2dfeda1bf87e01bcfecaf3949af9e210105e"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1942244f00baaacaa8155eca94dbd9e8cc7017deb69b75ef67c78e89fdad3c77"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04a1f2a65ad2f93aa20f9ff9f1b672bf912413e5547f60749fa2ef8a644e061"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f2bfc0032a00405d4af2ba27f3c429e851d04fad1e5ceee4080a1c570476697"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:424ae21498790e12eb759040bbb504e5e280cab64693d14775c54269fd1d2bb7"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:975218eee0e6d24eb336d0328c768ebc5d617609affaca5dbbd6dd1984f16ed0"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4120d7fefa1e2d8fb6f650b11489710091788de554e2b6f8347c7a20ceb003f5"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b90078989ef3fc45cf9221d3859acd1108af7560c52397ff4ace8ad7052a132e"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ba5a8b74c2a8af7d862399cdedce1533642fa727def0b8c3e3e02fcb52dca1b1"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:02594361128f780eecc2a29939d9dfc870e17b45178a867bf61a11b2a4367277"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4fc029e135859f533025bc82047334e24b0d489e75513144f25408ecaf058"}, - {file = "aiohttp-3.10.5-cp311-cp311-win32.whl", hash = "sha256:e1ca1ef5ba129718a8fc827b0867f6aa4e893c56eb00003b7367f8a733a9b072"}, - {file = "aiohttp-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:349ef8a73a7c5665cca65c88ab24abe75447e28aa3bc4c93ea5093474dfdf0ff"}, - {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:305be5ff2081fa1d283a76113b8df7a14c10d75602a38d9f012935df20731487"}, - {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a1c32a19ee6bbde02f1cb189e13a71b321256cc1d431196a9f824050b160d5a"}, - {file = "aiohttp-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61645818edd40cc6f455b851277a21bf420ce347baa0b86eaa41d51ef58ba23d"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c225286f2b13bab5987425558baa5cbdb2bc925b2998038fa028245ef421e75"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ba01ebc6175e1e6b7275c907a3a36be48a2d487549b656aa90c8a910d9f3178"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8eaf44ccbc4e35762683078b72bf293f476561d8b68ec8a64f98cf32811c323e"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c43eb1ab7cbf411b8e387dc169acb31f0ca0d8c09ba63f9eac67829585b44f"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de7a5299827253023c55ea549444e058c0eb496931fa05d693b95140a947cb73"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4790f0e15f00058f7599dab2b206d3049d7ac464dc2e5eae0e93fa18aee9e7bf"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:44b324a6b8376a23e6ba25d368726ee3bc281e6ab306db80b5819999c737d820"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0d277cfb304118079e7044aad0b76685d30ecb86f83a0711fc5fb257ffe832ca"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:54d9ddea424cd19d3ff6128601a4a4d23d54a421f9b4c0fff740505813739a91"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f1c9866ccf48a6df2b06823e6ae80573529f2af3a0992ec4fe75b1a510df8a6"}, - {file = "aiohttp-3.10.5-cp312-cp312-win32.whl", hash = "sha256:dc4826823121783dccc0871e3f405417ac116055bf184ac04c36f98b75aacd12"}, - {file = "aiohttp-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:22c0a23a3b3138a6bf76fc553789cb1a703836da86b0f306b6f0dc1617398abc"}, - {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7f6b639c36734eaa80a6c152a238242bedcee9b953f23bb887e9102976343092"}, - {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29930bc2921cef955ba39a3ff87d2c4398a0394ae217f41cb02d5c26c8b1b77"}, - {file = "aiohttp-3.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f489a2c9e6455d87eabf907ac0b7d230a9786be43fbe884ad184ddf9e9c1e385"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:123dd5b16b75b2962d0fff566effb7a065e33cd4538c1692fb31c3bda2bfb972"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b98e698dc34966e5976e10bbca6d26d6724e6bdea853c7c10162a3235aba6e16"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3b9162bab7e42f21243effc822652dc5bb5e8ff42a4eb62fe7782bcbcdfacf6"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1923a5c44061bffd5eebeef58cecf68096e35003907d8201a4d0d6f6e387ccaa"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55f011da0a843c3d3df2c2cf4e537b8070a419f891c930245f05d329c4b0689"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:afe16a84498441d05e9189a15900640a2d2b5e76cf4efe8cbb088ab4f112ee57"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8112fb501b1e0567a1251a2fd0747baae60a4ab325a871e975b7bb67e59221f"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1e72589da4c90337837fdfe2026ae1952c0f4a6e793adbbfbdd40efed7c63599"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4d46c7b4173415d8e583045fbc4daa48b40e31b19ce595b8d92cf639396c15d5"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33e6bc4bab477c772a541f76cd91e11ccb6d2efa2b8d7d7883591dfb523e5987"}, - {file = "aiohttp-3.10.5-cp313-cp313-win32.whl", hash = "sha256:c58c6837a2c2a7cf3133983e64173aec11f9c2cd8e87ec2fdc16ce727bcf1a04"}, - {file = "aiohttp-3.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:38172a70005252b6893088c0f5e8a47d173df7cc2b2bd88650957eb84fcf5022"}, - {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f6f18898ace4bcd2d41a122916475344a87f1dfdec626ecde9ee802a711bc569"}, - {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5ede29d91a40ba22ac1b922ef510aab871652f6c88ef60b9dcdf773c6d32ad7a"}, - {file = "aiohttp-3.10.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:673f988370f5954df96cc31fd99c7312a3af0a97f09e407399f61583f30da9bc"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58718e181c56a3c02d25b09d4115eb02aafe1a732ce5714ab70326d9776457c3"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b38b1570242fbab8d86a84128fb5b5234a2f70c2e32f3070143a6d94bc854cf"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:074d1bff0163e107e97bd48cad9f928fa5a3eb4b9d33366137ffce08a63e37fe"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd31f176429cecbc1ba499d4aba31aaccfea488f418d60376b911269d3b883c5"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7384d0b87d4635ec38db9263e6a3f1eb609e2e06087f0aa7f63b76833737b471"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8989f46f3d7ef79585e98fa991e6ded55d2f48ae56d2c9fa5e491a6e4effb589"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:c83f7a107abb89a227d6c454c613e7606c12a42b9a4ca9c5d7dad25d47c776ae"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cde98f323d6bf161041e7627a5fd763f9fd829bcfcd089804a5fdce7bb6e1b7d"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:676f94c5480d8eefd97c0c7e3953315e4d8c2b71f3b49539beb2aa676c58272f"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2d21ac12dc943c68135ff858c3a989f2194a709e6e10b4c8977d7fcd67dfd511"}, - {file = "aiohttp-3.10.5-cp38-cp38-win32.whl", hash = "sha256:17e997105bd1a260850272bfb50e2a328e029c941c2708170d9d978d5a30ad9a"}, - {file = "aiohttp-3.10.5-cp38-cp38-win_amd64.whl", hash = "sha256:1c19de68896747a2aa6257ae4cf6ef59d73917a36a35ee9d0a6f48cff0f94db8"}, - {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7e2fe37ac654032db1f3499fe56e77190282534810e2a8e833141a021faaab0e"}, - {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5bf3ead3cb66ab990ee2561373b009db5bc0e857549b6c9ba84b20bc462e172"}, - {file = "aiohttp-3.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b2c16a919d936ca87a3c5f0e43af12a89a3ce7ccbce59a2d6784caba945b68b"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad146dae5977c4dd435eb31373b3fe9b0b1bf26858c6fc452bf6af394067e10b"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c5c6fa16412b35999320f5c9690c0f554392dc222c04e559217e0f9ae244b92"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95c4dc6f61d610bc0ee1edc6f29d993f10febfe5b76bb470b486d90bbece6b22"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da452c2c322e9ce0cfef392e469a26d63d42860f829026a63374fde6b5c5876f"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:898715cf566ec2869d5cb4d5fb4be408964704c46c96b4be267442d265390f32"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:391cc3a9c1527e424c6865e087897e766a917f15dddb360174a70467572ac6ce"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:380f926b51b92d02a34119d072f178d80bbda334d1a7e10fa22d467a66e494db"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce91db90dbf37bb6fa0997f26574107e1b9d5ff939315247b7e615baa8ec313b"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9093a81e18c45227eebe4c16124ebf3e0d893830c6aca7cc310bfca8fe59d857"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ee40b40aa753d844162dcc80d0fe256b87cba48ca0054f64e68000453caead11"}, - {file = "aiohttp-3.10.5-cp39-cp39-win32.whl", hash = "sha256:03f2645adbe17f274444953bdea69f8327e9d278d961d85657cb0d06864814c1"}, - {file = "aiohttp-3.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:d17920f18e6ee090bdd3d0bfffd769d9f2cb4c8ffde3eb203777a3895c128862"}, - {file = "aiohttp-3.10.5.tar.gz", hash = "sha256:f071854b47d39591ce9a17981c46790acb30518e2f83dfca8db2dfa091178691"}, -] - -[package.dependencies] -aiohappyeyeballs = ">=2.3.0" -aiosignal = ">=1.1.2" -async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} -attrs = ">=17.3.0" -frozenlist = ">=1.1.1" -multidict = ">=4.5,<7.0" -yarl = ">=1.0,<2.0" - -[package.extras] -speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] - -[[package]] -name = "aiosignal" -version = "1.3.1" -description = "aiosignal: a list of registered asynchronous callbacks" -optional = false -python-versions = ">=3.7" -files = [ - {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, - {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, -] - -[package.dependencies] -frozenlist = ">=1.1.0" - -[[package]] -name = "altair" -version = "5.4.1" -description = "Vega-Altair: A declarative statistical visualization library for Python." -optional = false -python-versions = ">=3.8" -files = [ - {file = "altair-5.4.1-py3-none-any.whl", hash = "sha256:0fb130b8297a569d08991fb6fe763582e7569f8a04643bbd9212436e3be04aef"}, - {file = "altair-5.4.1.tar.gz", hash = "sha256:0ce8c2e66546cb327e5f2d7572ec0e7c6feece816203215613962f0ec1d76a82"}, -] - -[package.dependencies] -jinja2 = "*" -jsonschema = ">=3.0" -narwhals = ">=1.5.2" -packaging = "*" -typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} - -[package.extras] -all = ["altair-tiles (>=0.3.0)", "anywidget (>=0.9.0)", "numpy", "pandas (>=0.25.3)", "pyarrow (>=11)", "vega-datasets (>=0.9.0)", "vegafusion[embed] (>=1.6.6)", "vl-convert-python (>=1.6.0)"] -dev = ["geopandas", "hatch", "ibis-framework[polars]", "ipython[kernel]", "mistune", "mypy", "pandas (>=0.25.3)", "pandas-stubs", "polars (>=0.20.3)", "pytest", "pytest-cov", "pytest-xdist[psutil] (>=3.5,<4.0)", "ruff (>=0.6.0)", "types-jsonschema", "types-setuptools"] -doc = ["docutils", "jinja2", "myst-parser", "numpydoc", "pillow (>=9,<10)", "pydata-sphinx-theme (>=0.14.1)", "scipy", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinxext-altair"] - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "anthropic" -version = "0.31.2" -description = "The official Python library for the anthropic API" -optional = false -python-versions = ">=3.7" -files = [ - {file = "anthropic-0.31.2-py3-none-any.whl", hash = "sha256:28d176b98c72615bfae30f0a9eee6297cc33bf52535d38156fc2805556e2f09b"}, - {file = "anthropic-0.31.2.tar.gz", hash = "sha256:0134b73df8d1f142fc68675fbadb75e920054e9e3437b99df63f10f0fc6ac26f"}, -] - -[package.dependencies] -anyio = ">=3.5.0,<5" -distro = ">=1.7.0,<2" -httpx = ">=0.23.0,<1" -jiter = ">=0.4.0,<1" -pydantic = ">=1.9.0,<3" -sniffio = "*" -tokenizers = ">=0.13.0" -typing-extensions = ">=4.7,<5" - -[package.extras] -bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"] -vertex = ["google-auth (>=2,<3)"] - -[[package]] -name = "anyio" -version = "4.5.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.8" -files = [ - {file = "anyio-4.5.0-py3-none-any.whl", hash = "sha256:fdeb095b7cc5a5563175eedd926ec4ae55413bb4be5770c424af0ba46ccb4a78"}, - {file = "anyio-4.5.0.tar.gz", hash = "sha256:c5a275fe5ca0afd788001f58fca1e69e29ce706d746e317d660e21f70c530ef9"}, -] - -[package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} - -[package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.21.0b1)"] -trio = ["trio (>=0.26.1)"] - -[[package]] -name = "arxiv" -version = "2.1.3" -description = "Python wrapper for the arXiv API: https://arxiv.org/help/api/" -optional = false -python-versions = ">=3.7" -files = [ - {file = "arxiv-2.1.3-py3-none-any.whl", hash = "sha256:6f43673ab770a9e848d7d4fc1894824df55edeac3c3572ea280c9ba2e3c0f39f"}, - {file = "arxiv-2.1.3.tar.gz", hash = "sha256:32365221994d2cf05657c1fadf63a26efc8ccdec18590281ee03515bfef8bc4e"}, -] - -[package.dependencies] -feedparser = ">=6.0.10,<6.1.0" -requests = ">=2.32.0,<2.33.0" - -[[package]] -name = "async-timeout" -version = "4.0.3" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.7" -files = [ - {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, - {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, -] - -[[package]] -name = "attrs" -version = "24.2.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.7" -files = [ - {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, - {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, -] - -[package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] - -[[package]] -name = "blinker" -version = "1.8.2" -description = "Fast, simple object-to-object and broadcast signaling" -optional = false -python-versions = ">=3.8" -files = [ - {file = "blinker-1.8.2-py3-none-any.whl", hash = "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01"}, - {file = "blinker-1.8.2.tar.gz", hash = "sha256:8f77b09d3bf7c795e969e9486f39c2c5e9c39d4ee07424be2bc594ece9642d83"}, -] - -[[package]] -name = "cachetools" -version = "5.5.0" -description = "Extensible memoizing collections and decorators" -optional = false -python-versions = ">=3.7" -files = [ - {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, - {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, -] - -[[package]] -name = "certifi" -version = "2024.8.30" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.3.2" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, -] - -[[package]] -name = "click" -version = "8.1.7" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "dataclasses-json" -version = "0.6.7" -description = "Easily serialize dataclasses to and from JSON." -optional = false -python-versions = "<4.0,>=3.7" -files = [ - {file = "dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a"}, - {file = "dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0"}, -] - -[package.dependencies] -marshmallow = ">=3.18.0,<4.0.0" -typing-inspect = ">=0.4.0,<1" - -[[package]] -name = "datasets" -version = "2.21.0" -description = "HuggingFace community-driven open-source library of datasets" -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "datasets-2.21.0-py3-none-any.whl", hash = "sha256:25e4e097110ce28824b746a107727ada94024cba11db8bc588d468414692b65a"}, - {file = "datasets-2.21.0.tar.gz", hash = "sha256:998f85a8460f1bd982e5bd058f8a0808eef424249e3df1e8cdd594ccd0dc8ba2"}, -] - -[package.dependencies] -aiohttp = "*" -dill = ">=0.3.0,<0.3.9" -filelock = "*" -fsspec = {version = ">=2023.1.0,<=2024.6.1", extras = ["http"]} -huggingface-hub = ">=0.21.2" -multiprocess = "*" -numpy = ">=1.17" -packaging = "*" -pandas = "*" -pyarrow = ">=15.0.0" -pyyaml = ">=5.1" -requests = ">=2.32.2" -tqdm = ">=4.66.3" -xxhash = "*" - -[package.extras] -apache-beam = ["apache-beam (>=2.26.0)"] -audio = ["librosa", "soundfile (>=0.12.1)", "soxr (>=0.4.0)"] -benchmarks = ["tensorflow (==2.12.0)", "torch (==2.0.1)", "transformers (==4.30.1)"] -dev = ["Pillow (>=9.4.0)", "absl-py", "decorator", "elasticsearch (<8.0.0)", "faiss-cpu (>=1.8.0.post1)", "jax (>=0.3.14)", "jaxlib (>=0.3.14)", "joblib (<1.3.0)", "joblibspark", "librosa", "lz4", "moto[server]", "polars[timezone] (>=0.20.0)", "protobuf (<4.0.0)", "py7zr", "pyspark (>=3.4)", "pytest", "pytest-datadir", "pytest-xdist", "rarfile (>=4.0)", "ruff (>=0.3.0)", "s3fs", "s3fs (>=2021.11.1)", "soundfile (>=0.12.1)", "soxr (>=0.4.0)", "sqlalchemy", "tensorflow (>=2.16.0)", "tensorflow (>=2.6.0)", "tensorflow (>=2.6.0)", "tiktoken", "torch", "torch (>=2.0.0)", "transformers", "transformers (>=4.42.0)", "typing-extensions (>=4.6.1)", "zstandard"] -docs = ["s3fs", "tensorflow (>=2.6.0)", "torch", "transformers"] -jax = ["jax (>=0.3.14)", "jaxlib (>=0.3.14)"] -metrics-tests = ["Werkzeug (>=1.0.1)", "accelerate", "bert-score (>=0.3.6)", "jiwer", "langdetect", "mauve-text", "nltk (<3.8.2)", "requests-file (>=1.5.1)", "rouge-score", "sacrebleu", "sacremoses", "scikit-learn", "scipy", "sentencepiece", "seqeval", "six (>=1.15.0,<1.16.0)", "spacy (>=3.0.0)", "texttable (>=1.6.3)", "tldextract", "tldextract (>=3.1.0)", "toml (>=0.10.1)", "typer (<0.5.0)"] -quality = ["ruff (>=0.3.0)"] -s3 = ["s3fs"] -tensorflow = ["tensorflow (>=2.6.0)"] -tensorflow-gpu = ["tensorflow (>=2.6.0)"] -tests = ["Pillow (>=9.4.0)", "absl-py", "decorator", "elasticsearch (<8.0.0)", "faiss-cpu (>=1.8.0.post1)", "jax (>=0.3.14)", "jaxlib (>=0.3.14)", "joblib (<1.3.0)", "joblibspark", "librosa", "lz4", "moto[server]", "polars[timezone] (>=0.20.0)", "protobuf (<4.0.0)", "py7zr", "pyspark (>=3.4)", "pytest", "pytest-datadir", "pytest-xdist", "rarfile (>=4.0)", "s3fs (>=2021.11.1)", "soundfile (>=0.12.1)", "soxr (>=0.4.0)", "sqlalchemy", "tensorflow (>=2.16.0)", "tensorflow (>=2.6.0)", "tiktoken", "torch (>=2.0.0)", "transformers (>=4.42.0)", "typing-extensions (>=4.6.1)", "zstandard"] -tests-numpy2 = ["Pillow (>=9.4.0)", "absl-py", "decorator", "elasticsearch (<8.0.0)", "jax (>=0.3.14)", "jaxlib (>=0.3.14)", "joblib (<1.3.0)", "joblibspark", "librosa", "lz4", "moto[server]", "polars[timezone] (>=0.20.0)", "protobuf (<4.0.0)", "py7zr", "pyspark (>=3.4)", "pytest", "pytest-datadir", "pytest-xdist", "rarfile (>=4.0)", "s3fs (>=2021.11.1)", "soundfile (>=0.12.1)", "soxr (>=0.4.0)", "sqlalchemy", "tiktoken", "torch (>=2.0.0)", "typing-extensions (>=4.6.1)", "zstandard"] -torch = ["torch"] -vision = ["Pillow (>=9.4.0)"] - -[[package]] -name = "deprecated" -version = "1.2.14" -description = "Python @deprecated decorator to deprecate old python classes, functions or methods." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, - {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, -] - -[package.dependencies] -wrapt = ">=1.10,<2" - -[package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] - -[[package]] -name = "dill" -version = "0.3.8" -description = "serialize all of Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, - {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, -] - -[package.extras] -graph = ["objgraph (>=1.7.2)"] -profile = ["gprof2dot (>=2022.7.29)"] - -[[package]] -name = "dirtyjson" -version = "1.0.8" -description = "JSON decoder for Python that can extract data from the muck" -optional = false -python-versions = "*" -files = [ - {file = "dirtyjson-1.0.8-py3-none-any.whl", hash = "sha256:125e27248435a58acace26d5c2c4c11a1c0de0a9c5124c5a94ba78e517d74f53"}, - {file = "dirtyjson-1.0.8.tar.gz", hash = "sha256:90ca4a18f3ff30ce849d100dcf4a003953c79d3a2348ef056f1d9c22231a25fd"}, -] - -[[package]] -name = "distro" -version = "1.9.0" -description = "Distro - an OS platform information API" -optional = false -python-versions = ">=3.6" -files = [ - {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, - {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "feedparser" -version = "6.0.11" -description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" -optional = false -python-versions = ">=3.6" -files = [ - {file = "feedparser-6.0.11-py3-none-any.whl", hash = "sha256:0be7ee7b395572b19ebeb1d6aafb0028dee11169f1c934e0ed67d54992f4ad45"}, - {file = "feedparser-6.0.11.tar.gz", hash = "sha256:c9d0407b64c6f2a065d0ebb292c2b35c01050cc0dc33757461aaabdc4c4184d5"}, -] - -[package.dependencies] -sgmllib3k = "*" - -[[package]] -name = "filelock" -version = "3.16.1" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.8" -files = [ - {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, - {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] -typing = ["typing-extensions (>=4.12.2)"] - -[[package]] -name = "frozenlist" -version = "1.4.1" -description = "A list-like structure which implements collections.abc.MutableSequence" -optional = false -python-versions = ">=3.8" -files = [ - {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, - {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, - {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, - {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, - {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, - {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, - {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, - {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, - {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, - {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, - {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, - {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, - {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, - {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, - {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, - {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, - {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, - {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, - {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, - {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, - {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, - {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, - {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, - {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, - {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, - {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, - {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, -] - -[[package]] -name = "fsspec" -version = "2024.6.1" -description = "File-system specification" -optional = false -python-versions = ">=3.8" -files = [ - {file = "fsspec-2024.6.1-py3-none-any.whl", hash = "sha256:3cb443f8bcd2efb31295a5b9fdb02aee81d8452c80d28f97a6d0959e6cee101e"}, - {file = "fsspec-2024.6.1.tar.gz", hash = "sha256:fad7d7e209dd4c1208e3bbfda706620e0da5142bebbd9c384afb95b07e798e49"}, -] - -[package.dependencies] -aiohttp = {version = "<4.0.0a0 || >4.0.0a0,<4.0.0a1 || >4.0.0a1", optional = true, markers = "extra == \"http\""} - -[package.extras] -abfs = ["adlfs"] -adl = ["adlfs"] -arrow = ["pyarrow (>=1)"] -dask = ["dask", "distributed"] -dev = ["pre-commit", "ruff"] -doc = ["numpydoc", "sphinx", "sphinx-design", "sphinx-rtd-theme", "yarl"] -dropbox = ["dropbox", "dropboxdrivefs", "requests"] -full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "dask", "distributed", "dropbox", "dropboxdrivefs", "fusepy", "gcsfs", "libarchive-c", "ocifs", "panel", "paramiko", "pyarrow (>=1)", "pygit2", "requests", "s3fs", "smbprotocol", "tqdm"] -fuse = ["fusepy"] -gcs = ["gcsfs"] -git = ["pygit2"] -github = ["requests"] -gs = ["gcsfs"] -gui = ["panel"] -hdfs = ["pyarrow (>=1)"] -http = ["aiohttp (!=4.0.0a0,!=4.0.0a1)"] -libarchive = ["libarchive-c"] -oci = ["ocifs"] -s3 = ["s3fs"] -sftp = ["paramiko"] -smb = ["smbprotocol"] -ssh = ["paramiko"] -test = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "numpy", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "requests"] -test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask-expr", "dask[dataframe,test]", "moto[server] (>4,<5)", "pytest-timeout", "xarray"] -test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard"] -tqdm = ["tqdm"] - -[[package]] -name = "gitdb" -version = "4.0.11" -description = "Git Object Database" -optional = false -python-versions = ">=3.7" -files = [ - {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"}, - {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"}, -] - -[package.dependencies] -smmap = ">=3.0.1,<6" - -[[package]] -name = "gitpython" -version = "3.1.43" -description = "GitPython is a Python library used to interact with Git repositories" -optional = false -python-versions = ">=3.7" -files = [ - {file = "GitPython-3.1.43-py3-none-any.whl", hash = "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff"}, - {file = "GitPython-3.1.43.tar.gz", hash = "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c"}, -] - -[package.dependencies] -gitdb = ">=4.0.1,<5" - -[package.extras] -doc = ["sphinx (==4.3.2)", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "sphinxcontrib-applehelp (>=1.0.2,<=1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (>=2.0.0,<=2.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)"] -test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions"] - -[[package]] -name = "greenlet" -version = "3.1.1" -description = "Lightweight in-process concurrent programming" -optional = false -python-versions = ">=3.7" -files = [ - {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617"}, - {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7"}, - {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6"}, - {file = "greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80"}, - {file = "greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a"}, - {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511"}, - {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395"}, - {file = "greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39"}, - {file = "greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9"}, - {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0"}, - {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942"}, - {file = "greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01"}, - {file = "greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e"}, - {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1"}, - {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c"}, - {file = "greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822"}, - {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01"}, - {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de"}, - {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa"}, - {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af"}, - {file = "greenlet-3.1.1-cp37-cp37m-win32.whl", hash = "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798"}, - {file = "greenlet-3.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef"}, - {file = "greenlet-3.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1"}, - {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd"}, - {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7"}, - {file = "greenlet-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef"}, - {file = "greenlet-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d"}, - {file = "greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c"}, - {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e"}, - {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e"}, - {file = "greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c"}, - {file = "greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22"}, - {file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"}, -] - -[package.extras] -docs = ["Sphinx", "furo"] -test = ["objgraph", "psutil"] - -[[package]] -name = "grep-ast" -version = "0.3.3" -description = "A tool to grep through the AST of a source file" -optional = false -python-versions = "*" -files = [ - {file = "grep_ast-0.3.3-py3-none-any.whl", hash = "sha256:515cb889bffefefa26c4ab1377b9a75b3fc678aa5fa02bf9aa4f8f20999a83ad"}, - {file = "grep_ast-0.3.3.tar.gz", hash = "sha256:42b8887d57301dc55634368f8d549e9c49c913dafb4d19c9b54c3ddb604fccf4"}, -] - -[package.dependencies] -pathspec = "*" -tree-sitter-languages = ">=1.8.0" - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.7" -files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, -] - -[[package]] -name = "hide-py" -version = "0.3.0" -description = "Hide: Headless IDE for coding agents" -optional = false -python-versions = "<4.0,>=3.10" -files = [ - {file = "hide_py-0.3.0-py3-none-any.whl", hash = "sha256:5b3f68e206d721c83a0d027b733fabb51ad406783883f71fccbb7d682a63353d"}, - {file = "hide_py-0.3.0.tar.gz", hash = "sha256:3509f88d05e479580ba87300a096265bd4cf183d40b3fa39233eb5f95d1f3179"}, -] - -[package.dependencies] -langchain = ">=0.1.16,<0.2.0" -langchain-openai = ">=0.1.3,<0.2.0" -langchainhub = ">=0.1.15,<0.2.0" -pydantic = ">=2.7.3,<3.0.0" -pyjson5 = ">=1.6.6,<2.0.0" -requests = ">=2.31.0,<3.0.0" - -[[package]] -name = "httpcore" -version = "1.0.5" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, - {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.13,<0.15" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<0.26.0)"] - -[[package]] -name = "httpx" -version = "0.27.2" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, - {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" -sniffio = "*" - -[package.extras] -brotli = ["brotli", "brotlicffi"] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "httpx-socks" -version = "0.9.1" -description = "Proxy (HTTP, SOCKS) transports for httpx" -optional = false -python-versions = "*" -files = [ - {file = "httpx-socks-0.9.1.tar.gz", hash = "sha256:80ab86bad96fdcbb44b59940f2d3218577a7f09a6d4fdeb2ebaf9ccdff4748a9"}, - {file = "httpx_socks-0.9.1-py3-none-any.whl", hash = "sha256:d01dabfdf4da2a8d6c82986ddcfdbb5799a32a21eda0a0639934caf9411bf4a5"}, -] - -[package.dependencies] -httpcore = ">=0.17.3,<2.0" -httpx = ">=0.21.0,<0.28.0" -python-socks = ">=2.0.0" - -[package.extras] -asyncio = ["async-timeout (>=3.0.1)"] -trio = ["trio (>=0.16.0)"] - -[[package]] -name = "huggingface-hub" -version = "0.25.0" -description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "huggingface_hub-0.25.0-py3-none-any.whl", hash = "sha256:e2f357b35d72d5012cfd127108c4e14abcd61ba4ebc90a5a374dc2456cb34e12"}, - {file = "huggingface_hub-0.25.0.tar.gz", hash = "sha256:fb5fbe6c12fcd99d187ec7db95db9110fb1a20505f23040a5449a717c1a0db4d"}, -] - -[package.dependencies] -filelock = "*" -fsspec = ">=2023.5.0" -packaging = ">=20.9" -pyyaml = ">=5.1" -requests = "*" -tqdm = ">=4.42.1" -typing-extensions = ">=3.7.4.3" - -[package.extras] -all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio", "jedi", "minijinja (>=1.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] -cli = ["InquirerPy (==0.3.4)"] -dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio", "jedi", "minijinja (>=1.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] -fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] -hf-transfer = ["hf-transfer (>=0.1.4)"] -inference = ["aiohttp", "minijinja (>=1.0)"] -quality = ["mypy (==1.5.1)", "ruff (>=0.5.0)"] -tensorflow = ["graphviz", "pydot", "tensorflow"] -tensorflow-testing = ["keras (<3.0)", "tensorflow"] -testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio", "jedi", "minijinja (>=1.0)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] -torch = ["safetensors[torch]", "torch"] -typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "importlib-metadata" -version = "8.5.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, - {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - -[[package]] -name = "importlib-resources" -version = "6.4.5" -description = "Read resources from Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, - {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] -type = ["pytest-mypy"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "jinja2" -version = "3.1.4" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "jiter" -version = "0.5.0" -description = "Fast iterable JSON parser." -optional = false -python-versions = ">=3.8" -files = [ - {file = "jiter-0.5.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b599f4e89b3def9a94091e6ee52e1d7ad7bc33e238ebb9c4c63f211d74822c3f"}, - {file = "jiter-0.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a063f71c4b06225543dddadbe09d203dc0c95ba352d8b85f1221173480a71d5"}, - {file = "jiter-0.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc0d5b8b3dd12e91dd184b87273f864b363dfabc90ef29a1092d269f18c7e28"}, - {file = "jiter-0.5.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c22541f0b672f4d741382a97c65609332a783501551445ab2df137ada01e019e"}, - {file = "jiter-0.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63314832e302cc10d8dfbda0333a384bf4bcfce80d65fe99b0f3c0da8945a91a"}, - {file = "jiter-0.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a25fbd8a5a58061e433d6fae6d5298777c0814a8bcefa1e5ecfff20c594bd749"}, - {file = "jiter-0.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:503b2c27d87dfff5ab717a8200fbbcf4714516c9d85558048b1fc14d2de7d8dc"}, - {file = "jiter-0.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d1f3d27cce923713933a844872d213d244e09b53ec99b7a7fdf73d543529d6d"}, - {file = "jiter-0.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c95980207b3998f2c3b3098f357994d3fd7661121f30669ca7cb945f09510a87"}, - {file = "jiter-0.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:afa66939d834b0ce063f57d9895e8036ffc41c4bd90e4a99631e5f261d9b518e"}, - {file = "jiter-0.5.0-cp310-none-win32.whl", hash = "sha256:f16ca8f10e62f25fd81d5310e852df6649af17824146ca74647a018424ddeccf"}, - {file = "jiter-0.5.0-cp310-none-win_amd64.whl", hash = "sha256:b2950e4798e82dd9176935ef6a55cf6a448b5c71515a556da3f6b811a7844f1e"}, - {file = "jiter-0.5.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d4c8e1ed0ef31ad29cae5ea16b9e41529eb50a7fba70600008e9f8de6376d553"}, - {file = "jiter-0.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6f16e21276074a12d8421692515b3fd6d2ea9c94fd0734c39a12960a20e85f3"}, - {file = "jiter-0.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5280e68e7740c8c128d3ae5ab63335ce6d1fb6603d3b809637b11713487af9e6"}, - {file = "jiter-0.5.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:583c57fc30cc1fec360e66323aadd7fc3edeec01289bfafc35d3b9dcb29495e4"}, - {file = "jiter-0.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26351cc14507bdf466b5f99aba3df3143a59da75799bf64a53a3ad3155ecded9"}, - {file = "jiter-0.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829df14d656b3fb87e50ae8b48253a8851c707da9f30d45aacab2aa2ba2d614"}, - {file = "jiter-0.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a42a4bdcf7307b86cb863b2fb9bb55029b422d8f86276a50487982d99eed7c6e"}, - {file = "jiter-0.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04d461ad0aebf696f8da13c99bc1b3e06f66ecf6cfd56254cc402f6385231c06"}, - {file = "jiter-0.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e6375923c5f19888c9226582a124b77b622f8fd0018b843c45eeb19d9701c403"}, - {file = "jiter-0.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2cec323a853c24fd0472517113768c92ae0be8f8c384ef4441d3632da8baa646"}, - {file = "jiter-0.5.0-cp311-none-win32.whl", hash = "sha256:aa1db0967130b5cab63dfe4d6ff547c88b2a394c3410db64744d491df7f069bb"}, - {file = "jiter-0.5.0-cp311-none-win_amd64.whl", hash = "sha256:aa9d2b85b2ed7dc7697597dcfaac66e63c1b3028652f751c81c65a9f220899ae"}, - {file = "jiter-0.5.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9f664e7351604f91dcdd557603c57fc0d551bc65cc0a732fdacbf73ad335049a"}, - {file = "jiter-0.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:044f2f1148b5248ad2c8c3afb43430dccf676c5a5834d2f5089a4e6c5bbd64df"}, - {file = "jiter-0.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:702e3520384c88b6e270c55c772d4bd6d7b150608dcc94dea87ceba1b6391248"}, - {file = "jiter-0.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:528d742dcde73fad9d63e8242c036ab4a84389a56e04efd854062b660f559544"}, - {file = "jiter-0.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cf80e5fe6ab582c82f0c3331df27a7e1565e2dcf06265afd5173d809cdbf9ba"}, - {file = "jiter-0.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:44dfc9ddfb9b51a5626568ef4e55ada462b7328996294fe4d36de02fce42721f"}, - {file = "jiter-0.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c451f7922992751a936b96c5f5b9bb9312243d9b754c34b33d0cb72c84669f4e"}, - {file = "jiter-0.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:308fce789a2f093dca1ff91ac391f11a9f99c35369117ad5a5c6c4903e1b3e3a"}, - {file = "jiter-0.5.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7f5ad4a7c6b0d90776fdefa294f662e8a86871e601309643de30bf94bb93a64e"}, - {file = "jiter-0.5.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ea189db75f8eca08807d02ae27929e890c7d47599ce3d0a6a5d41f2419ecf338"}, - {file = "jiter-0.5.0-cp312-none-win32.whl", hash = "sha256:e3bbe3910c724b877846186c25fe3c802e105a2c1fc2b57d6688b9f8772026e4"}, - {file = "jiter-0.5.0-cp312-none-win_amd64.whl", hash = "sha256:a586832f70c3f1481732919215f36d41c59ca080fa27a65cf23d9490e75b2ef5"}, - {file = "jiter-0.5.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:f04bc2fc50dc77be9d10f73fcc4e39346402ffe21726ff41028f36e179b587e6"}, - {file = "jiter-0.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6f433a4169ad22fcb550b11179bb2b4fd405de9b982601914ef448390b2954f3"}, - {file = "jiter-0.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad4a6398c85d3a20067e6c69890ca01f68659da94d74c800298581724e426c7e"}, - {file = "jiter-0.5.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6baa88334e7af3f4d7a5c66c3a63808e5efbc3698a1c57626541ddd22f8e4fbf"}, - {file = "jiter-0.5.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ece0a115c05efca597c6d938f88c9357c843f8c245dbbb53361a1c01afd7148"}, - {file = "jiter-0.5.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:335942557162ad372cc367ffaf93217117401bf930483b4b3ebdb1223dbddfa7"}, - {file = "jiter-0.5.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649b0ee97a6e6da174bffcb3c8c051a5935d7d4f2f52ea1583b5b3e7822fbf14"}, - {file = "jiter-0.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f4be354c5de82157886ca7f5925dbda369b77344b4b4adf2723079715f823989"}, - {file = "jiter-0.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5206144578831a6de278a38896864ded4ed96af66e1e63ec5dd7f4a1fce38a3a"}, - {file = "jiter-0.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8120c60f8121ac3d6f072b97ef0e71770cc72b3c23084c72c4189428b1b1d3b6"}, - {file = "jiter-0.5.0-cp38-none-win32.whl", hash = "sha256:6f1223f88b6d76b519cb033a4d3687ca157c272ec5d6015c322fc5b3074d8a5e"}, - {file = "jiter-0.5.0-cp38-none-win_amd64.whl", hash = "sha256:c59614b225d9f434ea8fc0d0bec51ef5fa8c83679afedc0433905994fb36d631"}, - {file = "jiter-0.5.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:0af3838cfb7e6afee3f00dc66fa24695199e20ba87df26e942820345b0afc566"}, - {file = "jiter-0.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:550b11d669600dbc342364fd4adbe987f14d0bbedaf06feb1b983383dcc4b961"}, - {file = "jiter-0.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:489875bf1a0ffb3cb38a727b01e6673f0f2e395b2aad3c9387f94187cb214bbf"}, - {file = "jiter-0.5.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b250ca2594f5599ca82ba7e68785a669b352156260c5362ea1b4e04a0f3e2389"}, - {file = "jiter-0.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ea18e01f785c6667ca15407cd6dabbe029d77474d53595a189bdc813347218e"}, - {file = "jiter-0.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:462a52be85b53cd9bffd94e2d788a09984274fe6cebb893d6287e1c296d50653"}, - {file = "jiter-0.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92cc68b48d50fa472c79c93965e19bd48f40f207cb557a8346daa020d6ba973b"}, - {file = "jiter-0.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c834133e59a8521bc87ebcad773608c6fa6ab5c7a022df24a45030826cf10bc"}, - {file = "jiter-0.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab3a71ff31cf2d45cb216dc37af522d335211f3a972d2fe14ea99073de6cb104"}, - {file = "jiter-0.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cccd3af9c48ac500c95e1bcbc498020c87e1781ff0345dd371462d67b76643eb"}, - {file = "jiter-0.5.0-cp39-none-win32.whl", hash = "sha256:368084d8d5c4fc40ff7c3cc513c4f73e02c85f6009217922d0823a48ee7adf61"}, - {file = "jiter-0.5.0-cp39-none-win_amd64.whl", hash = "sha256:ce03f7b4129eb72f1687fa11300fbf677b02990618428934662406d2a76742a1"}, - {file = "jiter-0.5.0.tar.gz", hash = "sha256:1d916ba875bcab5c5f7d927df998c4cb694d27dceddf3392e58beaf10563368a"}, -] - -[[package]] -name = "joblib" -version = "1.4.2" -description = "Lightweight pipelining with Python functions" -optional = false -python-versions = ">=3.8" -files = [ - {file = "joblib-1.4.2-py3-none-any.whl", hash = "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6"}, - {file = "joblib-1.4.2.tar.gz", hash = "sha256:2382c5816b2636fbd20a09e0f4e9dad4736765fdfb7dca582943b9c1366b3f0e"}, -] - -[[package]] -name = "jsonpatch" -version = "1.33" -description = "Apply JSON-Patches (RFC 6902)" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" -files = [ - {file = "jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade"}, - {file = "jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c"}, -] - -[package.dependencies] -jsonpointer = ">=1.9" - -[[package]] -name = "jsonpointer" -version = "3.0.0" -description = "Identify specific nodes in a JSON document (RFC 6901)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"}, - {file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"}, -] - -[[package]] -name = "jsonschema" -version = "4.23.0" -description = "An implementation of JSON Schema validation for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, - {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" -referencing = ">=0.28.4" -rpds-py = ">=0.7.1" - -[package.extras] -format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] - -[[package]] -name = "jsonschema-specifications" -version = "2023.12.1" -description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, - {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, -] - -[package.dependencies] -referencing = ">=0.31.0" - -[[package]] -name = "langchain" -version = "0.1.20" -description = "Building applications with LLMs through composability" -optional = false -python-versions = "<4.0,>=3.8.1" -files = [ - {file = "langchain-0.1.20-py3-none-any.whl", hash = "sha256:09991999fbd6c3421a12db3c7d1f52d55601fc41d9b2a3ef51aab2e0e9c38da9"}, - {file = "langchain-0.1.20.tar.gz", hash = "sha256:f35c95eed8c8375e02dce95a34f2fd4856a4c98269d6dc34547a23dba5beab7e"}, -] - -[package.dependencies] -aiohttp = ">=3.8.3,<4.0.0" -async-timeout = {version = ">=4.0.0,<5.0.0", markers = "python_version < \"3.11\""} -dataclasses-json = ">=0.5.7,<0.7" -langchain-community = ">=0.0.38,<0.1" -langchain-core = ">=0.1.52,<0.2.0" -langchain-text-splitters = ">=0.0.1,<0.1" -langsmith = ">=0.1.17,<0.2.0" -numpy = ">=1,<2" -pydantic = ">=1,<3" -PyYAML = ">=5.3" -requests = ">=2,<3" -SQLAlchemy = ">=1.4,<3" -tenacity = ">=8.1.0,<9.0.0" - -[package.extras] -azure = ["azure-ai-formrecognizer (>=3.2.1,<4.0.0)", "azure-ai-textanalytics (>=5.3.0,<6.0.0)", "azure-cognitiveservices-speech (>=1.28.0,<2.0.0)", "azure-core (>=1.26.4,<2.0.0)", "azure-cosmos (>=4.4.0b1,<5.0.0)", "azure-identity (>=1.12.0,<2.0.0)", "azure-search-documents (==11.4.0b8)", "openai (<2)"] -clarifai = ["clarifai (>=9.1.0)"] -cli = ["typer (>=0.9.0,<0.10.0)"] -cohere = ["cohere (>=4,<6)"] -docarray = ["docarray[hnswlib] (>=0.32.0,<0.33.0)"] -embeddings = ["sentence-transformers (>=2,<3)"] -extended-testing = ["aiosqlite (>=0.19.0,<0.20.0)", "aleph-alpha-client (>=2.15.0,<3.0.0)", "anthropic (>=0.3.11,<0.4.0)", "arxiv (>=1.4,<2.0)", "assemblyai (>=0.17.0,<0.18.0)", "atlassian-python-api (>=3.36.0,<4.0.0)", "beautifulsoup4 (>=4,<5)", "bibtexparser (>=1.4.0,<2.0.0)", "cassio (>=0.1.0,<0.2.0)", "chardet (>=5.1.0,<6.0.0)", "cohere (>=4,<6)", "couchbase (>=4.1.9,<5.0.0)", "dashvector (>=1.0.1,<2.0.0)", "databricks-vectorsearch (>=0.21,<0.22)", "datasets (>=2.15.0,<3.0.0)", "dgml-utils (>=0.3.0,<0.4.0)", "esprima (>=4.0.1,<5.0.0)", "faiss-cpu (>=1,<2)", "feedparser (>=6.0.10,<7.0.0)", "fireworks-ai (>=0.9.0,<0.10.0)", "geopandas (>=0.13.1,<0.14.0)", "gitpython (>=3.1.32,<4.0.0)", "google-cloud-documentai (>=2.20.1,<3.0.0)", "gql (>=3.4.1,<4.0.0)", "hologres-vector (>=0.0.6,<0.0.7)", "html2text (>=2020.1.16,<2021.0.0)", "javelin-sdk (>=0.1.8,<0.2.0)", "jinja2 (>=3,<4)", "jq (>=1.4.1,<2.0.0)", "jsonschema (>1)", "langchain-openai (>=0.0.2,<0.1)", "lxml (>=4.9.3,<6.0)", "markdownify (>=0.11.6,<0.12.0)", "motor (>=3.3.1,<4.0.0)", "msal (>=1.25.0,<2.0.0)", "mwparserfromhell (>=0.6.4,<0.7.0)", "mwxml (>=0.3.3,<0.4.0)", "newspaper3k (>=0.2.8,<0.3.0)", "numexpr (>=2.8.6,<3.0.0)", "openai (<2)", "openapi-pydantic (>=0.3.2,<0.4.0)", "pandas (>=2.0.1,<3.0.0)", "pdfminer-six (>=20221105,<20221106)", "pgvector (>=0.1.6,<0.2.0)", "praw (>=7.7.1,<8.0.0)", "psychicapi (>=0.8.0,<0.9.0)", "py-trello (>=0.19.0,<0.20.0)", "pymupdf (>=1.22.3,<2.0.0)", "pypdf (>=3.4.0,<4.0.0)", "pypdfium2 (>=4.10.0,<5.0.0)", "pyspark (>=3.4.0,<4.0.0)", "rank-bm25 (>=0.2.2,<0.3.0)", "rapidfuzz (>=3.1.1,<4.0.0)", "rapidocr-onnxruntime (>=1.3.2,<2.0.0)", "rdflib (==7.0.0)", "requests-toolbelt (>=1.0.0,<2.0.0)", "rspace_client (>=2.5.0,<3.0.0)", "scikit-learn (>=1.2.2,<2.0.0)", "sqlite-vss (>=0.1.2,<0.2.0)", "streamlit (>=1.18.0,<2.0.0)", "sympy (>=1.12,<2.0)", "telethon (>=1.28.5,<2.0.0)", "timescale-vector (>=0.0.1,<0.0.2)", "tqdm (>=4.48.0)", "upstash-redis (>=0.15.0,<0.16.0)", "xata (>=1.0.0a7,<2.0.0)", "xmltodict (>=0.13.0,<0.14.0)"] -javascript = ["esprima (>=4.0.1,<5.0.0)"] -llms = ["clarifai (>=9.1.0)", "cohere (>=4,<6)", "huggingface_hub (>=0,<1)", "manifest-ml (>=0.0.1,<0.0.2)", "nlpcloud (>=1,<2)", "openai (<2)", "openlm (>=0.0.5,<0.0.6)", "torch (>=1,<3)", "transformers (>=4,<5)"] -openai = ["openai (<2)", "tiktoken (>=0.3.2,<0.6.0)"] -qdrant = ["qdrant-client (>=1.3.1,<2.0.0)"] -text-helpers = ["chardet (>=5.1.0,<6.0.0)"] - -[[package]] -name = "langchain-community" -version = "0.0.38" -description = "Community contributed LangChain integrations." -optional = false -python-versions = "<4.0,>=3.8.1" -files = [ - {file = "langchain_community-0.0.38-py3-none-any.whl", hash = "sha256:ecb48660a70a08c90229be46b0cc5f6bc9f38f2833ee44c57dfab9bf3a2c121a"}, - {file = "langchain_community-0.0.38.tar.gz", hash = "sha256:127fc4b75bc67b62fe827c66c02e715a730fef8fe69bd2023d466bab06b5810d"}, -] - -[package.dependencies] -aiohttp = ">=3.8.3,<4.0.0" -dataclasses-json = ">=0.5.7,<0.7" -langchain-core = ">=0.1.52,<0.2.0" -langsmith = ">=0.1.0,<0.2.0" -numpy = ">=1,<2" -PyYAML = ">=5.3" -requests = ">=2,<3" -SQLAlchemy = ">=1.4,<3" -tenacity = ">=8.1.0,<9.0.0" - -[package.extras] -cli = ["typer (>=0.9.0,<0.10.0)"] -extended-testing = ["aiosqlite (>=0.19.0,<0.20.0)", "aleph-alpha-client (>=2.15.0,<3.0.0)", "anthropic (>=0.3.11,<0.4.0)", "arxiv (>=1.4,<2.0)", "assemblyai (>=0.17.0,<0.18.0)", "atlassian-python-api (>=3.36.0,<4.0.0)", "azure-ai-documentintelligence (>=1.0.0b1,<2.0.0)", "azure-identity (>=1.15.0,<2.0.0)", "azure-search-documents (==11.4.0)", "beautifulsoup4 (>=4,<5)", "bibtexparser (>=1.4.0,<2.0.0)", "cassio (>=0.1.6,<0.2.0)", "chardet (>=5.1.0,<6.0.0)", "cloudpickle (>=2.0.0)", "cohere (>=4,<5)", "databricks-vectorsearch (>=0.21,<0.22)", "datasets (>=2.15.0,<3.0.0)", "dgml-utils (>=0.3.0,<0.4.0)", "elasticsearch (>=8.12.0,<9.0.0)", "esprima (>=4.0.1,<5.0.0)", "faiss-cpu (>=1,<2)", "feedparser (>=6.0.10,<7.0.0)", "fireworks-ai (>=0.9.0,<0.10.0)", "friendli-client (>=1.2.4,<2.0.0)", "geopandas (>=0.13.1,<0.14.0)", "gitpython (>=3.1.32,<4.0.0)", "google-cloud-documentai (>=2.20.1,<3.0.0)", "gql (>=3.4.1,<4.0.0)", "gradientai (>=1.4.0,<2.0.0)", "hdbcli (>=2.19.21,<3.0.0)", "hologres-vector (>=0.0.6,<0.0.7)", "html2text (>=2020.1.16,<2021.0.0)", "httpx (>=0.24.1,<0.25.0)", "httpx-sse (>=0.4.0,<0.5.0)", "javelin-sdk (>=0.1.8,<0.2.0)", "jinja2 (>=3,<4)", "jq (>=1.4.1,<2.0.0)", "jsonschema (>1)", "lxml (>=4.9.3,<6.0)", "markdownify (>=0.11.6,<0.12.0)", "motor (>=3.3.1,<4.0.0)", "msal (>=1.25.0,<2.0.0)", "mwparserfromhell (>=0.6.4,<0.7.0)", "mwxml (>=0.3.3,<0.4.0)", "newspaper3k (>=0.2.8,<0.3.0)", "numexpr (>=2.8.6,<3.0.0)", "nvidia-riva-client (>=2.14.0,<3.0.0)", "oci (>=2.119.1,<3.0.0)", "openai (<2)", "openapi-pydantic (>=0.3.2,<0.4.0)", "oracle-ads (>=2.9.1,<3.0.0)", "oracledb (>=2.2.0,<3.0.0)", "pandas (>=2.0.1,<3.0.0)", "pdfminer-six (>=20221105,<20221106)", "pgvector (>=0.1.6,<0.2.0)", "praw (>=7.7.1,<8.0.0)", "premai (>=0.3.25,<0.4.0)", "psychicapi (>=0.8.0,<0.9.0)", "py-trello (>=0.19.0,<0.20.0)", "pyjwt (>=2.8.0,<3.0.0)", "pymupdf (>=1.22.3,<2.0.0)", "pypdf (>=3.4.0,<4.0.0)", "pypdfium2 (>=4.10.0,<5.0.0)", "pyspark (>=3.4.0,<4.0.0)", "rank-bm25 (>=0.2.2,<0.3.0)", "rapidfuzz (>=3.1.1,<4.0.0)", "rapidocr-onnxruntime (>=1.3.2,<2.0.0)", "rdflib (==7.0.0)", "requests-toolbelt (>=1.0.0,<2.0.0)", "rspace_client (>=2.5.0,<3.0.0)", "scikit-learn (>=1.2.2,<2.0.0)", "sqlite-vss (>=0.1.2,<0.2.0)", "streamlit (>=1.18.0,<2.0.0)", "sympy (>=1.12,<2.0)", "telethon (>=1.28.5,<2.0.0)", "tidb-vector (>=0.0.3,<1.0.0)", "timescale-vector (>=0.0.1,<0.0.2)", "tqdm (>=4.48.0)", "tree-sitter (>=0.20.2,<0.21.0)", "tree-sitter-languages (>=1.8.0,<2.0.0)", "upstash-redis (>=0.15.0,<0.16.0)", "vdms (>=0.0.20,<0.0.21)", "xata (>=1.0.0a7,<2.0.0)", "xmltodict (>=0.13.0,<0.14.0)"] - -[[package]] -name = "langchain-core" -version = "0.1.52" -description = "Building applications with LLMs through composability" -optional = false -python-versions = "<4.0,>=3.8.1" -files = [ - {file = "langchain_core-0.1.52-py3-none-any.whl", hash = "sha256:62566749c92e8a1181c255c788548dc16dbc319d896cd6b9c95dc17af9b2a6db"}, - {file = "langchain_core-0.1.52.tar.gz", hash = "sha256:084c3fc452f5a6966c28ab3ec5dbc8b8d26fc3f63378073928f4e29d90b6393f"}, -] - -[package.dependencies] -jsonpatch = ">=1.33,<2.0" -langsmith = ">=0.1.0,<0.2.0" -packaging = ">=23.2,<24.0" -pydantic = ">=1,<3" -PyYAML = ">=5.3" -tenacity = ">=8.1.0,<9.0.0" - -[package.extras] -extended-testing = ["jinja2 (>=3,<4)"] - -[[package]] -name = "langchain-openai" -version = "0.1.7" -description = "An integration package connecting OpenAI and LangChain" -optional = false -python-versions = "<4.0,>=3.8.1" -files = [ - {file = "langchain_openai-0.1.7-py3-none-any.whl", hash = "sha256:39c3cb22bb739900ae8294d4d9939a6138c0ca7ad11198e57038eb14c08d04ec"}, - {file = "langchain_openai-0.1.7.tar.gz", hash = "sha256:fd7e1c33ba8e2cab4b2154f3a2fd4a0d9cc6518b41cf49bb87255f9f732a4896"}, -] - -[package.dependencies] -langchain-core = ">=0.1.46,<0.3" -openai = ">=1.24.0,<2.0.0" -tiktoken = ">=0.7,<1" - -[[package]] -name = "langchain-text-splitters" -version = "0.0.2" -description = "LangChain text splitting utilities" -optional = false -python-versions = "<4.0,>=3.8.1" -files = [ - {file = "langchain_text_splitters-0.0.2-py3-none-any.whl", hash = "sha256:13887f32705862c1e1454213cb7834a63aae57c26fcd80346703a1d09c46168d"}, - {file = "langchain_text_splitters-0.0.2.tar.gz", hash = "sha256:ac8927dc0ba08eba702f6961c9ed7df7cead8de19a9f7101ab2b5ea34201b3c1"}, -] - -[package.dependencies] -langchain-core = ">=0.1.28,<0.3" - -[package.extras] -extended-testing = ["beautifulsoup4 (>=4.12.3,<5.0.0)", "lxml (>=4.9.3,<6.0)"] - -[[package]] -name = "langchainhub" -version = "0.1.21" -description = "The LangChain Hub API client" -optional = false -python-versions = "<4.0,>=3.8.1" -files = [ - {file = "langchainhub-0.1.21-py3-none-any.whl", hash = "sha256:1cc002dc31e0d132a776afd044361e2b698743df5202618cf2bad399246b895f"}, - {file = "langchainhub-0.1.21.tar.gz", hash = "sha256:723383b3964a47dbaea6ad5d0ef728accefbc9d2c07480e800bdec43510a8c10"}, -] - -[package.dependencies] -packaging = ">=23.2,<25" -requests = ">=2,<3" -types-requests = ">=2.31.0.2,<3.0.0.0" - -[[package]] -name = "langsmith" -version = "0.1.125" -description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform." -optional = false -python-versions = "<4.0,>=3.8.1" -files = [ - {file = "langsmith-0.1.125-py3-none-any.whl", hash = "sha256:74ce8eb2663e1ed20bfcfc88d41e0712879306956c9938d1cdbab7d60458bdca"}, - {file = "langsmith-0.1.125.tar.gz", hash = "sha256:2c0eb0c3cbf22cff55bf519b8e889041f9a591bcf97af5152c8e130333c5940e"}, -] - -[package.dependencies] -httpx = ">=0.23.0,<1" -orjson = ">=3.9.14,<4.0.0" -pydantic = [ - {version = ">=1,<3", markers = "python_full_version < \"3.12.4\""}, - {version = ">=2.7.4,<3.0.0", markers = "python_full_version >= \"3.12.4\""}, -] -requests = ">=2,<3" - -[[package]] -name = "litellm" -version = "1.46.8" -description = "Library to easily interface with LLM API providers" -optional = false -python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" -files = [ - {file = "litellm-1.46.8-py3-none-any.whl", hash = "sha256:112acc854d67ced573dc5d60bbf8b493dea1e61244013685dace8c2d912aa1b3"}, - {file = "litellm-1.46.8.tar.gz", hash = "sha256:443c67d33e1a264641b80bf170cad1ba42d6fa9816f86df5eaaaf10c1e21b551"}, -] - -[package.dependencies] -aiohttp = "*" -click = "*" -importlib-metadata = ">=6.8.0" -jinja2 = ">=3.1.2,<4.0.0" -jsonschema = ">=4.22.0,<5.0.0" -openai = ">=1.45.0" -pydantic = ">=2.0.0,<3.0.0" -python-dotenv = ">=0.2.0" -requests = ">=2.31.0,<3.0.0" -tiktoken = ">=0.7.0" -tokenizers = "*" - -[package.extras] -extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "resend (>=0.8.0,<0.9.0)"] -proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "backoff", "cryptography (>=42.0.5,<43.0.0)", "fastapi (>=0.111.0,<0.112.0)", "fastapi-sso (>=0.10.0,<0.11.0)", "gunicorn (>=22.0.0,<23.0.0)", "orjson (>=3.9.7,<4.0.0)", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.9,<0.0.10)", "pyyaml (>=6.0.1,<7.0.0)", "rq", "uvicorn (>=0.22.0,<0.23.0)"] - -[[package]] -name = "llama-index-core" -version = "0.11.10" -description = "Interface between LLMs and your data" -optional = false -python-versions = "<4.0,>=3.8.1" -files = [ - {file = "llama_index_core-0.11.10-py3-none-any.whl", hash = "sha256:2dddd7cb4ccee89fdbbddd62e5fe3c7ae7fc431130e0a0a7155daee052874191"}, - {file = "llama_index_core-0.11.10.tar.gz", hash = "sha256:9929b11cfb24a3581620466660ab11a6360fde8c2441caa3660e0127df65c1b9"}, -] - -[package.dependencies] -aiohttp = ">=3.8.6,<4.0.0" -dataclasses-json = "*" -deprecated = ">=1.2.9.3" -dirtyjson = ">=1.0.8,<2.0.0" -fsspec = ">=2023.5.0" -httpx = "*" -nest-asyncio = ">=1.5.8,<2.0.0" -networkx = ">=3.0" -nltk = ">3.8.1" -numpy = "<2.0.0" -pillow = ">=9.0.0" -pydantic = ">=2.7.0,<3.0.0" -PyYAML = ">=6.0.1" -requests = ">=2.31.0" -SQLAlchemy = {version = ">=1.4.49", extras = ["asyncio"]} -tenacity = ">=8.2.0,<8.4.0 || >8.4.0,<9.0.0" -tiktoken = ">=0.3.3" -tqdm = ">=4.66.1,<5.0.0" -typing-extensions = ">=4.5.0" -typing-inspect = ">=0.8.0" -wrapt = "*" - -[[package]] -name = "llama-index-llms-openai" -version = "0.2.9" -description = "llama-index llms openai integration" -optional = false -python-versions = "<4.0,>=3.8.1" -files = [ - {file = "llama_index_llms_openai-0.2.9-py3-none-any.whl", hash = "sha256:5f36e8cbca2c3c657380c711bd3974fe7e2344d3b6a8dde6c263e56868d01e27"}, - {file = "llama_index_llms_openai-0.2.9.tar.gz", hash = "sha256:56376f39e3a40253b5c4fb90d0fb6af093f21bb2935925615f0c28a28d028187"}, -] - -[package.dependencies] -llama-index-core = ">=0.11.7,<0.12.0" -openai = ">=1.40.0,<2.0.0" - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.8" -files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "markupsafe" -version = "2.1.5" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.7" -files = [ - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, - {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, -] - -[[package]] -name = "marshmallow" -version = "3.22.0" -description = "A lightweight library for converting complex datatypes to and from native Python datatypes." -optional = false -python-versions = ">=3.8" -files = [ - {file = "marshmallow-3.22.0-py3-none-any.whl", hash = "sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9"}, - {file = "marshmallow-3.22.0.tar.gz", hash = "sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e"}, -] - -[package.dependencies] -packaging = ">=17.0" - -[package.extras] -dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] -docs = ["alabaster (==1.0.0)", "autodocsumm (==0.2.13)", "sphinx (==8.0.2)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"] -tests = ["pytest", "pytz", "simplejson"] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mpmath" -version = "1.3.0" -description = "Python library for arbitrary-precision floating-point arithmetic" -optional = false -python-versions = "*" -files = [ - {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, - {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, -] - -[package.extras] -develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] -docs = ["sphinx"] -gmpy = ["gmpy2 (>=2.1.0a4)"] -tests = ["pytest (>=4.6)"] - -[[package]] -name = "multidict" -version = "6.1.0" -description = "multidict implementation" -optional = false -python-versions = ">=3.8" -files = [ - {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, - {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, - {file = "multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7"}, - {file = "multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0"}, - {file = "multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d"}, - {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6"}, - {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156"}, - {file = "multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753"}, - {file = "multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80"}, - {file = "multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926"}, - {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa"}, - {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436"}, - {file = "multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3"}, - {file = "multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133"}, - {file = "multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1"}, - {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008"}, - {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f"}, - {file = "multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6"}, - {file = "multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81"}, - {file = "multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774"}, - {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392"}, - {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a"}, - {file = "multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd"}, - {file = "multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167"}, - {file = "multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef"}, - {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c"}, - {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1"}, - {file = "multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43"}, - {file = "multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada"}, - {file = "multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a"}, - {file = "multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506"}, - {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} - -[[package]] -name = "multiprocess" -version = "0.70.16" -description = "better multiprocessing and multithreading in Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "multiprocess-0.70.16-pp310-pypy310_pp73-macosx_10_13_x86_64.whl", hash = "sha256:476887be10e2f59ff183c006af746cb6f1fd0eadcfd4ef49e605cbe2659920ee"}, - {file = "multiprocess-0.70.16-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d951bed82c8f73929ac82c61f01a7b5ce8f3e5ef40f5b52553b4f547ce2b08ec"}, - {file = "multiprocess-0.70.16-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:37b55f71c07e2d741374998c043b9520b626a8dddc8b3129222ca4f1a06ef67a"}, - {file = "multiprocess-0.70.16-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba8c31889abf4511c7308a8c52bb4a30b9d590e7f58523302ba00237702ca054"}, - {file = "multiprocess-0.70.16-pp39-pypy39_pp73-macosx_10_13_x86_64.whl", hash = "sha256:0dfd078c306e08d46d7a8d06fb120313d87aa43af60d66da43ffff40b44d2f41"}, - {file = "multiprocess-0.70.16-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e7b9d0f307cd9bd50851afaac0dba2cb6c44449efff697df7c7645f7d3f2be3a"}, - {file = "multiprocess-0.70.16-py310-none-any.whl", hash = "sha256:c4a9944c67bd49f823687463660a2d6daae94c289adff97e0f9d696ba6371d02"}, - {file = "multiprocess-0.70.16-py311-none-any.whl", hash = "sha256:af4cabb0dac72abfb1e794fa7855c325fd2b55a10a44628a3c1ad3311c04127a"}, - {file = "multiprocess-0.70.16-py312-none-any.whl", hash = "sha256:fc0544c531920dde3b00c29863377f87e1632601092ea2daca74e4beb40faa2e"}, - {file = "multiprocess-0.70.16-py38-none-any.whl", hash = "sha256:a71d82033454891091a226dfc319d0cfa8019a4e888ef9ca910372a446de4435"}, - {file = "multiprocess-0.70.16-py39-none-any.whl", hash = "sha256:a0bafd3ae1b732eac64be2e72038231c1ba97724b60b09400d68f229fcc2fbf3"}, - {file = "multiprocess-0.70.16.tar.gz", hash = "sha256:161af703d4652a0e1410be6abccecde4a7ddffd19341be0a7011b94aeb171ac1"}, -] - -[package.dependencies] -dill = ">=0.3.8" - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - -[[package]] -name = "narwhals" -version = "1.9.0" -description = "Extremely lightweight compatibility layer between dataframe libraries" -optional = false -python-versions = ">=3.8" -files = [ - {file = "narwhals-1.9.0-py3-none-any.whl", hash = "sha256:914cde513487341fe1e3b8cb09d3b79083530141c570e45d42150796b8d87a01"}, - {file = "narwhals-1.9.0.tar.gz", hash = "sha256:bfd8ab5abb87cfeca9cc72af4af47bf9d73a2f0fda97cffa2223a535bc65b5e5"}, -] - -[package.extras] -cudf = ["cudf (>=23.08.00)"] -dask = ["dask[dataframe] (>=2024.7)"] -modin = ["modin"] -pandas = ["pandas (>=0.25.3)"] -polars = ["polars (>=0.20.3)"] -pyarrow = ["pyarrow (>=11.0.0)"] - -[[package]] -name = "nest-asyncio" -version = "1.6.0" -description = "Patch asyncio to allow nested event loops" -optional = false -python-versions = ">=3.5" -files = [ - {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, - {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, -] - -[[package]] -name = "networkx" -version = "3.3" -description = "Python package for creating and manipulating graphs and networks" -optional = false -python-versions = ">=3.10" -files = [ - {file = "networkx-3.3-py3-none-any.whl", hash = "sha256:28575580c6ebdaf4505b22c6256a2b9de86b316dc63ba9e93abde3d78dfdbcf2"}, - {file = "networkx-3.3.tar.gz", hash = "sha256:0c127d8b2f4865f59ae9cb8aafcd60b5c70f3241ebd66f7defad7c4ab90126c9"}, -] - -[package.extras] -default = ["matplotlib (>=3.6)", "numpy (>=1.23)", "pandas (>=1.4)", "scipy (>=1.9,!=1.11.0,!=1.11.1)"] -developer = ["changelist (==0.5)", "mypy (>=1.1)", "pre-commit (>=3.2)", "rtoml"] -doc = ["myst-nb (>=1.0)", "numpydoc (>=1.7)", "pillow (>=9.4)", "pydata-sphinx-theme (>=0.14)", "sphinx (>=7)", "sphinx-gallery (>=0.14)", "texext (>=0.6.7)"] -extra = ["lxml (>=4.6)", "pydot (>=2.0)", "pygraphviz (>=1.12)", "sympy (>=1.10)"] -test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"] - -[[package]] -name = "nltk" -version = "3.9.1" -description = "Natural Language Toolkit" -optional = false -python-versions = ">=3.8" -files = [ - {file = "nltk-3.9.1-py3-none-any.whl", hash = "sha256:4fa26829c5b00715afe3061398a8989dc643b92ce7dd93fb4585a70930d168a1"}, - {file = "nltk-3.9.1.tar.gz", hash = "sha256:87d127bd3de4bd89a4f81265e5fa59cb1b199b27440175370f7417d2bc7ae868"}, -] - -[package.dependencies] -click = "*" -joblib = "*" -regex = ">=2021.8.3" -tqdm = "*" - -[package.extras] -all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"] -corenlp = ["requests"] -machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"] -plot = ["matplotlib"] -tgrep = ["pyparsing"] -twitter = ["twython"] - -[[package]] -name = "numpy" -version = "1.26.4" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.9" -files = [ - {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, - {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, - {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, - {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, - {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, - {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, - {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, - {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, - {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, - {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, - {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, - {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, - {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, - {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, - {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, - {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, - {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, - {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, - {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, - {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, - {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, - {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, - {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, - {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, - {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, - {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, - {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, - {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, - {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, - {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, - {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, - {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, - {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, - {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, - {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, - {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, -] - -[[package]] -name = "openai" -version = "1.51.0" -description = "The official Python library for the openai API" -optional = false -python-versions = ">=3.7.1" -files = [ - {file = "openai-1.51.0-py3-none-any.whl", hash = "sha256:d9affafb7e51e5a27dce78589d4964ce4d6f6d560307265933a94b2e3f3c5d2c"}, - {file = "openai-1.51.0.tar.gz", hash = "sha256:8dc4f9d75ccdd5466fc8c99a952186eddceb9fd6ba694044773f3736a847149d"}, -] - -[package.dependencies] -anyio = ">=3.5.0,<5" -distro = ">=1.7.0,<2" -httpx = ">=0.23.0,<1" -jiter = ">=0.4.0,<1" -pydantic = ">=1.9.0,<3" -sniffio = "*" -tqdm = ">4" -typing-extensions = ">=4.11,<5" - -[package.extras] -datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] - -[[package]] -name = "orjson" -version = "3.10.7" -description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" -optional = false -python-versions = ">=3.8" -files = [ - {file = "orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91"}, - {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250"}, - {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84"}, - {file = "orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175"}, - {file = "orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c"}, - {file = "orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6"}, - {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6"}, - {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0"}, - {file = "orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f"}, - {file = "orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5"}, - {file = "orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09"}, - {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5"}, - {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b"}, - {file = "orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb"}, - {file = "orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1"}, - {file = "orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149"}, - {file = "orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe"}, - {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c"}, - {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad"}, - {file = "orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2"}, - {file = "orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024"}, - {file = "orjson-3.10.7-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6ea2b2258eff652c82652d5e0f02bd5e0463a6a52abb78e49ac288827aaa1469"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:430ee4d85841e1483d487e7b81401785a5dfd69db5de01314538f31f8fbf7ee1"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b6146e439af4c2472c56f8540d799a67a81226e11992008cb47e1267a9b3225"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:084e537806b458911137f76097e53ce7bf5806dda33ddf6aaa66a028f8d43a23"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829cf2195838e3f93b70fd3b4292156fc5e097aac3739859ac0dcc722b27ac0"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1193b2416cbad1a769f868b1749535d5da47626ac29445803dae7cc64b3f5c98"}, - {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4e6c3da13e5a57e4b3dca2de059f243ebec705857522f188f0180ae88badd354"}, - {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c31008598424dfbe52ce8c5b47e0752dca918a4fdc4a2a32004efd9fab41d866"}, - {file = "orjson-3.10.7-cp38-none-win32.whl", hash = "sha256:7122a99831f9e7fe977dc45784d3b2edc821c172d545e6420c375e5a935f5a1c"}, - {file = "orjson-3.10.7-cp38-none-win_amd64.whl", hash = "sha256:a763bc0e58504cc803739e7df040685816145a6f3c8a589787084b54ebc9f16e"}, - {file = "orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff"}, - {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd"}, - {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5"}, - {file = "orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2"}, - {file = "orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58"}, - {file = "orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3"}, -] - -[[package]] -name = "packaging" -version = "23.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, -] - -[[package]] -name = "pandas" -version = "2.2.3" -description = "Powerful data structures for data analysis, time series, and statistics" -optional = false -python-versions = ">=3.9" -files = [ - {file = "pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5"}, - {file = "pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348"}, - {file = "pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57"}, - {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f"}, - {file = "pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645"}, - {file = "pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039"}, - {file = "pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd"}, - {file = "pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc"}, - {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32"}, - {file = "pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5"}, - {file = "pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9"}, - {file = "pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4"}, - {file = "pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319"}, - {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a"}, - {file = "pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13"}, - {file = "pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015"}, - {file = "pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28"}, - {file = "pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24"}, - {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb"}, - {file = "pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d"}, - {file = "pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468"}, - {file = "pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18"}, - {file = "pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4"}, - {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a"}, - {file = "pandas-2.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc6b93f9b966093cb0fd62ff1a7e4c09e6d546ad7c1de191767baffc57628f39"}, - {file = "pandas-2.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5dbca4c1acd72e8eeef4753eeca07de9b1db4f398669d5994086f788a5d7cc30"}, - {file = "pandas-2.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99df71520d25fade9db7c1076ac94eb994f4d2673ef2aa2e86ee039b6746d20c"}, - {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7eee9e7cea6adf3e3d24e304ac6b8300646e2a5d1cd3a3c2abed9101b0846761"}, - {file = "pandas-2.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e"}, - {file = "pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667"}, -] - -[package.dependencies] -numpy = [ - {version = ">=1.22.4", markers = "python_version < \"3.11\""}, - {version = ">=1.23.2", markers = "python_version == \"3.11\""}, - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, -] -python-dateutil = ">=2.8.2" -pytz = ">=2020.1" -tzdata = ">=2022.7" - -[package.extras] -all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] -aws = ["s3fs (>=2022.11.0)"] -clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] -compression = ["zstandard (>=0.19.0)"] -computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] -consortium-standard = ["dataframe-api-compat (>=0.1.7)"] -excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] -feather = ["pyarrow (>=10.0.1)"] -fss = ["fsspec (>=2022.11.0)"] -gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] -hdf5 = ["tables (>=3.8.0)"] -html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] -mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] -output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] -parquet = ["pyarrow (>=10.0.1)"] -performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] -plot = ["matplotlib (>=3.6.3)"] -postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] -pyarrow = ["pyarrow (>=10.0.1)"] -spss = ["pyreadstat (>=1.2.0)"] -sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] -test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] -xml = ["lxml (>=4.9.2)"] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - -[[package]] -name = "pillow" -version = "10.4.0" -description = "Python Imaging Library (Fork)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, - {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, - {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, - {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, - {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, - {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, - {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, - {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, - {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, - {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, - {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, - {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, - {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, - {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, - {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, - {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, - {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, - {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, - {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, - {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, - {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, - {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, - {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, - {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, - {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, - {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, - {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, - {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, - {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, - {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, - {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, - {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, - {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, - {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, - {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, - {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, - {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, - {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, - {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, - {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, - {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, - {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] -typing = ["typing-extensions"] -xmp = ["defusedxml"] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "prompt-toolkit" -version = "3.0.47" -description = "Library for building powerful interactive command lines in Python" -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10"}, - {file = "prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360"}, -] - -[package.dependencies] -wcwidth = "*" - -[[package]] -name = "protobuf" -version = "5.28.2" -description = "" -optional = false -python-versions = ">=3.8" -files = [ - {file = "protobuf-5.28.2-cp310-abi3-win32.whl", hash = "sha256:eeea10f3dc0ac7e6b4933d32db20662902b4ab81bf28df12218aa389e9c2102d"}, - {file = "protobuf-5.28.2-cp310-abi3-win_amd64.whl", hash = "sha256:2c69461a7fcc8e24be697624c09a839976d82ae75062b11a0972e41fd2cd9132"}, - {file = "protobuf-5.28.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a8b9403fc70764b08d2f593ce44f1d2920c5077bf7d311fefec999f8c40f78b7"}, - {file = "protobuf-5.28.2-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:35cfcb15f213449af7ff6198d6eb5f739c37d7e4f1c09b5d0641babf2cc0c68f"}, - {file = "protobuf-5.28.2-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:5e8a95246d581eef20471b5d5ba010d55f66740942b95ba9b872d918c459452f"}, - {file = "protobuf-5.28.2-cp38-cp38-win32.whl", hash = "sha256:87317e9bcda04a32f2ee82089a204d3a2f0d3c8aeed16568c7daf4756e4f1fe0"}, - {file = "protobuf-5.28.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0ea0123dac3399a2eeb1a1443d82b7afc9ff40241433296769f7da42d142ec3"}, - {file = "protobuf-5.28.2-cp39-cp39-win32.whl", hash = "sha256:ca53faf29896c526863366a52a8f4d88e69cd04ec9571ed6082fa117fac3ab36"}, - {file = "protobuf-5.28.2-cp39-cp39-win_amd64.whl", hash = "sha256:8ddc60bf374785fb7cb12510b267f59067fa10087325b8e1855b898a0d81d276"}, - {file = "protobuf-5.28.2-py3-none-any.whl", hash = "sha256:52235802093bd8a2811abbe8bf0ab9c5f54cca0a751fdd3f6ac2a21438bffece"}, - {file = "protobuf-5.28.2.tar.gz", hash = "sha256:59379674ff119717404f7454647913787034f03fe7049cbef1d74a97bb4593f0"}, -] - -[[package]] -name = "pyarrow" -version = "17.0.0" -description = "Python library for Apache Arrow" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pyarrow-17.0.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:a5c8b238d47e48812ee577ee20c9a2779e6a5904f1708ae240f53ecbee7c9f07"}, - {file = "pyarrow-17.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db023dc4c6cae1015de9e198d41250688383c3f9af8f565370ab2b4cb5f62655"}, - {file = "pyarrow-17.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da1e060b3876faa11cee287839f9cc7cdc00649f475714b8680a05fd9071d545"}, - {file = "pyarrow-17.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75c06d4624c0ad6674364bb46ef38c3132768139ddec1c56582dbac54f2663e2"}, - {file = "pyarrow-17.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:fa3c246cc58cb5a4a5cb407a18f193354ea47dd0648194e6265bd24177982fe8"}, - {file = "pyarrow-17.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:f7ae2de664e0b158d1607699a16a488de3d008ba99b3a7aa5de1cbc13574d047"}, - {file = "pyarrow-17.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:5984f416552eea15fd9cee03da53542bf4cddaef5afecefb9aa8d1010c335087"}, - {file = "pyarrow-17.0.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:1c8856e2ef09eb87ecf937104aacfa0708f22dfeb039c363ec99735190ffb977"}, - {file = "pyarrow-17.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e19f569567efcbbd42084e87f948778eb371d308e137a0f97afe19bb860ccb3"}, - {file = "pyarrow-17.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b244dc8e08a23b3e352899a006a26ae7b4d0da7bb636872fa8f5884e70acf15"}, - {file = "pyarrow-17.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b72e87fe3e1db343995562f7fff8aee354b55ee83d13afba65400c178ab2597"}, - {file = "pyarrow-17.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:dc5c31c37409dfbc5d014047817cb4ccd8c1ea25d19576acf1a001fe07f5b420"}, - {file = "pyarrow-17.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:e3343cb1e88bc2ea605986d4b94948716edc7a8d14afd4e2c097232f729758b4"}, - {file = "pyarrow-17.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:a27532c38f3de9eb3e90ecab63dfda948a8ca859a66e3a47f5f42d1e403c4d03"}, - {file = "pyarrow-17.0.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:9b8a823cea605221e61f34859dcc03207e52e409ccf6354634143e23af7c8d22"}, - {file = "pyarrow-17.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1e70de6cb5790a50b01d2b686d54aaf73da01266850b05e3af2a1bc89e16053"}, - {file = "pyarrow-17.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0071ce35788c6f9077ff9ecba4858108eebe2ea5a3f7cf2cf55ebc1dbc6ee24a"}, - {file = "pyarrow-17.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:757074882f844411fcca735e39aae74248a1531367a7c80799b4266390ae51cc"}, - {file = "pyarrow-17.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9ba11c4f16976e89146781a83833df7f82077cdab7dc6232c897789343f7891a"}, - {file = "pyarrow-17.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b0c6ac301093b42d34410b187bba560b17c0330f64907bfa4f7f7f2444b0cf9b"}, - {file = "pyarrow-17.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:392bc9feabc647338e6c89267635e111d71edad5fcffba204425a7c8d13610d7"}, - {file = "pyarrow-17.0.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:af5ff82a04b2171415f1410cff7ebb79861afc5dae50be73ce06d6e870615204"}, - {file = "pyarrow-17.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:edca18eaca89cd6382dfbcff3dd2d87633433043650c07375d095cd3517561d8"}, - {file = "pyarrow-17.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c7916bff914ac5d4a8fe25b7a25e432ff921e72f6f2b7547d1e325c1ad9d155"}, - {file = "pyarrow-17.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f553ca691b9e94b202ff741bdd40f6ccb70cdd5fbf65c187af132f1317de6145"}, - {file = "pyarrow-17.0.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:0cdb0e627c86c373205a2f94a510ac4376fdc523f8bb36beab2e7f204416163c"}, - {file = "pyarrow-17.0.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:d7d192305d9d8bc9082d10f361fc70a73590a4c65cf31c3e6926cd72b76bc35c"}, - {file = "pyarrow-17.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:02dae06ce212d8b3244dd3e7d12d9c4d3046945a5933d28026598e9dbbda1fca"}, - {file = "pyarrow-17.0.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:13d7a460b412f31e4c0efa1148e1d29bdf18ad1411eb6757d38f8fbdcc8645fb"}, - {file = "pyarrow-17.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9b564a51fbccfab5a04a80453e5ac6c9954a9c5ef2890d1bcf63741909c3f8df"}, - {file = "pyarrow-17.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32503827abbc5aadedfa235f5ece8c4f8f8b0a3cf01066bc8d29de7539532687"}, - {file = "pyarrow-17.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a155acc7f154b9ffcc85497509bcd0d43efb80d6f733b0dc3bb14e281f131c8b"}, - {file = "pyarrow-17.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:dec8d129254d0188a49f8a1fc99e0560dc1b85f60af729f47de4046015f9b0a5"}, - {file = "pyarrow-17.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:a48ddf5c3c6a6c505904545c25a4ae13646ae1f8ba703c4df4a1bfe4f4006bda"}, - {file = "pyarrow-17.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:42bf93249a083aca230ba7e2786c5f673507fa97bbd9725a1e2754715151a204"}, - {file = "pyarrow-17.0.0.tar.gz", hash = "sha256:4beca9521ed2c0921c1023e68d097d0299b62c362639ea315572a58f3f50fd28"}, -] - -[package.dependencies] -numpy = ">=1.16.6" - -[package.extras] -test = ["cffi", "hypothesis", "pandas", "pytest", "pytz"] - -[[package]] -name = "pydantic" -version = "2.9.2" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, - {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.23.4" -typing-extensions = {version = ">=4.6.1", markers = "python_version < \"3.13\""} - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] - -[[package]] -name = "pydantic-core" -version = "2.23.4" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, - {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, - {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, - {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, - {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, - {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, - {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, - {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, - {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, - {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, - {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, - {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, - {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, - {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, - {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, - {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, - {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, - {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, - {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, - {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, - {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, - {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, - {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, - {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, - {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, - {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, - {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, - {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, - {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, - {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, - {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, - {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, - {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - -[[package]] -name = "pydantic-settings" -version = "2.5.2" -description = "Settings management using Pydantic" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_settings-2.5.2-py3-none-any.whl", hash = "sha256:2c912e55fd5794a59bf8c832b9de832dcfdf4778d79ff79b708744eed499a907"}, - {file = "pydantic_settings-2.5.2.tar.gz", hash = "sha256:f90b139682bee4d2065273d5185d71d37ea46cfe57e1b5ae184fc6a0b2484ca0"}, -] - -[package.dependencies] -pydantic = ">=2.7.0" -python-dotenv = ">=0.21.0" - -[package.extras] -azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] -toml = ["tomli (>=2.0.1)"] -yaml = ["pyyaml (>=6.0.1)"] - -[[package]] -name = "pydeck" -version = "0.9.1" -description = "Widget for deck.gl maps" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038"}, - {file = "pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605"}, -] - -[package.dependencies] -jinja2 = ">=2.10.1" -numpy = ">=1.16.4" - -[package.extras] -carto = ["pydeck-carto"] -jupyter = ["ipykernel (>=5.1.2)", "ipython (>=5.8.0)", "ipywidgets (>=7,<8)", "traitlets (>=4.3.2)"] - -[[package]] -name = "pygments" -version = "2.18.0" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, - {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pyjson5" -version = "1.6.6" -description = "JSON5 serializer and parser for Python 3 written in Cython." -optional = false -python-versions = "~=3.5" -files = [ - {file = "pyjson5-1.6.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:567437862f410a5912eee4cf13dd01a8c28ce9c9bf95590b9b9a4cb20e9daaed"}, - {file = "pyjson5-1.6.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1649e043e1277aae474e72f8fa3431cbf83605059e733043e718f77f59aef29"}, - {file = "pyjson5-1.6.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:420d9f970d678d8d1fab8aefdd483747c75e64681766e771494910b8030f41fa"}, - {file = "pyjson5-1.6.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59166de551e7321dbd3aa552ef647d652873701faadb021eae20f55ba6705829"}, - {file = "pyjson5-1.6.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:314537a41af768367ab19a50d697a6ea85cb1c39bac4a2b93680cab930002e31"}, - {file = "pyjson5-1.6.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed4c50a117e8ff0733e2c2b761adf183150ee9bf9294f06c01983d76e89f5f2c"}, - {file = "pyjson5-1.6.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b258ba83c2d888568a098cab8f7df1ceffded589386eb5d70960ab83cffa9d"}, - {file = "pyjson5-1.6.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:211a9e260cf7d122e4d8881ae8724067cf7dfa48317760adb59b96bcf8e0a407"}, - {file = "pyjson5-1.6.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e4b0a14f9226aa425bec207b9ea5ec149b6b9ff63d40399001b9ad4a1f9920df"}, - {file = "pyjson5-1.6.6-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a873c3a0d38f4f901e2d721ea3875ecf8d704a6d6c642cf31f5e17d37b60ca38"}, - {file = "pyjson5-1.6.6-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:64381957edf773f4c67cc32c6e44fc791034efde5d654626171829d1219e4247"}, - {file = "pyjson5-1.6.6-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:c5ee52cc58080472bd5668a2e114b779a56e200cdae52870e61a72c95c0760d4"}, - {file = "pyjson5-1.6.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e6f791cb35ec48a5822a888225b54b36e53e10ae7ec193f85d26c188702861a9"}, - {file = "pyjson5-1.6.6-cp310-cp310-win32.whl", hash = "sha256:80a96a264b7eb7fcbeaf646f44e18c22f98d1f72cc9e9ca284f785612df457d8"}, - {file = "pyjson5-1.6.6-cp310-cp310-win_amd64.whl", hash = "sha256:a79e914cba2a45725c2b10801bbdc7975e2b0926c4406f3cbd67d3976c2ded9c"}, - {file = "pyjson5-1.6.6-cp310-cp310-win_arm64.whl", hash = "sha256:ab2d7b402b8f27a866431dc1b476c4f9f0ccbb0811425c846985f05f5de9236b"}, - {file = "pyjson5-1.6.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:127f145467b61ef8a9a1d01c5c33a433d14dbca70c70d0b0800f4a19581303ff"}, - {file = "pyjson5-1.6.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d828b98667ebca9fbd172f221746ecba105081b77f5ac6bbbf055f2faa38e14"}, - {file = "pyjson5-1.6.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d1fe850aa1763e8f9945a7120f032f5f707d0372d52c8e0fecda2a79640820e"}, - {file = "pyjson5-1.6.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8e4974cd32697f3a2c6738b597eaf66f2d23eb34dcdd79b8b6bb92549b56395"}, - {file = "pyjson5-1.6.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:011957e1e7143c82ee5701dd39292d3864f6989247d1423462b1ef405390435e"}, - {file = "pyjson5-1.6.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:122a980366db9c56b35bead60debf832d614ebe47a65d34bc292b2211e42e547"}, - {file = "pyjson5-1.6.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0abc60630bb1601ae9005e251d30cef363cb4d6cef2d2fb7a160329569170e38"}, - {file = "pyjson5-1.6.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f9f8072be8d2cd3ba40346efeaaaba089a6e9be04ef23a3d690aaa897c53f71"}, - {file = "pyjson5-1.6.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:990bf3aa3df840dfb98e3663d645d6c897d00ccf1e6cc2a594a547e8c239cc52"}, - {file = "pyjson5-1.6.6-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bff1c85756a80fba14285bcaef576419b4b159543fdb8c20714331c1d873e9e1"}, - {file = "pyjson5-1.6.6-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:0570cd200f5b52938769705c4f3092f1fcbb850f62278d30db8b8c6ae5ef0617"}, - {file = "pyjson5-1.6.6-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:6e358035e6fd975933f00a01c2ed3cc1e344f9a06b6edc445ad4b3bca7509de4"}, - {file = "pyjson5-1.6.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:86ac9e2b5dc086a979a115661f7886208352a45f3276453db914adbda54adbb7"}, - {file = "pyjson5-1.6.6-cp311-cp311-win32.whl", hash = "sha256:415805f111faa16b050827beda8d763d4391becc24f86438848c7cb23ce63c55"}, - {file = "pyjson5-1.6.6-cp311-cp311-win_amd64.whl", hash = "sha256:9293b3768e27a69ef764daa10168c199887ffbe820ef86ea9c2ff155bdd27fba"}, - {file = "pyjson5-1.6.6-cp311-cp311-win_arm64.whl", hash = "sha256:3145da3885e063b360bd9cc0bff6c9b0c83e3a2e822f83b4f7f40b26b41e1598"}, - {file = "pyjson5-1.6.6-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b2f96647371dcab50060e5d6908f152ad33c10e534f9695f81e3f59733748f0f"}, - {file = "pyjson5-1.6.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c0836a8181e4e857a91c1ab55adfee2dc4e60fff5e67f9e45f885a69586702f8"}, - {file = "pyjson5-1.6.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4f1a9eebe9d793e5b6ed00691744255400f57666004f454b09e2e651657e15fe"}, - {file = "pyjson5-1.6.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02d60cd0f98d39347416d9172f38cd9601dfcf9976536688deb82c387ac22db1"}, - {file = "pyjson5-1.6.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7366857ff307ef1bae3f1096651a2b2f86ef87b8ff4ea7264c6d7db9176adaab"}, - {file = "pyjson5-1.6.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdf1f8140eccab24bd917a4111cc2783b1b430e8b9a6443b2ec4dce7267cfa4e"}, - {file = "pyjson5-1.6.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8b738a46b7a4c6910a8cd34c3fec752c26afb5156939241b080311df1ace484"}, - {file = "pyjson5-1.6.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1f437b4258661d0cf884675487220fd035f67e50a648732e02b22b1e417d60"}, - {file = "pyjson5-1.6.6-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5940c9c2feb9dbcda0cb87d2fc7edf330de231469c882f1e584d96814b6efd0d"}, - {file = "pyjson5-1.6.6-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:270600deefe532138d00cec1e4029c716fc86eaa52dabfb12494dca1cb30e611"}, - {file = "pyjson5-1.6.6-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:bdcc8073544acbb625dfcd64af8d6fdddefa4dd8f6898eb0bea1d50bfc7db071"}, - {file = "pyjson5-1.6.6-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:e62998daf49ccfa106459724ab3e57a87afc6b4e17e3422db633bf837918ee01"}, - {file = "pyjson5-1.6.6-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1e3cf693ac82c7ee1da149e172bbe0cf59d11b06c31331f254daf8825d566033"}, - {file = "pyjson5-1.6.6-cp312-cp312-win32.whl", hash = "sha256:cd44211d3430fc32abad56a991fe5279243cbdae303a1c00ce418e0562f315cb"}, - {file = "pyjson5-1.6.6-cp312-cp312-win_amd64.whl", hash = "sha256:50edc8c1f8c903f6e5df8547ae4195e3456ba2cdb45fbbad14022937a27ffa7c"}, - {file = "pyjson5-1.6.6-cp312-cp312-win_arm64.whl", hash = "sha256:14cad404a8dff0ea57370b98350b8fedb7611ee8023090b8a991673b4542abf2"}, - {file = "pyjson5-1.6.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8eddf775c2846e24f5f17c0ef2dc20f7de628148d03394a46ac1fd866a59ab78"}, - {file = "pyjson5-1.6.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34500d5373dff030ab5183a69b3eb545a3a89385a3949142ea61ca95e653cbf8"}, - {file = "pyjson5-1.6.6-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6cb856116850100d46df398cd68fc05d61ae5525eb0164b1aa9310e14c957d6"}, - {file = "pyjson5-1.6.6-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:410c15018434271408bb9410ac7db5195beccc33d8d1fbc8fb4a39858427e0df"}, - {file = "pyjson5-1.6.6-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6a4bfdfb47904f1a4fdc135ab000e36bf79a0bc70aa9ec98275493ae19fd55"}, - {file = "pyjson5-1.6.6-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b21596c5d805c5e8fae5ba2c80317f2a6441c62ea91d1bd6515c43c23911a16a"}, - {file = "pyjson5-1.6.6-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:4724d6bc1e369b29fb9fab7b10530cc11ccba60dc403cef3a83c213bc88d541c"}, - {file = "pyjson5-1.6.6-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:0d8063c83603e4eda99dc9396113521f0d277b027ccb3182e8e47ea284db8a70"}, - {file = "pyjson5-1.6.6-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:e23d76671b1ea35d31144ea94f911d7096808e63834ee412105e9c281d50802a"}, - {file = "pyjson5-1.6.6-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:1c2ae4f322d2c859abd45efa893d06737b12575ca32fb068d2ab1ff7e1dacf7c"}, - {file = "pyjson5-1.6.6-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:0c069dfbc4939ce5d8b6d72b7755bb287988aab819435349e6f52da4e62fac9c"}, - {file = "pyjson5-1.6.6-cp36-cp36m-win32.whl", hash = "sha256:7fe091a563a1fe8f1c495d96d26fd40f9d19b9c3674dbf89dd3c3df8bf46cfe5"}, - {file = "pyjson5-1.6.6-cp36-cp36m-win_amd64.whl", hash = "sha256:3d2829447d9d6a51a5976a3eb0a2a0069122ab741378c60367883e60160cb6de"}, - {file = "pyjson5-1.6.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:87063a381b2db5f5af794ba6a3ec16c9a9f8fc5e974e41c299f761e7af138ec3"}, - {file = "pyjson5-1.6.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94b2d2a26ecdd9ecef704a5a0c86a272e85d1df6bb30f44fb7f825d6975f1fb3"}, - {file = "pyjson5-1.6.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55df02651c98dc8ad7fa30fd4fc5a4789a83ed0498312154c35ee46bdd027ccd"}, - {file = "pyjson5-1.6.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:985f51077649fd0c84fcc7057d48b862c843f30f77c9f5c27102363c653c9e5e"}, - {file = "pyjson5-1.6.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c8af6410aa6525783ceef0008f4f86480a4f04c4b921dd4295a2f2ba62f682d"}, - {file = "pyjson5-1.6.6-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caebf75a83015299e45006819caac156ac2ef1e057158c6c1453c0ebf73de1d9"}, - {file = "pyjson5-1.6.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f069e4c6336b19afdc594d04c81eb900af5d834a6cd8b395015389974b044972"}, - {file = "pyjson5-1.6.6-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:94e20d28d2cfba1b2be59c63ad8ae221e709a84158dc7f138d3b7258319f59b2"}, - {file = "pyjson5-1.6.6-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:8b869fead9daa2ef5972f30abd76fdb8acfe3d6f73c0e3783fe13e1775d4aa05"}, - {file = "pyjson5-1.6.6-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:a093e24dd14a97f06d1596158d413b3b556cdf2b0d7a1af45b546d1d90224de7"}, - {file = "pyjson5-1.6.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:493fa3a81ce879e7ae47d1aa6a2b465a207087857b693404e523f069f531d01d"}, - {file = "pyjson5-1.6.6-cp37-cp37m-win32.whl", hash = "sha256:149f6ca0e346fce26ccb154e6633d3dbe10f197aae2b306cf3e3f09a57b6e4f7"}, - {file = "pyjson5-1.6.6-cp37-cp37m-win_amd64.whl", hash = "sha256:7dffc9dcbdf09663f6aefcf03a48cfb029437ee60c0d4372e2218f30929d3035"}, - {file = "pyjson5-1.6.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5c7643308f995d746af8210b9a418c287885a0f8c0104b5e5160c190e56fbd0c"}, - {file = "pyjson5-1.6.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:144ea7654b64226cd24c6cc856827180b2e04ddc4404f9513ba41c6f284aa4c7"}, - {file = "pyjson5-1.6.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f3fb709498a53e0f9fb8d18612ae88b11bd472cce80431e312f1a6ad65bce673"}, - {file = "pyjson5-1.6.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de97c9e246bce7231dab34248a66218b48be5af5d5ae69c78421a095b0e0ab26"}, - {file = "pyjson5-1.6.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57231901f4c96cc5bc630daef3fc3eadc95b8babe019b9f26b4915296a755bb5"}, - {file = "pyjson5-1.6.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:759ef75276235d9491ecf31c4c7ba057cdcd45707c04d2bdd0c0a18bd9f36f71"}, - {file = "pyjson5-1.6.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb1fec1b2a03a219c9fb3cccb255140bc11daa86ce844ffe7354db7b2bc9223f"}, - {file = "pyjson5-1.6.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3a120f1ac4dfe9d7fdfadd3cd2e0f02260c2e1c1418767946fa6ce6a786dcd0"}, - {file = "pyjson5-1.6.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e6dedeb9aa7a4d6703c1b5ffd3ec97f4ee6549f4f6550f644f43a74f43fcc89b"}, - {file = "pyjson5-1.6.6-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d6e8f92e2f0b84e6ede805aa68bbee408f639c29f64fd14960a242bb518dcc6"}, - {file = "pyjson5-1.6.6-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:d9661f68bcf033a80da5292a87ab1abcbd6462ec308f83cc9a96d5df4255a500"}, - {file = "pyjson5-1.6.6-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:db57d37d1c2cc07e50dc8a37c1fd4903a38feb1b236e8f9094249d621deb39e5"}, - {file = "pyjson5-1.6.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:1d0bd69aa1b82410593341eb43c546c567bee5acb151666857c9df98e2cdfc09"}, - {file = "pyjson5-1.6.6-cp38-cp38-win32.whl", hash = "sha256:6b19a7025546406ca91184125aadc89a961189a9f5d4a76c0534a04f814c8882"}, - {file = "pyjson5-1.6.6-cp38-cp38-win_amd64.whl", hash = "sha256:e94bb47d5fbacf7859f16117ab572b063705fdc6d3caf0afd92e02bbe1a0adfb"}, - {file = "pyjson5-1.6.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d539ed7039ca0677a9eee156bd7551e158fd4c8e67b500fba4e9b42c2178dbde"}, - {file = "pyjson5-1.6.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c8c19dd552551b256ec70beed516d47953cbf153cc7b04ec7189b9000211f372"}, - {file = "pyjson5-1.6.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:36bdfa9334d00be616a63fd15295e9885d0e91dfa75cda5b6a8d1a2b406d6252"}, - {file = "pyjson5-1.6.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6453b85fd4839660beff29165cdee0e0c486d32d8e7be002daffbf40c64c445"}, - {file = "pyjson5-1.6.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62cd10320a1e80b1ce386ccaed9073cffd729e0e5b7a8793f2083291770b0db3"}, - {file = "pyjson5-1.6.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2add15588939d8b1b7c59fd3f5480cce76231e0b014a2edebf3443ba92789d38"}, - {file = "pyjson5-1.6.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8eb8edf6607a6648316f8a5a76bbd1bcb6626990dd9bd6c4b1dee91ec73650e"}, - {file = "pyjson5-1.6.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2cec7163effe96abe40ff098ffd2b16f5b322628bdac34c7aa5d316588524c42"}, - {file = "pyjson5-1.6.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a29645584914f7b1840e916777d402264ea3cbe3e04ff47ea00a799de17552d6"}, - {file = "pyjson5-1.6.6-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:259f1797cd25e13396b6cb08dc0625c5de26a4214a8279e5304d6c91e5c8eb50"}, - {file = "pyjson5-1.6.6-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:48d02c97e34e64eefca4794dc3717373637aec4bd97b6e094cbed926b1893097"}, - {file = "pyjson5-1.6.6-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:ce1c193b0b35c40aa8e2097c88cb92674fa4b37016724cd8e2dc2a12784fad4f"}, - {file = "pyjson5-1.6.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:df2429ec7da1495122b63df437c04d3d5af8cf23654848d20637fa41c4aee1b5"}, - {file = "pyjson5-1.6.6-cp39-cp39-win32.whl", hash = "sha256:f46d276c64b787c895323c82acb0f0151c4e0b275cf1ef001e2c7633a29f3068"}, - {file = "pyjson5-1.6.6-cp39-cp39-win_amd64.whl", hash = "sha256:521e7691fe38bd56bc4764e7c9c54b4783910f602904d8ca1a6571a9e82a3f82"}, - {file = "pyjson5-1.6.6-cp39-cp39-win_arm64.whl", hash = "sha256:76154b0e0245b9dbb97be34055d5be38cb6a55508785594b53e0f4d57b0267eb"}, - {file = "pyjson5-1.6.6-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8afb7a6f3e020b9a2996bb62f98067ebf793e86599f349910a7129fbfaebdc79"}, - {file = "pyjson5-1.6.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9facfbdf1952d3c96016ce17159a3ce97b296454bc900e94c5ea1f0ae85f00c"}, - {file = "pyjson5-1.6.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9371ff9d5c6a92676935171eafa9cc6a541702352f747f77000b343d3101b0c0"}, - {file = "pyjson5-1.6.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f48861b7aaafee14c2c1769e28070cae1aeb011e54cdc75a7dae8ed732115c72"}, - {file = "pyjson5-1.6.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cbdcd7cb1745ce82b744fdadcfce167217c4c815e76e05897f806f99397fa092"}, - {file = "pyjson5-1.6.6-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:beaa44d409e16e31341713890baa4e612909250247201af49ddc57815c52a2e7"}, - {file = "pyjson5-1.6.6-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fedb2ab5cfc821f9b9ed44dc1eae5d22a39a040950bda77c8b26e04df327629"}, - {file = "pyjson5-1.6.6-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:868619dc4d271ea9ec5f4041901238167526be369e3598867ca0d9170827692e"}, - {file = "pyjson5-1.6.6-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ea61368eb930a39441a7e9dd34ecb9af026a7b35bd03570910558abcd296215"}, - {file = "pyjson5-1.6.6-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:0b9718821bfc36c61dd2ae8456fafbe3e9eb46df13cb2ac1ade38c5163ff9c92"}, - {file = "pyjson5-1.6.6-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d373e93e3384670b71fa42f4453cc1f979e15b34a21c0b746c5a6d14b6ebbb12"}, - {file = "pyjson5-1.6.6-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e2c27e3dd5caef56910c6da6a0c97cfc015a1dbdc6c2f2bd63a3ad86a16b0bd"}, - {file = "pyjson5-1.6.6-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aae2ad989da263216f556f3c5a3ea3eaf8c45894c9fea641c483576adb27494f"}, - {file = "pyjson5-1.6.6-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d29b5cefef1d2a2044fb800d7d7af2705b7acafac1bfd2630840a9a080d1c70d"}, - {file = "pyjson5-1.6.6-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:ae55e95b6c59e9ad86662b6be213a6ea3f1e0c7f3d5627d88ca3dbe293a0a23a"}, - {file = "pyjson5-1.6.6-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e5f192cf3570c0588511afac7f0aa155f66dbf0e61ae39c8db63f67e0a3c9788"}, - {file = "pyjson5-1.6.6-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14671bbb72df85b895570f092e1b0aa34437a8df3e609e70a00459943aa1b75c"}, - {file = "pyjson5-1.6.6-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f541197328ac3433863e2e67e4d34fccf1590bb88cc7f2c3fc2a81b8cde2681"}, - {file = "pyjson5-1.6.6-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d404ec8a8d8301d803ccf037b3f0fb5fc8051aaa8a9a737cd6d4c490911d316a"}, - {file = "pyjson5-1.6.6-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a3b0e45a2f1a84e78d8ecd70aecce0f84b360af37b18b2123646c3b310ea10a7"}, - {file = "pyjson5-1.6.6.tar.gz", hash = "sha256:20df8c5dbbe0d653f403da88b7520be44fc8d74697bbdd1ead688458b5691a02"}, -] - -[[package]] -name = "pytest" -version = "8.3.3" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, - {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "python-dotenv" -version = "1.0.1" -description = "Read key-value pairs from a .env file and set them as environment variables" -optional = false -python-versions = ">=3.8" -files = [ - {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, - {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, -] - -[package.extras] -cli = ["click (>=5.0)"] - -[[package]] -name = "python-socks" -version = "2.5.1" -description = "Core proxy (SOCKS4, SOCKS5, HTTP tunneling) functionality for Python" -optional = false -python-versions = "*" -files = [ - {file = "python_socks-2.5.1-py3-none-any.whl", hash = "sha256:00e9a0c3a208e14429d42c820ddbe0755e17596f639fd558f3e8d925fb34bcec"}, - {file = "python_socks-2.5.1.tar.gz", hash = "sha256:7ed6559864d28858fbb7a85c6d96bb280e95af814d1d5d6dc50f92e35bfa340e"}, -] - -[package.extras] -anyio = ["anyio (>=3.3.4,<5.0.0)"] -asyncio = ["async-timeout (>=3.0.1)"] -curio = ["curio (>=1.4)"] -trio = ["trio (>=0.16.0)"] - -[[package]] -name = "pytz" -version = "2024.2" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, - {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.2" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, -] - -[[package]] -name = "referencing" -version = "0.35.1" -description = "JSON Referencing + Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, - {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -rpds-py = ">=0.7.0" - -[[package]] -name = "regex" -version = "2024.9.11" -description = "Alternative regular expression module, to replace re." -optional = false -python-versions = ">=3.8" -files = [ - {file = "regex-2024.9.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1494fa8725c285a81d01dc8c06b55287a1ee5e0e382d8413adc0a9197aac6408"}, - {file = "regex-2024.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0e12c481ad92d129c78f13a2a3662317e46ee7ef96c94fd332e1c29131875b7d"}, - {file = "regex-2024.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16e13a7929791ac1216afde26f712802e3df7bf0360b32e4914dca3ab8baeea5"}, - {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46989629904bad940bbec2106528140a218b4a36bb3042d8406980be1941429c"}, - {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a906ed5e47a0ce5f04b2c981af1c9acf9e8696066900bf03b9d7879a6f679fc8"}, - {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a091b0550b3b0207784a7d6d0f1a00d1d1c8a11699c1a4d93db3fbefc3ad35"}, - {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ddcd9a179c0a6fa8add279a4444015acddcd7f232a49071ae57fa6e278f1f71"}, - {file = "regex-2024.9.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b41e1adc61fa347662b09398e31ad446afadff932a24807d3ceb955ed865cc8"}, - {file = "regex-2024.9.11-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ced479f601cd2f8ca1fd7b23925a7e0ad512a56d6e9476f79b8f381d9d37090a"}, - {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:635a1d96665f84b292e401c3d62775851aedc31d4f8784117b3c68c4fcd4118d"}, - {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c0256beda696edcf7d97ef16b2a33a8e5a875affd6fa6567b54f7c577b30a137"}, - {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3ce4f1185db3fbde8ed8aa223fc9620f276c58de8b0d4f8cc86fd1360829edb6"}, - {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:09d77559e80dcc9d24570da3745ab859a9cf91953062e4ab126ba9d5993688ca"}, - {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7a22ccefd4db3f12b526eccb129390942fe874a3a9fdbdd24cf55773a1faab1a"}, - {file = "regex-2024.9.11-cp310-cp310-win32.whl", hash = "sha256:f745ec09bc1b0bd15cfc73df6fa4f726dcc26bb16c23a03f9e3367d357eeedd0"}, - {file = "regex-2024.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:01c2acb51f8a7d6494c8c5eafe3d8e06d76563d8a8a4643b37e9b2dd8a2ff623"}, - {file = "regex-2024.9.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2cce2449e5927a0bf084d346da6cd5eb016b2beca10d0013ab50e3c226ffc0df"}, - {file = "regex-2024.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b37fa423beefa44919e009745ccbf353d8c981516e807995b2bd11c2c77d268"}, - {file = "regex-2024.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64ce2799bd75039b480cc0360907c4fb2f50022f030bf9e7a8705b636e408fad"}, - {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4cc92bb6db56ab0c1cbd17294e14f5e9224f0cc6521167ef388332604e92679"}, - {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d05ac6fa06959c4172eccd99a222e1fbf17b5670c4d596cb1e5cde99600674c4"}, - {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040562757795eeea356394a7fb13076ad4f99d3c62ab0f8bdfb21f99a1f85664"}, - {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6113c008a7780792efc80f9dfe10ba0cd043cbf8dc9a76ef757850f51b4edc50"}, - {file = "regex-2024.9.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e5fb5f77c8745a60105403a774fe2c1759b71d3e7b4ca237a5e67ad066c7199"}, - {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:54d9ff35d4515debf14bc27f1e3b38bfc453eff3220f5bce159642fa762fe5d4"}, - {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df5cbb1fbc74a8305b6065d4ade43b993be03dbe0f8b30032cced0d7740994bd"}, - {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7fb89ee5d106e4a7a51bce305ac4efb981536301895f7bdcf93ec92ae0d91c7f"}, - {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a738b937d512b30bf75995c0159c0ddf9eec0775c9d72ac0202076c72f24aa96"}, - {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e28f9faeb14b6f23ac55bfbbfd3643f5c7c18ede093977f1df249f73fd22c7b1"}, - {file = "regex-2024.9.11-cp311-cp311-win32.whl", hash = "sha256:18e707ce6c92d7282dfce370cd205098384b8ee21544e7cb29b8aab955b66fa9"}, - {file = "regex-2024.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:313ea15e5ff2a8cbbad96ccef6be638393041b0a7863183c2d31e0c6116688cf"}, - {file = "regex-2024.9.11-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b0d0a6c64fcc4ef9c69bd5b3b3626cc3776520a1637d8abaa62b9edc147a58f7"}, - {file = "regex-2024.9.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:49b0e06786ea663f933f3710a51e9385ce0cba0ea56b67107fd841a55d56a231"}, - {file = "regex-2024.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5b513b6997a0b2f10e4fd3a1313568e373926e8c252bd76c960f96fd039cd28d"}, - {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee439691d8c23e76f9802c42a95cfeebf9d47cf4ffd06f18489122dbb0a7ad64"}, - {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8f877c89719d759e52783f7fe6e1c67121076b87b40542966c02de5503ace42"}, - {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23b30c62d0f16827f2ae9f2bb87619bc4fba2044911e2e6c2eb1af0161cdb766"}, - {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85ab7824093d8f10d44330fe1e6493f756f252d145323dd17ab6b48733ff6c0a"}, - {file = "regex-2024.9.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8dee5b4810a89447151999428fe096977346cf2f29f4d5e29609d2e19e0199c9"}, - {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98eeee2f2e63edae2181c886d7911ce502e1292794f4c5ee71e60e23e8d26b5d"}, - {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:57fdd2e0b2694ce6fc2e5ccf189789c3e2962916fb38779d3e3521ff8fe7a822"}, - {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d552c78411f60b1fdaafd117a1fca2f02e562e309223b9d44b7de8be451ec5e0"}, - {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a0b2b80321c2ed3fcf0385ec9e51a12253c50f146fddb2abbb10f033fe3d049a"}, - {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:18406efb2f5a0e57e3a5881cd9354c1512d3bb4f5c45d96d110a66114d84d23a"}, - {file = "regex-2024.9.11-cp312-cp312-win32.whl", hash = "sha256:e464b467f1588e2c42d26814231edecbcfe77f5ac414d92cbf4e7b55b2c2a776"}, - {file = "regex-2024.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:9e8719792ca63c6b8340380352c24dcb8cd7ec49dae36e963742a275dfae6009"}, - {file = "regex-2024.9.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c157bb447303070f256e084668b702073db99bbb61d44f85d811025fcf38f784"}, - {file = "regex-2024.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4db21ece84dfeefc5d8a3863f101995de646c6cb0536952c321a2650aa202c36"}, - {file = "regex-2024.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:220e92a30b426daf23bb67a7962900ed4613589bab80382be09b48896d211e92"}, - {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb1ae19e64c14c7ec1995f40bd932448713d3c73509e82d8cd7744dc00e29e86"}, - {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f47cd43a5bfa48f86925fe26fbdd0a488ff15b62468abb5d2a1e092a4fb10e85"}, - {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9d4a76b96f398697fe01117093613166e6aa8195d63f1b4ec3f21ab637632963"}, - {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ea51dcc0835eea2ea31d66456210a4e01a076d820e9039b04ae8d17ac11dee6"}, - {file = "regex-2024.9.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7aaa315101c6567a9a45d2839322c51c8d6e81f67683d529512f5bcfb99c802"}, - {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c57d08ad67aba97af57a7263c2d9006d5c404d721c5f7542f077f109ec2a4a29"}, - {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8404bf61298bb6f8224bb9176c1424548ee1181130818fcd2cbffddc768bed8"}, - {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dd4490a33eb909ef5078ab20f5f000087afa2a4daa27b4c072ccb3cb3050ad84"}, - {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:eee9130eaad130649fd73e5cd92f60e55708952260ede70da64de420cdcad554"}, - {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a2644a93da36c784e546de579ec1806bfd2763ef47babc1b03d765fe560c9f8"}, - {file = "regex-2024.9.11-cp313-cp313-win32.whl", hash = "sha256:e997fd30430c57138adc06bba4c7c2968fb13d101e57dd5bb9355bf8ce3fa7e8"}, - {file = "regex-2024.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:042c55879cfeb21a8adacc84ea347721d3d83a159da6acdf1116859e2427c43f"}, - {file = "regex-2024.9.11-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:35f4a6f96aa6cb3f2f7247027b07b15a374f0d5b912c0001418d1d55024d5cb4"}, - {file = "regex-2024.9.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:55b96e7ce3a69a8449a66984c268062fbaa0d8ae437b285428e12797baefce7e"}, - {file = "regex-2024.9.11-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb130fccd1a37ed894824b8c046321540263013da72745d755f2d35114b81a60"}, - {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:323c1f04be6b2968944d730e5c2091c8c89767903ecaa135203eec4565ed2b2b"}, - {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be1c8ed48c4c4065ecb19d882a0ce1afe0745dfad8ce48c49586b90a55f02366"}, - {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5b029322e6e7b94fff16cd120ab35a253236a5f99a79fb04fda7ae71ca20ae8"}, - {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6fff13ef6b5f29221d6904aa816c34701462956aa72a77f1f151a8ec4f56aeb"}, - {file = "regex-2024.9.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:587d4af3979376652010e400accc30404e6c16b7df574048ab1f581af82065e4"}, - {file = "regex-2024.9.11-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:079400a8269544b955ffa9e31f186f01d96829110a3bf79dc338e9910f794fca"}, - {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f9268774428ec173654985ce55fc6caf4c6d11ade0f6f914d48ef4719eb05ebb"}, - {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:23f9985c8784e544d53fc2930fc1ac1a7319f5d5332d228437acc9f418f2f168"}, - {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:ae2941333154baff9838e88aa71c1d84f4438189ecc6021a12c7573728b5838e"}, - {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e93f1c331ca8e86fe877a48ad64e77882c0c4da0097f2212873a69bbfea95d0c"}, - {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:846bc79ee753acf93aef4184c040d709940c9d001029ceb7b7a52747b80ed2dd"}, - {file = "regex-2024.9.11-cp38-cp38-win32.whl", hash = "sha256:c94bb0a9f1db10a1d16c00880bdebd5f9faf267273b8f5bd1878126e0fbde771"}, - {file = "regex-2024.9.11-cp38-cp38-win_amd64.whl", hash = "sha256:2b08fce89fbd45664d3df6ad93e554b6c16933ffa9d55cb7e01182baaf971508"}, - {file = "regex-2024.9.11-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:07f45f287469039ffc2c53caf6803cd506eb5f5f637f1d4acb37a738f71dd066"}, - {file = "regex-2024.9.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4838e24ee015101d9f901988001038f7f0d90dc0c3b115541a1365fb439add62"}, - {file = "regex-2024.9.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6edd623bae6a737f10ce853ea076f56f507fd7726bee96a41ee3d68d347e4d16"}, - {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c69ada171c2d0e97a4b5aa78fbb835e0ffbb6b13fc5da968c09811346564f0d3"}, - {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02087ea0a03b4af1ed6ebab2c54d7118127fee8d71b26398e8e4b05b78963199"}, - {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69dee6a020693d12a3cf892aba4808fe168d2a4cef368eb9bf74f5398bfd4ee8"}, - {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297f54910247508e6e5cae669f2bc308985c60540a4edd1c77203ef19bfa63ca"}, - {file = "regex-2024.9.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecea58b43a67b1b79805f1a0255730edaf5191ecef84dbc4cc85eb30bc8b63b9"}, - {file = "regex-2024.9.11-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:eab4bb380f15e189d1313195b062a6aa908f5bd687a0ceccd47c8211e9cf0d4a"}, - {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0cbff728659ce4bbf4c30b2a1be040faafaa9eca6ecde40aaff86f7889f4ab39"}, - {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:54c4a097b8bc5bb0dfc83ae498061d53ad7b5762e00f4adaa23bee22b012e6ba"}, - {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:73d6d2f64f4d894c96626a75578b0bf7d9e56dcda8c3d037a2118fdfe9b1c664"}, - {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:e53b5fbab5d675aec9f0c501274c467c0f9a5d23696cfc94247e1fb56501ed89"}, - {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0ffbcf9221e04502fc35e54d1ce9567541979c3fdfb93d2c554f0ca583a19b35"}, - {file = "regex-2024.9.11-cp39-cp39-win32.whl", hash = "sha256:e4c22e1ac1f1ec1e09f72e6c44d8f2244173db7eb9629cc3a346a8d7ccc31142"}, - {file = "regex-2024.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:faa3c142464efec496967359ca99696c896c591c56c53506bac1ad465f66e919"}, - {file = "regex-2024.9.11.tar.gz", hash = "sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd"}, -] - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "restrictedpython" -version = "7.2" -description = "RestrictedPython is a defined subset of the Python language which allows to provide a program input into a trusted environment." -optional = false -python-versions = "<3.13,>=3.7" -files = [ - {file = "RestrictedPython-7.2-py3-none-any.whl", hash = "sha256:139cb41da6e57521745a566d05825f7a09e6a884f7fa922568cff0a70b84ce6b"}, - {file = "RestrictedPython-7.2.tar.gz", hash = "sha256:4d1d30f709a6621ca7c4236f08b67b732a651c8099145f137078c7dd4bed3d21"}, -] - -[package.extras] -docs = ["Sphinx", "sphinx-rtd-theme"] -test = ["pytest", "pytest-mock"] - -[[package]] -name = "rich" -version = "13.8.1" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"}, - {file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "rpds-py" -version = "0.20.0" -description = "Python bindings to Rust's persistent data structures (rpds)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"}, - {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6377e647bbfd0a0b159fe557f2c6c602c159fc752fa316572f012fc0bf67150"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb851b7df9dda52dc1415ebee12362047ce771fc36914586b2e9fcbd7d293b3e"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e0f80b739e5a8f54837be5d5c924483996b603d5502bfff79bf33da06164ee2"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a8c94dad2e45324fc74dce25e1645d4d14df9a4e54a30fa0ae8bad9a63928e3"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e604fe73ba048c06085beaf51147eaec7df856824bfe7b98657cf436623daf"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df3de6b7726b52966edf29663e57306b23ef775faf0ac01a3e9f4012a24a4140"}, - {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf258ede5bc22a45c8e726b29835b9303c285ab46fc7c3a4cc770736b5304c9f"}, - {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:55fea87029cded5df854ca7e192ec7bdb7ecd1d9a3f63d5c4eb09148acf4a7ce"}, - {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae94bd0b2f02c28e199e9bc51485d0c5601f58780636185660f86bf80c89af94"}, - {file = "rpds_py-0.20.0-cp310-none-win32.whl", hash = "sha256:28527c685f237c05445efec62426d285e47a58fb05ba0090a4340b73ecda6dee"}, - {file = "rpds_py-0.20.0-cp310-none-win_amd64.whl", hash = "sha256:238a2d5b1cad28cdc6ed15faf93a998336eb041c4e440dd7f902528b8891b399"}, - {file = "rpds_py-0.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac2f4f7a98934c2ed6505aead07b979e6f999389f16b714448fb39bbaa86a489"}, - {file = "rpds_py-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:220002c1b846db9afd83371d08d239fdc865e8f8c5795bbaec20916a76db3318"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d7919548df3f25374a1f5d01fbcd38dacab338ef5f33e044744b5c36729c8db"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:758406267907b3781beee0f0edfe4a179fbd97c0be2e9b1154d7f0a1279cf8e5"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d61339e9f84a3f0767b1995adfb171a0d00a1185192718a17af6e124728e0f5"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1259c7b3705ac0a0bd38197565a5d603218591d3f6cee6e614e380b6ba61c6f6"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c1dc0f53856b9cc9a0ccca0a7cc61d3d20a7088201c0937f3f4048c1718a209"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7e60cb630f674a31f0368ed32b2a6b4331b8350d67de53c0359992444b116dd3"}, - {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dbe982f38565bb50cb7fb061ebf762c2f254ca3d8c20d4006878766e84266272"}, - {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:514b3293b64187172bc77c8fb0cdae26981618021053b30d8371c3a902d4d5ad"}, - {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0a26ffe9d4dd35e4dfdd1e71f46401cff0181c75ac174711ccff0459135fa58"}, - {file = "rpds_py-0.20.0-cp311-none-win32.whl", hash = "sha256:89c19a494bf3ad08c1da49445cc5d13d8fefc265f48ee7e7556839acdacf69d0"}, - {file = "rpds_py-0.20.0-cp311-none-win_amd64.whl", hash = "sha256:c638144ce971df84650d3ed0096e2ae7af8e62ecbbb7b201c8935c370df00a2c"}, - {file = "rpds_py-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a84ab91cbe7aab97f7446652d0ed37d35b68a465aeef8fc41932a9d7eee2c1a6"}, - {file = "rpds_py-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56e27147a5a4c2c21633ff8475d185734c0e4befd1c989b5b95a5d0db699b21b"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2580b0c34583b85efec8c5c5ec9edf2dfe817330cc882ee972ae650e7b5ef739"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80d4a7900cf6b66bb9cee5c352b2d708e29e5a37fe9bf784fa97fc11504bf6c"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50eccbf054e62a7b2209b28dc7a22d6254860209d6753e6b78cfaeb0075d7bee"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49a8063ea4296b3a7e81a5dfb8f7b2d73f0b1c20c2af401fb0cdf22e14711a96"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea438162a9fcbee3ecf36c23e6c68237479f89f962f82dae83dc15feeceb37e4"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18d7585c463087bddcfa74c2ba267339f14f2515158ac4db30b1f9cbdb62c8ef"}, - {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4c7d1a051eeb39f5c9547e82ea27cbcc28338482242e3e0b7768033cb083821"}, - {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4df1e3b3bec320790f699890d41c59d250f6beda159ea3c44c3f5bac1976940"}, - {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cf126d33a91ee6eedc7f3197b53e87a2acdac63602c0f03a02dd69e4b138174"}, - {file = "rpds_py-0.20.0-cp312-none-win32.whl", hash = "sha256:8bc7690f7caee50b04a79bf017a8d020c1f48c2a1077ffe172abec59870f1139"}, - {file = "rpds_py-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:0e13e6952ef264c40587d510ad676a988df19adea20444c2b295e536457bc585"}, - {file = "rpds_py-0.20.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aa9a0521aeca7d4941499a73ad7d4f8ffa3d1affc50b9ea11d992cd7eff18a29"}, - {file = "rpds_py-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1f1d51eccb7e6c32ae89243cb352389228ea62f89cd80823ea7dd1b98e0b91"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a86a9b96070674fc88b6f9f71a97d2c1d3e5165574615d1f9168ecba4cecb24"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c8ef2ebf76df43f5750b46851ed1cdf8f109d7787ca40035fe19fbdc1acc5a7"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b25f024b421d5859d156750ea9a65651793d51b76a2e9238c05c9d5f203a9"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57eb94a8c16ab08fef6404301c38318e2c5a32216bf5de453e2714c964c125c8"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1940dae14e715e2e02dfd5b0f64a52e8374a517a1e531ad9412319dc3ac7879"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d20277fd62e1b992a50c43f13fbe13277a31f8c9f70d59759c88f644d66c619f"}, - {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:06db23d43f26478303e954c34c75182356ca9aa7797d22c5345b16871ab9c45c"}, - {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2a5db5397d82fa847e4c624b0c98fe59d2d9b7cf0ce6de09e4d2e80f8f5b3f2"}, - {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a35df9f5548fd79cb2f52d27182108c3e6641a4feb0f39067911bf2adaa3e57"}, - {file = "rpds_py-0.20.0-cp313-none-win32.whl", hash = "sha256:fd2d84f40633bc475ef2d5490b9c19543fbf18596dcb1b291e3a12ea5d722f7a"}, - {file = "rpds_py-0.20.0-cp313-none-win_amd64.whl", hash = "sha256:9bc2d153989e3216b0559251b0c260cfd168ec78b1fac33dd485750a228db5a2"}, - {file = "rpds_py-0.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:f2fbf7db2012d4876fb0d66b5b9ba6591197b0f165db8d99371d976546472a24"}, - {file = "rpds_py-0.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1e5f3cd7397c8f86c8cc72d5a791071431c108edd79872cdd96e00abd8497d29"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce9845054c13696f7af7f2b353e6b4f676dab1b4b215d7fe5e05c6f8bb06f965"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c3e130fd0ec56cb76eb49ef52faead8ff09d13f4527e9b0c400307ff72b408e1"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b16aa0107ecb512b568244ef461f27697164d9a68d8b35090e9b0c1c8b27752"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa7f429242aae2947246587d2964fad750b79e8c233a2367f71b554e9447949c"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af0fc424a5842a11e28956e69395fbbeab2c97c42253169d87e90aac2886d751"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b8c00a3b1e70c1d3891f0db1b05292747f0dbcfb49c43f9244d04c70fbc40eb8"}, - {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:40ce74fc86ee4645d0a225498d091d8bc61f39b709ebef8204cb8b5a464d3c0e"}, - {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4fe84294c7019456e56d93e8ababdad5a329cd25975be749c3f5f558abb48253"}, - {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:338ca4539aad4ce70a656e5187a3a31c5204f261aef9f6ab50e50bcdffaf050a"}, - {file = "rpds_py-0.20.0-cp38-none-win32.whl", hash = "sha256:54b43a2b07db18314669092bb2de584524d1ef414588780261e31e85846c26a5"}, - {file = "rpds_py-0.20.0-cp38-none-win_amd64.whl", hash = "sha256:a1862d2d7ce1674cffa6d186d53ca95c6e17ed2b06b3f4c476173565c862d232"}, - {file = "rpds_py-0.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3fde368e9140312b6e8b6c09fb9f8c8c2f00999d1823403ae90cc00480221b22"}, - {file = "rpds_py-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9824fb430c9cf9af743cf7aaf6707bf14323fb51ee74425c380f4c846ea70789"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11ef6ce74616342888b69878d45e9f779b95d4bd48b382a229fe624a409b72c5"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52d3f2f82b763a24ef52f5d24358553e8403ce05f893b5347098014f2d9eff2"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d35cef91e59ebbeaa45214861874bc6f19eb35de96db73e467a8358d701a96c"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d72278a30111e5b5525c1dd96120d9e958464316f55adb030433ea905866f4de"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c29cbbba378759ac5786730d1c3cb4ec6f8ababf5c42a9ce303dc4b3d08cda"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6632f2d04f15d1bd6fe0eedd3b86d9061b836ddca4c03d5cf5c7e9e6b7c14580"}, - {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d0b67d87bb45ed1cd020e8fbf2307d449b68abc45402fe1a4ac9e46c3c8b192b"}, - {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec31a99ca63bf3cd7f1a5ac9fe95c5e2d060d3c768a09bc1d16e235840861420"}, - {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e6c9976e38f4d8c4a63bd8a8edac5307dffd3ee7e6026d97f3cc3a2dc02a0b"}, - {file = "rpds_py-0.20.0-cp39-none-win32.whl", hash = "sha256:569b3ea770c2717b730b61998b6c54996adee3cef69fc28d444f3e7920313cf7"}, - {file = "rpds_py-0.20.0-cp39-none-win_amd64.whl", hash = "sha256:e6900ecdd50ce0facf703f7a00df12374b74bbc8ad9fe0f6559947fb20f82364"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:617c7357272c67696fd052811e352ac54ed1d9b49ab370261a80d3b6ce385045"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9426133526f69fcaba6e42146b4e12d6bc6c839b8b555097020e2b78ce908dcc"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deb62214c42a261cb3eb04d474f7155279c1a8a8c30ac89b7dcb1721d92c3c02"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcaeb7b57f1a1e071ebd748984359fef83ecb026325b9d4ca847c95bc7311c92"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d454b8749b4bd70dd0a79f428731ee263fa6995f83ccb8bada706e8d1d3ff89d"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d807dc2051abe041b6649681dce568f8e10668e3c1c6543ebae58f2d7e617855"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3c20f0ddeb6e29126d45f89206b8291352b8c5b44384e78a6499d68b52ae511"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7f19250ceef892adf27f0399b9e5afad019288e9be756d6919cb58892129f51"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4f1ed4749a08379555cebf4650453f14452eaa9c43d0a95c49db50c18b7da075"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:dcedf0b42bcb4cfff4101d7771a10532415a6106062f005ab97d1d0ab5681c60"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:39ed0d010457a78f54090fafb5d108501b5aa5604cc22408fc1c0c77eac14344"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bb273176be34a746bdac0b0d7e4e2c467323d13640b736c4c477881a3220a989"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f918a1a130a6dfe1d7fe0f105064141342e7dd1611f2e6a21cd2f5c8cb1cfb3e"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f60012a73aa396be721558caa3a6fd49b3dd0033d1675c6d59c4502e870fcf0c"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d2b1ad682a3dfda2a4e8ad8572f3100f95fad98cb99faf37ff0ddfe9cbf9d03"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:614fdafe9f5f19c63ea02817fa4861c606a59a604a77c8cdef5aa01d28b97921"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa518bcd7600c584bf42e6617ee8132869e877db2f76bcdc281ec6a4113a53ab"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0475242f447cc6cb8a9dd486d68b2ef7fbee84427124c232bff5f63b1fe11e5"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f90a4cd061914a60bd51c68bcb4357086991bd0bb93d8aa66a6da7701370708f"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:def7400461c3a3f26e49078302e1c1b38f6752342c77e3cf72ce91ca69fb1bc1"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:65794e4048ee837494aea3c21a28ad5fc080994dfba5b036cf84de37f7ad5074"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:faefcc78f53a88f3076b7f8be0a8f8d35133a3ecf7f3770895c25f8813460f08"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5b4f105deeffa28bbcdff6c49b34e74903139afa690e35d2d9e3c2c2fba18cec"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdfc3a892927458d98f3d55428ae46b921d1f7543b89382fdb483f5640daaec8"}, - {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"}, -] - -[[package]] -name = "sgmllib3k" -version = "1.0.0" -description = "Py3k port of sgmllib." -optional = false -python-versions = "*" -files = [ - {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, -] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "smmap" -version = "5.0.1" -description = "A pure Python implementation of a sliding window memory map manager" -optional = false -python-versions = ">=3.7" -files = [ - {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, - {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "sqlalchemy" -version = "2.0.35" -description = "Database Abstraction Library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "SQLAlchemy-2.0.35-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:67219632be22f14750f0d1c70e62f204ba69d28f62fd6432ba05ab295853de9b"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4668bd8faf7e5b71c0319407b608f278f279668f358857dbfd10ef1954ac9f90"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb8bea573863762bbf45d1e13f87c2d2fd32cee2dbd50d050f83f87429c9e1ea"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f552023710d4b93d8fb29a91fadf97de89c5926c6bd758897875435f2a939f33"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:016b2e665f778f13d3c438651dd4de244214b527a275e0acf1d44c05bc6026a9"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7befc148de64b6060937231cbff8d01ccf0bfd75aa26383ffdf8d82b12ec04ff"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-win32.whl", hash = "sha256:22b83aed390e3099584b839b93f80a0f4a95ee7f48270c97c90acd40ee646f0b"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-win_amd64.whl", hash = "sha256:a29762cd3d116585278ffb2e5b8cc311fb095ea278b96feef28d0b423154858e"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e21f66748ab725ade40fa7af8ec8b5019c68ab00b929f6643e1b1af461eddb60"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8a6219108a15fc6d24de499d0d515c7235c617b2540d97116b663dade1a54d62"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042622a5306c23b972192283f4e22372da3b8ddf5f7aac1cc5d9c9b222ab3ff6"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:627dee0c280eea91aed87b20a1f849e9ae2fe719d52cbf847c0e0ea34464b3f7"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4fdcd72a789c1c31ed242fd8c1bcd9ea186a98ee8e5408a50e610edfef980d71"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89b64cd8898a3a6f642db4eb7b26d1b28a497d4022eccd7717ca066823e9fb01"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-win32.whl", hash = "sha256:6a93c5a0dfe8d34951e8a6f499a9479ffb9258123551fa007fc708ae2ac2bc5e"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-win_amd64.whl", hash = "sha256:c68fe3fcde03920c46697585620135b4ecfdfc1ed23e75cc2c2ae9f8502c10b8"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:eb60b026d8ad0c97917cb81d3662d0b39b8ff1335e3fabb24984c6acd0c900a2"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6921ee01caf375363be5e9ae70d08ce7ca9d7e0e8983183080211a062d299468"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cdf1a0dbe5ced887a9b127da4ffd7354e9c1a3b9bb330dce84df6b70ccb3a8d"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93a71c8601e823236ac0e5d087e4f397874a421017b3318fd92c0b14acf2b6db"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e04b622bb8a88f10e439084486f2f6349bf4d50605ac3e445869c7ea5cf0fa8c"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1b56961e2d31389aaadf4906d453859f35302b4eb818d34a26fab72596076bb8"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-win32.whl", hash = "sha256:0f9f3f9a3763b9c4deb8c5d09c4cc52ffe49f9876af41cc1b2ad0138878453cf"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-win_amd64.whl", hash = "sha256:25b0f63e7fcc2a6290cb5f7f5b4fc4047843504983a28856ce9b35d8f7de03cc"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f021d334f2ca692523aaf7bbf7592ceff70c8594fad853416a81d66b35e3abf9"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05c3f58cf91683102f2f0265c0db3bd3892e9eedabe059720492dbaa4f922da1"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:032d979ce77a6c2432653322ba4cbeabf5a6837f704d16fa38b5a05d8e21fa00"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:2e795c2f7d7249b75bb5f479b432a51b59041580d20599d4e112b5f2046437a3"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:cc32b2990fc34380ec2f6195f33a76b6cdaa9eecf09f0c9404b74fc120aef36f"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-win32.whl", hash = "sha256:9509c4123491d0e63fb5e16199e09f8e262066e58903e84615c301dde8fa2e87"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-win_amd64.whl", hash = "sha256:3655af10ebcc0f1e4e06c5900bb33e080d6a1fa4228f502121f28a3b1753cde5"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4c31943b61ed8fdd63dfd12ccc919f2bf95eefca133767db6fbbd15da62078ec"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a62dd5d7cc8626a3634208df458c5fe4f21200d96a74d122c83bc2015b333bc1"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0630774b0977804fba4b6bbea6852ab56c14965a2b0c7fc7282c5f7d90a1ae72"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d625eddf7efeba2abfd9c014a22c0f6b3796e0ffb48f5d5ab106568ef01ff5a"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ada603db10bb865bbe591939de854faf2c60f43c9b763e90f653224138f910d9"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c41411e192f8d3ea39ea70e0fae48762cd11a2244e03751a98bd3c0ca9a4e936"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-win32.whl", hash = "sha256:d299797d75cd747e7797b1b41817111406b8b10a4f88b6e8fe5b5e59598b43b0"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-win_amd64.whl", hash = "sha256:0375a141e1c0878103eb3d719eb6d5aa444b490c96f3fedab8471c7f6ffe70ee"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ccae5de2a0140d8be6838c331604f91d6fafd0735dbdcee1ac78fc8fbaba76b4"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2a275a806f73e849e1c309ac11108ea1a14cd7058577aba962cd7190e27c9e3c"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:732e026240cdd1c1b2e3ac515c7a23820430ed94292ce33806a95869c46bd139"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:890da8cd1941fa3dab28c5bac3b9da8502e7e366f895b3b8e500896f12f94d11"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c0d8326269dbf944b9201911b0d9f3dc524d64779a07518199a58384c3d37a44"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b76d63495b0508ab9fc23f8152bac63205d2a704cd009a2b0722f4c8e0cba8e0"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-win32.whl", hash = "sha256:69683e02e8a9de37f17985905a5eca18ad651bf592314b4d3d799029797d0eb3"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-win_amd64.whl", hash = "sha256:aee110e4ef3c528f3abbc3c2018c121e708938adeeff9006428dd7c8555e9b3f"}, - {file = "SQLAlchemy-2.0.35-py3-none-any.whl", hash = "sha256:2ab3f0336c0387662ce6221ad30ab3a5e6499aab01b9790879b6578fd9b8faa1"}, - {file = "sqlalchemy-2.0.35.tar.gz", hash = "sha256:e11d7ea4d24f0a262bccf9a7cd6284c976c5369dac21db237cff59586045ab9f"}, -] - -[package.dependencies] -greenlet = {version = "!=0.4.17", optional = true, markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\") or extra == \"asyncio\""} -typing-extensions = ">=4.6.0" - -[package.extras] -aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] -aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] -aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] -asyncio = ["greenlet (!=0.4.17)"] -asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] -mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] -mssql = ["pyodbc"] -mssql-pymssql = ["pymssql"] -mssql-pyodbc = ["pyodbc"] -mypy = ["mypy (>=0.910)"] -mysql = ["mysqlclient (>=1.4.0)"] -mysql-connector = ["mysql-connector-python"] -oracle = ["cx_oracle (>=8)"] -oracle-oracledb = ["oracledb (>=1.0.1)"] -postgresql = ["psycopg2 (>=2.7)"] -postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] -postgresql-pg8000 = ["pg8000 (>=1.29.1)"] -postgresql-psycopg = ["psycopg (>=3.0.7)"] -postgresql-psycopg2binary = ["psycopg2-binary"] -postgresql-psycopg2cffi = ["psycopg2cffi"] -postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] -pymysql = ["pymysql"] -sqlcipher = ["sqlcipher3_binary"] - -[[package]] -name = "streamlit" -version = "1.39.0" -description = "A faster way to build and share data apps" -optional = false -python-versions = "!=3.9.7,>=3.8" -files = [ - {file = "streamlit-1.39.0-py2.py3-none-any.whl", hash = "sha256:a359fc54ed568b35b055ff1d453c320735539ad12e264365a36458aef55a5fba"}, - {file = "streamlit-1.39.0.tar.gz", hash = "sha256:fef9de7983c4ee65c08e85607d7ffccb56b00482b1041fa62f90e4815d39df3a"}, -] - -[package.dependencies] -altair = ">=4.0,<6" -blinker = ">=1.0.0,<2" -cachetools = ">=4.0,<6" -click = ">=7.0,<9" -gitpython = ">=3.0.7,<3.1.19 || >3.1.19,<4" -numpy = ">=1.20,<3" -packaging = ">=20,<25" -pandas = ">=1.4.0,<3" -pillow = ">=7.1.0,<11" -protobuf = ">=3.20,<6" -pyarrow = ">=7.0" -pydeck = ">=0.8.0b4,<1" -requests = ">=2.27,<3" -rich = ">=10.14.0,<14" -tenacity = ">=8.1.0,<10" -toml = ">=0.10.1,<2" -tornado = ">=6.0.3,<7" -typing-extensions = ">=4.3.0,<5" -watchdog = {version = ">=2.1.5,<6", markers = "platform_system != \"Darwin\""} - -[package.extras] -snowflake = ["snowflake-connector-python (>=2.8.0)", "snowflake-snowpark-python[modin] (>=1.17.0)"] - -[[package]] -name = "streamlit-pydantic" -version = "0.6.0" -description = "Auto-generate Streamlit UI from Pydantic Models & Dataclasses." -optional = false -python-versions = ">=3.7" -files = [ - {file = "streamlit-pydantic-0.6.0.tar.gz", hash = "sha256:3bc5d51af085eb6791b360f569f1a541681ddcc51579b09a1e2ab54639b39d49"}, - {file = "streamlit_pydantic-0.6.0-py3-none-any.whl", hash = "sha256:7a69ec6519f5de1b21bd9737891c61d8fea33d7727824ab19c4c65d49f136304"}, -] - -[package.dependencies] -importlib-resources = "*" -pydantic = ">=1.9" -streamlit = ">=1.14.0" - -[package.extras] -dev = ["black", "build", "colorama", "flake8", "isort", "lazydocs", "mypy", "pydocstyle", "pytest", "pytest-cov", "pytest-mock", "rope", "setuptools", "twine", "types-dataclasses", "universal-build", "wheel"] - -[[package]] -name = "sympy" -version = "1.13.3" -description = "Computer algebra system (CAS) in Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "sympy-1.13.3-py3-none-any.whl", hash = "sha256:54612cf55a62755ee71824ce692986f23c88ffa77207b30c1368eda4a7060f73"}, - {file = "sympy-1.13.3.tar.gz", hash = "sha256:b27fd2c6530e0ab39e275fc9b683895367e51d5da91baa8d3d64db2565fec4d9"}, -] - -[package.dependencies] -mpmath = ">=1.1.0,<1.4" - -[package.extras] -dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] - -[[package]] -name = "tenacity" -version = "8.5.0" -description = "Retry code until it succeeds" -optional = false -python-versions = ">=3.8" -files = [ - {file = "tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687"}, - {file = "tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78"}, -] - -[package.extras] -doc = ["reno", "sphinx"] -test = ["pytest", "tornado (>=4.5)", "typeguard"] - -[[package]] -name = "tiktoken" -version = "0.7.0" -description = "tiktoken is a fast BPE tokeniser for use with OpenAI's models" -optional = false -python-versions = ">=3.8" -files = [ - {file = "tiktoken-0.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485f3cc6aba7c6b6ce388ba634fbba656d9ee27f766216f45146beb4ac18b25f"}, - {file = "tiktoken-0.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e54be9a2cd2f6d6ffa3517b064983fb695c9a9d8aa7d574d1ef3c3f931a99225"}, - {file = "tiktoken-0.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79383a6e2c654c6040e5f8506f3750db9ddd71b550c724e673203b4f6b4b4590"}, - {file = "tiktoken-0.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d4511c52caacf3c4981d1ae2df85908bd31853f33d30b345c8b6830763f769c"}, - {file = "tiktoken-0.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13c94efacdd3de9aff824a788353aa5749c0faee1fbe3816df365ea450b82311"}, - {file = "tiktoken-0.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8e58c7eb29d2ab35a7a8929cbeea60216a4ccdf42efa8974d8e176d50c9a3df5"}, - {file = "tiktoken-0.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:21a20c3bd1dd3e55b91c1331bf25f4af522c525e771691adbc9a69336fa7f702"}, - {file = "tiktoken-0.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:10c7674f81e6e350fcbed7c09a65bca9356eaab27fb2dac65a1e440f2bcfe30f"}, - {file = "tiktoken-0.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:084cec29713bc9d4189a937f8a35dbdfa785bd1235a34c1124fe2323821ee93f"}, - {file = "tiktoken-0.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:811229fde1652fedcca7c6dfe76724d0908775b353556d8a71ed74d866f73f7b"}, - {file = "tiktoken-0.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86b6e7dc2e7ad1b3757e8a24597415bafcfb454cebf9a33a01f2e6ba2e663992"}, - {file = "tiktoken-0.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1063c5748be36344c7e18c7913c53e2cca116764c2080177e57d62c7ad4576d1"}, - {file = "tiktoken-0.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20295d21419bfcca092644f7e2f2138ff947a6eb8cfc732c09cc7d76988d4a89"}, - {file = "tiktoken-0.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:959d993749b083acc57a317cbc643fb85c014d055b2119b739487288f4e5d1cb"}, - {file = "tiktoken-0.7.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:71c55d066388c55a9c00f61d2c456a6086673ab7dec22dd739c23f77195b1908"}, - {file = "tiktoken-0.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09ed925bccaa8043e34c519fbb2f99110bd07c6fd67714793c21ac298e449410"}, - {file = "tiktoken-0.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03c6c40ff1db0f48a7b4d2dafeae73a5607aacb472fa11f125e7baf9dce73704"}, - {file = "tiktoken-0.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d20b5c6af30e621b4aca094ee61777a44118f52d886dbe4f02b70dfe05c15350"}, - {file = "tiktoken-0.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d427614c3e074004efa2f2411e16c826f9df427d3c70a54725cae860f09e4bf4"}, - {file = "tiktoken-0.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c46d7af7b8c6987fac9b9f61041b452afe92eb087d29c9ce54951280f899a97"}, - {file = "tiktoken-0.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:0bc603c30b9e371e7c4c7935aba02af5994a909fc3c0fe66e7004070858d3f8f"}, - {file = "tiktoken-0.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2398fecd38c921bcd68418675a6d155fad5f5e14c2e92fcf5fe566fa5485a858"}, - {file = "tiktoken-0.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f5f6afb52fb8a7ea1c811e435e4188f2bef81b5e0f7a8635cc79b0eef0193d6"}, - {file = "tiktoken-0.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:861f9ee616766d736be4147abac500732b505bf7013cfaf019b85892637f235e"}, - {file = "tiktoken-0.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54031f95c6939f6b78122c0aa03a93273a96365103793a22e1793ee86da31685"}, - {file = "tiktoken-0.7.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:fffdcb319b614cf14f04d02a52e26b1d1ae14a570f90e9b55461a72672f7b13d"}, - {file = "tiktoken-0.7.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c72baaeaefa03ff9ba9688624143c858d1f6b755bb85d456d59e529e17234769"}, - {file = "tiktoken-0.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:131b8aeb043a8f112aad9f46011dced25d62629091e51d9dc1adbf4a1cc6aa98"}, - {file = "tiktoken-0.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cabc6dc77460df44ec5b879e68692c63551ae4fae7460dd4ff17181df75f1db7"}, - {file = "tiktoken-0.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8d57f29171255f74c0aeacd0651e29aa47dff6f070cb9f35ebc14c82278f3b25"}, - {file = "tiktoken-0.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ee92776fdbb3efa02a83f968c19d4997a55c8e9ce7be821ceee04a1d1ee149c"}, - {file = "tiktoken-0.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e215292e99cb41fbc96988ef62ea63bb0ce1e15f2c147a61acc319f8b4cbe5bf"}, - {file = "tiktoken-0.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a81bac94769cab437dd3ab0b8a4bc4e0f9cf6835bcaa88de71f39af1791727a"}, - {file = "tiktoken-0.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d6d73ea93e91d5ca771256dfc9d1d29f5a554b83821a1dc0891987636e0ae226"}, - {file = "tiktoken-0.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:2bcb28ddf79ffa424f171dfeef9a4daff61a94c631ca6813f43967cb263b83b9"}, - {file = "tiktoken-0.7.0.tar.gz", hash = "sha256:1077266e949c24e0291f6c350433c6f0971365ece2b173a23bc3b9f9defef6b6"}, -] - -[package.dependencies] -regex = ">=2022.1.18" -requests = ">=2.26.0" - -[package.extras] -blobfile = ["blobfile (>=2)"] - -[[package]] -name = "tokenizers" -version = "0.20.0" -description = "" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tokenizers-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:6cff5c5e37c41bc5faa519d6f3df0679e4b37da54ea1f42121719c5e2b4905c0"}, - {file = "tokenizers-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:62a56bf75c27443432456f4ca5ca055befa95e25be8a28141cc495cac8ae4d6d"}, - {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68cc7de6a63f09c4a86909c2597b995aa66e19df852a23aea894929c74369929"}, - {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:053c37ecee482cc958fdee53af3c6534286a86f5d35aac476f7c246830e53ae5"}, - {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3d7074aaabc151a6363fa03db5493fc95b423b2a1874456783989e96d541c7b6"}, - {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a11435780f2acd89e8fefe5e81cecf01776f6edb9b3ac95bcb76baee76b30b90"}, - {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9a81cd2712973b007d84268d45fc3f6f90a79c31dfe7f1925e6732f8d2959987"}, - {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7dfd796ab9d909f76fb93080e1c7c8309f196ecb316eb130718cd5e34231c69"}, - {file = "tokenizers-0.20.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8029ad2aa8cb00605c9374566034c1cc1b15130713e0eb5afcef6cface8255c9"}, - {file = "tokenizers-0.20.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ca4d54260ebe97d59dfa9a30baa20d0c4dd9137d99a8801700055c561145c24e"}, - {file = "tokenizers-0.20.0-cp310-none-win32.whl", hash = "sha256:95ee16b57cec11b86a7940174ec5197d506439b0f415ab3859f254b1dffe9df0"}, - {file = "tokenizers-0.20.0-cp310-none-win_amd64.whl", hash = "sha256:0a61a11e93eeadbf02aea082ffc75241c4198e0608bbbac4f65a9026851dcf37"}, - {file = "tokenizers-0.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6636b798b3c4d6c9b1af1a918bd07c867808e5a21c64324e95318a237e6366c3"}, - {file = "tokenizers-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ec603e42eaf499ffd58b9258162add948717cf21372458132f14e13a6bc7172"}, - {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cce124264903a8ea6f8f48e1cc7669e5ef638c18bd4ab0a88769d5f92debdf7f"}, - {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07bbeba0231cf8de07aa6b9e33e9779ff103d47042eeeb859a8c432e3292fb98"}, - {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06c0ca8397b35d38b83a44a9c6929790c1692957d88541df061cb34d82ebbf08"}, - {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca6557ac3b83d912dfbb1f70ab56bd4b0594043916688e906ede09f42e192401"}, - {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a5ad94c9e80ac6098328bee2e3264dbced4c6faa34429994d473f795ec58ef4"}, - {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b5c7f906ee6bec30a9dc20268a8b80f3b9584de1c9f051671cb057dc6ce28f6"}, - {file = "tokenizers-0.20.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:31e087e9ee1b8f075b002bfee257e858dc695f955b43903e1bb4aa9f170e37fe"}, - {file = "tokenizers-0.20.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c3124fb6f3346cb3d8d775375d3b429bf4dcfc24f739822702009d20a4297990"}, - {file = "tokenizers-0.20.0-cp311-none-win32.whl", hash = "sha256:a4bb8b40ba9eefa621fdcabf04a74aa6038ae3be0c614c6458bd91a4697a452f"}, - {file = "tokenizers-0.20.0-cp311-none-win_amd64.whl", hash = "sha256:2b709d371f1fe60a28ef0c5c67815952d455ca7f34dbe7197eaaed3cc54b658e"}, - {file = "tokenizers-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:15c81a17d0d66f4987c6ca16f4bea7ec253b8c7ed1bb00fdc5d038b1bb56e714"}, - {file = "tokenizers-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a531cdf1fb6dc41c984c785a3b299cb0586de0b35683842a3afbb1e5207f910"}, - {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06caabeb4587f8404e0cd9d40f458e9cba3e815c8155a38e579a74ff3e2a4301"}, - {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8768f964f23f5b9f50546c0369c75ab3262de926983888bbe8b98be05392a79c"}, - {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:626403860152c816f97b649fd279bd622c3d417678c93b4b1a8909b6380b69a8"}, - {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c1b88fa9e5ff062326f4bf82681da5a96fca7104d921a6bd7b1e6fcf224af26"}, - {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d7e559436a07dc547f22ce1101f26d8b2fad387e28ec8e7e1e3b11695d681d8"}, - {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e48afb75e50449848964e4a67b0da01261dd3aa8df8daecf10db8fd7f5b076eb"}, - {file = "tokenizers-0.20.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:baf5d0e1ff44710a95eefc196dd87666ffc609fd447c5e5b68272a7c3d342a1d"}, - {file = "tokenizers-0.20.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e5e56df0e8ed23ba60ae3848c3f069a0710c4b197218fe4f89e27eba38510768"}, - {file = "tokenizers-0.20.0-cp312-none-win32.whl", hash = "sha256:ec53e5ecc142a82432f9c6c677dbbe5a2bfee92b8abf409a9ecb0d425ee0ce75"}, - {file = "tokenizers-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:f18661ece72e39c0dfaa174d6223248a15b457dbd4b0fc07809b8e6d3ca1a234"}, - {file = "tokenizers-0.20.0-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:f7065b1084d8d1a03dc89d9aad69bcbc8415d4bc123c367063eb32958cd85054"}, - {file = "tokenizers-0.20.0-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:e5d4069e4714e3f7ba0a4d3d44f9d84a432cd4e4aa85c3d7dd1f51440f12e4a1"}, - {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:799b808529e54b7e1a36350bda2aeb470e8390e484d3e98c10395cee61d4e3c6"}, - {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f9baa027cc8a281ad5f7725a93c204d7a46986f88edbe8ef7357f40a23fb9c7"}, - {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:010ec7f3f7a96adc4c2a34a3ada41fa14b4b936b5628b4ff7b33791258646c6b"}, - {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98d88f06155335b14fd78e32ee28ca5b2eb30fced4614e06eb14ae5f7fba24ed"}, - {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e13eb000ef540c2280758d1b9cfa5fe424b0424ae4458f440e6340a4f18b2638"}, - {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fab3cf066ff426f7e6d70435dc28a9ff01b2747be83810e397cba106f39430b0"}, - {file = "tokenizers-0.20.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:39fa3761b30a89368f322e5daf4130dce8495b79ad831f370449cdacfb0c0d37"}, - {file = "tokenizers-0.20.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c8da0fba4d179ddf2607821575998df3c294aa59aa8df5a6646dc64bc7352bce"}, - {file = "tokenizers-0.20.0-cp37-none-win32.whl", hash = "sha256:fada996d6da8cf213f6e3c91c12297ad4f6cdf7a85c2fadcd05ec32fa6846fcd"}, - {file = "tokenizers-0.20.0-cp37-none-win_amd64.whl", hash = "sha256:7d29aad702279e0760c265fcae832e89349078e3418dd329732d4503259fd6bd"}, - {file = "tokenizers-0.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:099c68207f3ef0227ecb6f80ab98ea74de559f7b124adc7b17778af0250ee90a"}, - {file = "tokenizers-0.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:68012d8a8cddb2eab3880870d7e2086cb359c7f7a2b03f5795044f5abff4e850"}, - {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9253bdd209c6aee168deca7d0e780581bf303e0058f268f9bb06859379de19b6"}, - {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f868600ddbcb0545905ed075eb7218a0756bf6c09dae7528ea2f8436ebd2c93"}, - {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a9643d9c8c5f99b6aba43fd10034f77cc6c22c31f496d2f0ee183047d948fa0"}, - {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c375c6a889aeab44734028bc65cc070acf93ccb0f9368be42b67a98e1063d3f6"}, - {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e359f852328e254f070bbd09a19a568421d23388f04aad9f2fb7da7704c7228d"}, - {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d98b01a309d4387f3b1c1dd68a8b8136af50376cf146c1b7e8d8ead217a5be4b"}, - {file = "tokenizers-0.20.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:459f7537119554c2899067dec1ac74a00d02beef6558f4ee2e99513bf6d568af"}, - {file = "tokenizers-0.20.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:392b87ec89452628c045c9f2a88bc2a827f4c79e7d84bc3b72752b74c2581f70"}, - {file = "tokenizers-0.20.0-cp38-none-win32.whl", hash = "sha256:55a393f893d2ed4dd95a1553c2e42d4d4086878266f437b03590d3f81984c4fe"}, - {file = "tokenizers-0.20.0-cp38-none-win_amd64.whl", hash = "sha256:30ffe33c5c2f2aab8e9a3340d0110dd9f7ace7eec7362e20a697802306bd8068"}, - {file = "tokenizers-0.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:aa2d4a6fed2a7e3f860c7fc9d48764bb30f2649d83915d66150d6340e06742b8"}, - {file = "tokenizers-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b5ef0f814084a897e9071fc4a868595f018c5c92889197bdc4bf19018769b148"}, - {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc1e1b791e8c3bf4c4f265f180dadaff1c957bf27129e16fdd5e5d43c2d3762c"}, - {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b69e55e481459c07885263743a0d3c18d52db19bae8226a19bcca4aaa213fff"}, - {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806b4d82e27a2512bc23057b2986bc8b85824914286975b84d8105ff40d03d9"}, - {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9859e9ef13adf5a473ccab39d31bff9c550606ae3c784bf772b40f615742a24f"}, - {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef703efedf4c20488a8eb17637b55973745b27997ff87bad88ed499b397d1144"}, - {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6eec0061bab94b1841ab87d10831fdf1b48ebaed60e6d66d66dbe1d873f92bf5"}, - {file = "tokenizers-0.20.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:980f3d0d7e73f845b69087f29a63c11c7eb924c4ad6b358da60f3db4cf24bdb4"}, - {file = "tokenizers-0.20.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7c157550a2f3851b29d7fdc9dc059fcf81ff0c0fc49a1e5173a89d533ed043fa"}, - {file = "tokenizers-0.20.0-cp39-none-win32.whl", hash = "sha256:8a3d2f4d08608ec4f9895ec25b4b36a97f05812543190a5f2c3cd19e8f041e5a"}, - {file = "tokenizers-0.20.0-cp39-none-win_amd64.whl", hash = "sha256:d90188d12afd0c75e537f9a1d92f9c7375650188ee4f48fdc76f9e38afbd2251"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d68e15f1815357b059ec266062340c343ea7f98f7f330602df81ffa3474b6122"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:23f9ecec637b9bc80da5f703808d29ed5329e56b5aa8d791d1088014f48afadc"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f830b318ee599e3d0665b3e325f85bc75ee2d2ca6285f52e439dc22b64691580"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3dc750def789cb1de1b5a37657919545e1d9ffa667658b3fa9cb7862407a1b8"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e26e6c755ae884c2ea6135cd215bdd0fccafe4ee62405014b8c3cd19954e3ab9"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a1158c7174f427182e08baa2a8ded2940f2b4a3e94969a85cc9cfd16004cbcea"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:6324826287a3fc198898d3dcf758fe4a8479e42d6039f4c59e2cedd3cf92f64e"}, - {file = "tokenizers-0.20.0-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7d8653149405bb0c16feaf9cfee327fdb6aaef9dc2998349fec686f35e81c4e2"}, - {file = "tokenizers-0.20.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8a2dc1e402a155e97309287ca085c80eb1b7fab8ae91527d3b729181639fa51"}, - {file = "tokenizers-0.20.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07bef67b20aa6e5f7868c42c7c5eae4d24f856274a464ae62e47a0f2cccec3da"}, - {file = "tokenizers-0.20.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da06e397182ff53789c506c7833220c192952c57e1581a53f503d8d953e2d67e"}, - {file = "tokenizers-0.20.0-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:302f7e11a14814028b7fc88c45a41f1bbe9b5b35fd76d6869558d1d1809baa43"}, - {file = "tokenizers-0.20.0-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:055ec46e807b875589dfbe3d9259f9a6ee43394fb553b03b3d1e9541662dbf25"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e3144b8acebfa6ae062e8f45f7ed52e4b50fb6c62f93afc8871b525ab9fdcab3"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:b52aa3fd14b2a07588c00a19f66511cff5cca8f7266ca3edcdd17f3512ad159f"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b8cf52779ffc5d4d63a0170fbeb512372bad0dd014ce92bbb9149756c831124"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:983a45dd11a876124378dae71d6d9761822199b68a4c73f32873d8cdaf326a5b"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df6b819c9a19831ebec581e71a7686a54ab45d90faf3842269a10c11d746de0c"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e738cfd80795fcafcef89c5731c84b05638a4ab3f412f97d5ed7765466576eb1"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c8842c7be2fadb9c9edcee233b1b7fe7ade406c99b0973f07439985c1c1d0683"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e47a82355511c373a4a430c4909dc1e518e00031207b1fec536c49127388886b"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:9afbf359004551179a5db19424180c81276682773cff2c5d002f6eaaffe17230"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a07eaa8799a92e6af6f472c21a75bf71575de2af3c0284120b7a09297c0de2f3"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0994b2e5fc53a301071806bc4303e4bc3bdc3f490e92a21338146a36746b0872"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b6466e0355b603d10e3cc3d282d350b646341b601e50969464a54939f9848d0"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:1e86594c2a433cb1ea09cfbe596454448c566e57ee8905bd557e489d93e89986"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3e14cdef1efa96ecead6ea64a891828432c3ebba128bdc0596e3059fea104ef3"}, - {file = "tokenizers-0.20.0.tar.gz", hash = "sha256:39d7acc43f564c274085cafcd1dae9d36f332456de1a31970296a6b8da4eac8d"}, -] - -[package.dependencies] -huggingface-hub = ">=0.16.4,<1.0" - -[package.extras] -dev = ["tokenizers[testing]"] -docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"] -testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests", "ruff"] - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "tornado" -version = "6.4.1" -description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." -optional = false -python-versions = ">=3.8" -files = [ - {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8"}, - {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14"}, - {file = "tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4"}, - {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ae50a504a740365267b2a8d1a90c9fbc86b780a39170feca9bcc1787ff80842"}, - {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3"}, - {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25486eb223babe3eed4b8aecbac33b37e3dd6d776bc730ca14e1bf93888b979f"}, - {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:454db8a7ecfcf2ff6042dde58404164d969b6f5d58b926da15e6b23817950fc4"}, - {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a02a08cc7a9314b006f653ce40483b9b3c12cda222d6a46d4ac63bb6c9057698"}, - {file = "tornado-6.4.1-cp38-abi3-win32.whl", hash = "sha256:d9a566c40b89757c9aa8e6f032bcdb8ca8795d7c1a9762910c722b1635c9de4d"}, - {file = "tornado-6.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:b24b8982ed444378d7f21d563f4180a2de31ced9d8d84443907a0a64da2072e7"}, - {file = "tornado-6.4.1.tar.gz", hash = "sha256:92d3ab53183d8c50f8204a51e6f91d18a15d5ef261e84d452800d4ff6fc504e9"}, -] - -[[package]] -name = "tqdm" -version = "4.66.5" -description = "Fast, Extensible Progress Meter" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"}, - {file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[package.extras] -dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"] -notebook = ["ipywidgets (>=6)"] -slack = ["slack-sdk"] -telegram = ["requests"] - -[[package]] -name = "tree-sitter" -version = "0.21.3" -description = "Python bindings for the Tree-Sitter parsing library" -optional = false -python-versions = ">=3.8" -files = [ - {file = "tree-sitter-0.21.3.tar.gz", hash = "sha256:b5de3028921522365aa864d95b3c41926e0ba6a85ee5bd000e10dc49b0766988"}, - {file = "tree_sitter-0.21.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:351f302b6615230c9dac9829f0ba20a94362cd658206ca9a7b2d58d73373dfb0"}, - {file = "tree_sitter-0.21.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:766e79ae1e61271e7fdfecf35b6401ad9b47fc07a0965ad78e7f97fddfdf47a6"}, - {file = "tree_sitter-0.21.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c4d3d4d4b44857e87de55302af7f2d051c912c466ef20e8f18158e64df3542a"}, - {file = "tree_sitter-0.21.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84eedb06615461b9e2847be7c47b9c5f2195d7d66d31b33c0a227eff4e0a0199"}, - {file = "tree_sitter-0.21.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9d33ea425df8c3d6436926fe2991429d59c335431bf4e3c71e77c17eb508be5a"}, - {file = "tree_sitter-0.21.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fae1ee0ff6d85e2fd5cd8ceb9fe4af4012220ee1e4cbe813305a316caf7a6f63"}, - {file = "tree_sitter-0.21.3-cp310-cp310-win_amd64.whl", hash = "sha256:bb41be86a987391f9970571aebe005ccd10222f39c25efd15826583c761a37e5"}, - {file = "tree_sitter-0.21.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54b22c3c2aab3e3639a4b255d9df8455da2921d050c4829b6a5663b057f10db5"}, - {file = "tree_sitter-0.21.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab6e88c1e2d5e84ff0f9e5cd83f21b8e5074ad292a2cf19df3ba31d94fbcecd4"}, - {file = "tree_sitter-0.21.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3fd34ed4cd5db445bc448361b5da46a2a781c648328dc5879d768f16a46771"}, - {file = "tree_sitter-0.21.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fabc7182f6083269ce3cfcad202fe01516aa80df64573b390af6cd853e8444a1"}, - {file = "tree_sitter-0.21.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f874c3f7d2a2faf5c91982dc7d88ff2a8f183a21fe475c29bee3009773b0558"}, - {file = "tree_sitter-0.21.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ee61ee3b7a4eedf9d8f1635c68ba4a6fa8c46929601fc48a907c6cfef0cfbcb2"}, - {file = "tree_sitter-0.21.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b7256c723642de1c05fbb776b27742204a2382e337af22f4d9e279d77df7aa2"}, - {file = "tree_sitter-0.21.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:669b3e5a52cb1e37d60c7b16cc2221c76520445bb4f12dd17fd7220217f5abf3"}, - {file = "tree_sitter-0.21.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2aa2a5099a9f667730ff26d57533cc893d766667f4d8a9877e76a9e74f48f0d3"}, - {file = "tree_sitter-0.21.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a3e06ae2a517cf6f1abb682974f76fa760298e6d5a3ecf2cf140c70f898adf0"}, - {file = "tree_sitter-0.21.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af992dfe08b4fefcfcdb40548d0d26d5d2e0a0f2d833487372f3728cd0772b48"}, - {file = "tree_sitter-0.21.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c7cbab1dd9765138505c4a55e2aa857575bac4f1f8a8b0457744a4fefa1288e6"}, - {file = "tree_sitter-0.21.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e1e66aeb457d1529370fcb0997ae5584c6879e0e662f1b11b2f295ea57e22f54"}, - {file = "tree_sitter-0.21.3-cp312-cp312-win_amd64.whl", hash = "sha256:013c750252dc3bd0e069d82e9658de35ed50eecf31c6586d0de7f942546824c5"}, - {file = "tree_sitter-0.21.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4986a8cb4acebd168474ec2e5db440e59c7888819b3449a43ce8b17ed0331b07"}, - {file = "tree_sitter-0.21.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6e217fee2e7be7dbce4496caa3d1c466977d7e81277b677f954d3c90e3272ec2"}, - {file = "tree_sitter-0.21.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f32a88afff4f2bc0f20632b0a2aa35fa9ae7d518f083409eca253518e0950929"}, - {file = "tree_sitter-0.21.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3652ac9e47cdddf213c5d5d6854194469097e62f7181c0a9aa8435449a163a9"}, - {file = "tree_sitter-0.21.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:60b4df3298ff467bc01e2c0f6c2fb43aca088038202304bf8e41edd9fa348f45"}, - {file = "tree_sitter-0.21.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:00e4d0c99dff595398ef5e88a1b1ddd53adb13233fb677c1fd8e497fb2361629"}, - {file = "tree_sitter-0.21.3-cp38-cp38-win_amd64.whl", hash = "sha256:50c91353a26946e4dd6779837ecaf8aa123aafa2d3209f261ab5280daf0962f5"}, - {file = "tree_sitter-0.21.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b17b8648b296ccc21a88d72ca054b809ee82d4b14483e419474e7216240ea278"}, - {file = "tree_sitter-0.21.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f2f057fd01d3a95cbce6794c6e9f6db3d376cb3bb14e5b0528d77f0ec21d6478"}, - {file = "tree_sitter-0.21.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:839759de30230ffd60687edbb119b31521d5ac016749358e5285816798bb804a"}, - {file = "tree_sitter-0.21.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df40aa29cb7e323898194246df7a03b9676955a0ac1f6bce06bc4903a70b5f7"}, - {file = "tree_sitter-0.21.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1d9be27dde007b569fa78ff9af5fe40d2532c998add9997a9729e348bb78fa59"}, - {file = "tree_sitter-0.21.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c4ac87735e6f98fe085244c7c020f0177d13d4c117db72ba041faa980d25d69d"}, - {file = "tree_sitter-0.21.3-cp39-cp39-win_amd64.whl", hash = "sha256:fbbd137f7d9a5309fb4cb82e2c3250ba101b0dd08a8abdce815661e6cf2cbc19"}, -] - -[[package]] -name = "tree-sitter-languages" -version = "1.10.2" -description = "Binary Python wheels for all tree sitter languages." -optional = false -python-versions = "*" -files = [ - {file = "tree_sitter_languages-1.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5580348f0b20233b1d5431fa178ccd3d07423ca4a3275df02a44608fd72344b9"}, - {file = "tree_sitter_languages-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:103c7466644486b1e9e03850df46fc6aa12f13ca636c74f173270276220ac80b"}, - {file = "tree_sitter_languages-1.10.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d13db84511c6f1a7dc40383b66deafa74dabd8b877e3d65ab253f3719eccafd6"}, - {file = "tree_sitter_languages-1.10.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57adfa32be7e465b54aa72f915f6c78a2b66b227df4f656b5d4fbd1ca7a92b3f"}, - {file = "tree_sitter_languages-1.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c6385e033e460ceb8f33f3f940335f422ef2b763700a04f0089391a68b56153"}, - {file = "tree_sitter_languages-1.10.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dfa3f38cc5381c5aba01dd7494f59b8a9050e82ff6e06e1233e3a0cbae297e3c"}, - {file = "tree_sitter_languages-1.10.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9f195155acf47f8bc5de7cee46ecd07b2f5697f007ba89435b51ef4c0b953ea5"}, - {file = "tree_sitter_languages-1.10.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2de330e2ac6d7426ca025a3ec0f10d5640c3682c1d0c7702e812dcfb44b58120"}, - {file = "tree_sitter_languages-1.10.2-cp310-cp310-win32.whl", hash = "sha256:c9731cf745f135d9770eeba9bb4e2ff4dabc107b5ae9b8211e919f6b9100ea6d"}, - {file = "tree_sitter_languages-1.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:6dd75851c41d0c3c4987a9b7692d90fa8848706c23115669d8224ffd6571e357"}, - {file = "tree_sitter_languages-1.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7eb7d7542b2091c875fe52719209631fca36f8c10fa66970d2c576ae6a1b8289"}, - {file = "tree_sitter_languages-1.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6b41bcb00974b1c8a1800c7f1bb476a1d15a0463e760ee24872f2d53b08ee424"}, - {file = "tree_sitter_languages-1.10.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f370cd7845c6c81df05680d5bd96db8a99d32b56f4728c5d05978911130a853"}, - {file = "tree_sitter_languages-1.10.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1dc195c88ef4c72607e112a809a69190e096a2e5ebc6201548b3e05fdd169ad"}, - {file = "tree_sitter_languages-1.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae34ac314a7170be24998a0f994c1ac80761d8d4bd126af27ee53a023d3b849"}, - {file = "tree_sitter_languages-1.10.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:01b5742d5f5bd675489486b582bd482215880b26dde042c067f8265a6e925d9c"}, - {file = "tree_sitter_languages-1.10.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ab1cbc46244d34fd16f21edaa20231b2a57f09f092a06ee3d469f3117e6eb954"}, - {file = "tree_sitter_languages-1.10.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0b1149e7467a4e92b8a70e6005fe762f880f493cf811fc003554b29f04f5e7c8"}, - {file = "tree_sitter_languages-1.10.2-cp311-cp311-win32.whl", hash = "sha256:049276343962f4696390ee555acc2c1a65873270c66a6cbe5cb0bca83bcdf3c6"}, - {file = "tree_sitter_languages-1.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:7f3fdd468a577f04db3b63454d939e26e360229b53c80361920aa1ebf2cd7491"}, - {file = "tree_sitter_languages-1.10.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c0f4c8b2734c45859edc7fcaaeaab97a074114111b5ba51ab4ec7ed52104763c"}, - {file = "tree_sitter_languages-1.10.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eecd3c1244ac3425b7a82ba9125b4ddb45d953bbe61de114c0334fd89b7fe782"}, - {file = "tree_sitter_languages-1.10.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15db3c8510bc39a80147ee7421bf4782c15c09581c1dc2237ea89cefbd95b846"}, - {file = "tree_sitter_languages-1.10.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92c6487a6feea683154d3e06e6db68c30e0ae749a7ce4ce90b9e4e46b78c85c7"}, - {file = "tree_sitter_languages-1.10.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2f1cd1d1bdd65332f9c2b67d49dcf148cf1ded752851d159ac3e5ee4f4d260"}, - {file = "tree_sitter_languages-1.10.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:976c8039165b8e12f17a01ddee9f4e23ec6e352b165ad29b44d2bf04e2fbe77e"}, - {file = "tree_sitter_languages-1.10.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:dafbbdf16bf668a580902e1620f4baa1913e79438abcce721a50647564c687b9"}, - {file = "tree_sitter_languages-1.10.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1aeabd3d60d6d276b73cd8f3739d595b1299d123cc079a317f1a5b3c5461e2ca"}, - {file = "tree_sitter_languages-1.10.2-cp312-cp312-win32.whl", hash = "sha256:fab8ee641914098e8933b87ea3d657bea4dd00723c1ee7038b847b12eeeef4f5"}, - {file = "tree_sitter_languages-1.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:5e606430d736367e5787fa5a7a0c5a1ec9b85eded0b3596bbc0d83532a40810b"}, - {file = "tree_sitter_languages-1.10.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:838d5b48a7ed7a17658721952c77fda4570d2a069f933502653b17e15a9c39c9"}, - {file = "tree_sitter_languages-1.10.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:987b3c71b1d278c2889e018ee77b8ee05c384e2e3334dec798f8b611c4ab2d1e"}, - {file = "tree_sitter_languages-1.10.2-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:faa00abcb2c819027df58472da055d22fa7dfcb77c77413d8500c32ebe24d38b"}, - {file = "tree_sitter_languages-1.10.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e102fbbf02322d9201a86a814e79a9734ac80679fdb9682144479044f401a73"}, - {file = "tree_sitter_languages-1.10.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8f0b87cf1a7b03174ba18dfd81582be82bfed26803aebfe222bd20e444aba003"}, - {file = "tree_sitter_languages-1.10.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c0f1b9af9cb67f0b942b020da9fdd000aad5e92f2383ae0ba7a330b318d31912"}, - {file = "tree_sitter_languages-1.10.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5a4076c921f7a4d31e643843de7dfe040b65b63a238a5aa8d31d93aabe6572aa"}, - {file = "tree_sitter_languages-1.10.2-cp37-cp37m-win32.whl", hash = "sha256:fa6391a3a5d83d32db80815161237b67d70576f090ce5f38339206e917a6f8bd"}, - {file = "tree_sitter_languages-1.10.2-cp37-cp37m-win_amd64.whl", hash = "sha256:55649d3f254585a064121513627cf9788c1cfdadbc5f097f33d5ba750685a4c0"}, - {file = "tree_sitter_languages-1.10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6f85d1edaa2d22d80d4ea5b6d12b95cf3644017b6c227d0d42854439e02e8893"}, - {file = "tree_sitter_languages-1.10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d78feed4a764ef3141cb54bf00fe94d514d8b6e26e09423e23b4c616fcb7938c"}, - {file = "tree_sitter_languages-1.10.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da1aca27531f9dd5308637d76643372856f0f65d0d28677d1bcf4211e8ed1ad0"}, - {file = "tree_sitter_languages-1.10.2-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1031ea440dafb72237437d754eff8940153a3b051e3d18932ac25e75ce060a15"}, - {file = "tree_sitter_languages-1.10.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99d3249beaef2c9fe558ecc9a97853c260433a849dcc68266d9770d196c2e102"}, - {file = "tree_sitter_languages-1.10.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:59a4450f262a55148fb7e68681522f0c2a2f6b7d89666312a2b32708d8f416e1"}, - {file = "tree_sitter_languages-1.10.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ce74eab0e430370d5e15a96b6c6205f93405c177a8b2e71e1526643b2fb9bab1"}, - {file = "tree_sitter_languages-1.10.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9b4dd2b6b3d24c85dffe33d6c343448869eaf4f41c19ddba662eb5d65d8808f4"}, - {file = "tree_sitter_languages-1.10.2-cp38-cp38-win32.whl", hash = "sha256:92d734fb968fe3927a7596d9f0459f81a8fa7b07e16569476b28e27d0d753348"}, - {file = "tree_sitter_languages-1.10.2-cp38-cp38-win_amd64.whl", hash = "sha256:46a13f7d38f2eeb75f7cf127d1201346093748c270d686131f0cbc50e42870a1"}, - {file = "tree_sitter_languages-1.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f8c6a936ae99fdd8857e91f86c11c2f5e507ff30631d141d98132bb7ab2c8638"}, - {file = "tree_sitter_languages-1.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c283a61423f49cdfa7b5a5dfbb39221e3bd126fca33479cd80749d4d7a6b7349"}, - {file = "tree_sitter_languages-1.10.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76e60be6bdcff923386a54a5edcb6ff33fc38ab0118636a762024fa2bc98de55"}, - {file = "tree_sitter_languages-1.10.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c00069f9575bd831eabcce2cdfab158dde1ed151e7e5614c2d985ff7d78a7de1"}, - {file = "tree_sitter_languages-1.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:475ff53203d8a43ccb19bb322fa2fb200d764001cc037793f1fadd714bb343da"}, - {file = "tree_sitter_languages-1.10.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26fe7c9c412e4141dea87ea4b3592fd12e385465b5bdab106b0d5125754d4f60"}, - {file = "tree_sitter_languages-1.10.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8fed27319957458340f24fe14daad467cd45021da034eef583519f83113a8c5e"}, - {file = "tree_sitter_languages-1.10.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3657a491a7f96cc75a3568ddd062d25f3be82b6a942c68801a7b226ff7130181"}, - {file = "tree_sitter_languages-1.10.2-cp39-cp39-win32.whl", hash = "sha256:33f7d584d01a7a3c893072f34cfc64ec031f3cfe57eebc32da2f8ac046e101a7"}, - {file = "tree_sitter_languages-1.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:1b944af3ee729fa70fc8ae82224a9ff597cdb63addea084e0ea2fa2b0ec39bb7"}, -] - -[package.dependencies] -tree-sitter = "*" - -[[package]] -name = "types-requests" -version = "2.32.0.20240914" -description = "Typing stubs for requests" -optional = false -python-versions = ">=3.8" -files = [ - {file = "types-requests-2.32.0.20240914.tar.gz", hash = "sha256:2850e178db3919d9bf809e434eef65ba49d0e7e33ac92d588f4a5e295fffd405"}, - {file = "types_requests-2.32.0.20240914-py3-none-any.whl", hash = "sha256:59c2f673eb55f32a99b2894faf6020e1a9f4a402ad0f192bfee0b64469054310"}, -] - -[package.dependencies] -urllib3 = ">=2" - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "typing-inspect" -version = "0.9.0" -description = "Runtime inspection utilities for typing module." -optional = false -python-versions = "*" -files = [ - {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"}, - {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"}, -] - -[package.dependencies] -mypy-extensions = ">=0.3.0" -typing-extensions = ">=3.7.4" - -[[package]] -name = "tzdata" -version = "2024.1" -description = "Provider of IANA time zone data" -optional = false -python-versions = ">=2" -files = [ - {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, - {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, -] - -[[package]] -name = "urllib3" -version = "2.2.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -files = [ - {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, - {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "watchdog" -version = "5.0.3" -description = "Filesystem events monitoring" -optional = false -python-versions = ">=3.9" -files = [ - {file = "watchdog-5.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:85527b882f3facda0579bce9d743ff7f10c3e1e0db0a0d0e28170a7d0e5ce2ea"}, - {file = "watchdog-5.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:53adf73dcdc0ef04f7735066b4a57a4cd3e49ef135daae41d77395f0b5b692cb"}, - {file = "watchdog-5.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e25adddab85f674acac303cf1f5835951345a56c5f7f582987d266679979c75b"}, - {file = "watchdog-5.0.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f01f4a3565a387080dc49bdd1fefe4ecc77f894991b88ef927edbfa45eb10818"}, - {file = "watchdog-5.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91b522adc25614cdeaf91f7897800b82c13b4b8ac68a42ca959f992f6990c490"}, - {file = "watchdog-5.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d52db5beb5e476e6853da2e2d24dbbbed6797b449c8bf7ea118a4ee0d2c9040e"}, - {file = "watchdog-5.0.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d11b07c64f63f49876e0ab8042ae034674c8653bfcdaa8c4b32e71cfff87e8"}, - {file = "watchdog-5.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:349c9488e1d85d0a58e8cb14222d2c51cbc801ce11ac3936ab4c3af986536926"}, - {file = "watchdog-5.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:53a3f10b62c2d569e260f96e8d966463dec1a50fa4f1b22aec69e3f91025060e"}, - {file = "watchdog-5.0.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:950f531ec6e03696a2414b6308f5c6ff9dab7821a768c9d5788b1314e9a46ca7"}, - {file = "watchdog-5.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6deb336cba5d71476caa029ceb6e88047fc1dc74b62b7c4012639c0b563906"}, - {file = "watchdog-5.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1021223c08ba8d2d38d71ec1704496471ffd7be42cfb26b87cd5059323a389a1"}, - {file = "watchdog-5.0.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:752fb40efc7cc8d88ebc332b8f4bcbe2b5cc7e881bccfeb8e25054c00c994ee3"}, - {file = "watchdog-5.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a2e8f3f955d68471fa37b0e3add18500790d129cc7efe89971b8a4cc6fdeb0b2"}, - {file = "watchdog-5.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b8ca4d854adcf480bdfd80f46fdd6fb49f91dd020ae11c89b3a79e19454ec627"}, - {file = "watchdog-5.0.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:90a67d7857adb1d985aca232cc9905dd5bc4803ed85cfcdcfcf707e52049eda7"}, - {file = "watchdog-5.0.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:720ef9d3a4f9ca575a780af283c8fd3a0674b307651c1976714745090da5a9e8"}, - {file = "watchdog-5.0.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:223160bb359281bb8e31c8f1068bf71a6b16a8ad3d9524ca6f523ac666bb6a1e"}, - {file = "watchdog-5.0.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:560135542c91eaa74247a2e8430cf83c4342b29e8ad4f520ae14f0c8a19cfb5b"}, - {file = "watchdog-5.0.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:dd021efa85970bd4824acacbb922066159d0f9e546389a4743d56919b6758b91"}, - {file = "watchdog-5.0.3-py3-none-manylinux2014_armv7l.whl", hash = "sha256:78864cc8f23dbee55be34cc1494632a7ba30263951b5b2e8fc8286b95845f82c"}, - {file = "watchdog-5.0.3-py3-none-manylinux2014_i686.whl", hash = "sha256:1e9679245e3ea6498494b3028b90c7b25dbb2abe65c7d07423ecfc2d6218ff7c"}, - {file = "watchdog-5.0.3-py3-none-manylinux2014_ppc64.whl", hash = "sha256:9413384f26b5d050b6978e6fcd0c1e7f0539be7a4f1a885061473c5deaa57221"}, - {file = "watchdog-5.0.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:294b7a598974b8e2c6123d19ef15de9abcd282b0fbbdbc4d23dfa812959a9e05"}, - {file = "watchdog-5.0.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:26dd201857d702bdf9d78c273cafcab5871dd29343748524695cecffa44a8d97"}, - {file = "watchdog-5.0.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:0f9332243355643d567697c3e3fa07330a1d1abf981611654a1f2bf2175612b7"}, - {file = "watchdog-5.0.3-py3-none-win32.whl", hash = "sha256:c66f80ee5b602a9c7ab66e3c9f36026590a0902db3aea414d59a2f55188c1f49"}, - {file = "watchdog-5.0.3-py3-none-win_amd64.whl", hash = "sha256:f00b4cf737f568be9665563347a910f8bdc76f88c2970121c86243c8cfdf90e9"}, - {file = "watchdog-5.0.3-py3-none-win_ia64.whl", hash = "sha256:49f4d36cb315c25ea0d946e018c01bb028048023b9e103d3d3943f58e109dd45"}, - {file = "watchdog-5.0.3.tar.gz", hash = "sha256:108f42a7f0345042a854d4d0ad0834b741d421330d5f575b81cb27b883500176"}, -] - -[package.extras] -watchmedo = ["PyYAML (>=3.10)"] - -[[package]] -name = "wcwidth" -version = "0.2.13" -description = "Measures the displayed width of unicode strings in a terminal" -optional = false -python-versions = "*" -files = [ - {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, - {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, -] - -[[package]] -name = "wrapt" -version = "1.16.0" -description = "Module for decorators, wrappers and monkey patching." -optional = false -python-versions = ">=3.6" -files = [ - {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, - {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, - {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, - {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, - {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, - {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, - {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, - {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, - {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, - {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, - {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, - {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, - {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, - {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, - {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, - {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, - {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, - {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, -] - -[[package]] -name = "xxhash" -version = "3.5.0" -description = "Python binding for xxHash" -optional = false -python-versions = ">=3.7" -files = [ - {file = "xxhash-3.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ece616532c499ee9afbb83078b1b952beffef121d989841f7f4b3dc5ac0fd212"}, - {file = "xxhash-3.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3171f693dbc2cef6477054a665dc255d996646b4023fe56cb4db80e26f4cc520"}, - {file = "xxhash-3.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c5d3e570ef46adaf93fc81b44aca6002b5a4d8ca11bd0580c07eac537f36680"}, - {file = "xxhash-3.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7cb29a034301e2982df8b1fe6328a84f4b676106a13e9135a0d7e0c3e9f806da"}, - {file = "xxhash-3.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0d307d27099bb0cbeea7260eb39ed4fdb99c5542e21e94bb6fd29e49c57a23"}, - {file = "xxhash-3.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0342aafd421795d740e514bc9858ebddfc705a75a8c5046ac56d85fe97bf196"}, - {file = "xxhash-3.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dbbd9892c5ebffeca1ed620cf0ade13eb55a0d8c84e0751a6653adc6ac40d0c"}, - {file = "xxhash-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4cc2d67fdb4d057730c75a64c5923abfa17775ae234a71b0200346bfb0a7f482"}, - {file = "xxhash-3.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ec28adb204b759306a3d64358a5e5c07d7b1dd0ccbce04aa76cb9377b7b70296"}, - {file = "xxhash-3.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1328f6d8cca2b86acb14104e381225a3d7b42c92c4b86ceae814e5c400dbb415"}, - {file = "xxhash-3.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8d47ebd9f5d9607fd039c1fbf4994e3b071ea23eff42f4ecef246ab2b7334198"}, - {file = "xxhash-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b96d559e0fcddd3343c510a0fe2b127fbff16bf346dd76280b82292567523442"}, - {file = "xxhash-3.5.0-cp310-cp310-win32.whl", hash = "sha256:61c722ed8d49ac9bc26c7071eeaa1f6ff24053d553146d5df031802deffd03da"}, - {file = "xxhash-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:9bed5144c6923cc902cd14bb8963f2d5e034def4486ab0bbe1f58f03f042f9a9"}, - {file = "xxhash-3.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:893074d651cf25c1cc14e3bea4fceefd67f2921b1bb8e40fcfeba56820de80c6"}, - {file = "xxhash-3.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02c2e816896dc6f85922ced60097bcf6f008dedfc5073dcba32f9c8dd786f3c1"}, - {file = "xxhash-3.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6027dcd885e21581e46d3c7f682cfb2b870942feeed58a21c29583512c3f09f8"}, - {file = "xxhash-3.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1308fa542bbdbf2fa85e9e66b1077eea3a88bef38ee8a06270b4298a7a62a166"}, - {file = "xxhash-3.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c28b2fdcee797e1c1961cd3bcd3d545cab22ad202c846235197935e1df2f8ef7"}, - {file = "xxhash-3.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:924361811732ddad75ff23e90efd9ccfda4f664132feecb90895bade6a1b4623"}, - {file = "xxhash-3.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89997aa1c4b6a5b1e5b588979d1da048a3c6f15e55c11d117a56b75c84531f5a"}, - {file = "xxhash-3.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:685c4f4e8c59837de103344eb1c8a3851f670309eb5c361f746805c5471b8c88"}, - {file = "xxhash-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dbd2ecfbfee70bc1a4acb7461fa6af7748ec2ab08ac0fa298f281c51518f982c"}, - {file = "xxhash-3.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:25b5a51dc3dfb20a10833c8eee25903fd2e14059e9afcd329c9da20609a307b2"}, - {file = "xxhash-3.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a8fb786fb754ef6ff8c120cb96629fb518f8eb5a61a16aac3a979a9dbd40a084"}, - {file = "xxhash-3.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a905ad00ad1e1c34fe4e9d7c1d949ab09c6fa90c919860c1534ff479f40fd12d"}, - {file = "xxhash-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:963be41bcd49f53af6d795f65c0da9b4cc518c0dd9c47145c98f61cb464f4839"}, - {file = "xxhash-3.5.0-cp311-cp311-win32.whl", hash = "sha256:109b436096d0a2dd039c355fa3414160ec4d843dfecc64a14077332a00aeb7da"}, - {file = "xxhash-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:b702f806693201ad6c0a05ddbbe4c8f359626d0b3305f766077d51388a6bac58"}, - {file = "xxhash-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:c4dcb4120d0cc3cc448624147dba64e9021b278c63e34a38789b688fd0da9bf3"}, - {file = "xxhash-3.5.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:14470ace8bd3b5d51318782cd94e6f94431974f16cb3b8dc15d52f3b69df8e00"}, - {file = "xxhash-3.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:59aa1203de1cb96dbeab595ded0ad0c0056bb2245ae11fac11c0ceea861382b9"}, - {file = "xxhash-3.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08424f6648526076e28fae6ea2806c0a7d504b9ef05ae61d196d571e5c879c84"}, - {file = "xxhash-3.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61a1ff00674879725b194695e17f23d3248998b843eb5e933007ca743310f793"}, - {file = "xxhash-3.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f2c61bee5844d41c3eb015ac652a0229e901074951ae48581d58bfb2ba01be"}, - {file = "xxhash-3.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d32a592cac88d18cc09a89172e1c32d7f2a6e516c3dfde1b9adb90ab5df54a6"}, - {file = "xxhash-3.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70dabf941dede727cca579e8c205e61121afc9b28516752fd65724be1355cc90"}, - {file = "xxhash-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e5d0ddaca65ecca9c10dcf01730165fd858533d0be84c75c327487c37a906a27"}, - {file = "xxhash-3.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e5b5e16c5a480fe5f59f56c30abdeba09ffd75da8d13f6b9b6fd224d0b4d0a2"}, - {file = "xxhash-3.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149b7914451eb154b3dfaa721315117ea1dac2cc55a01bfbd4df7c68c5dd683d"}, - {file = "xxhash-3.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:eade977f5c96c677035ff39c56ac74d851b1cca7d607ab3d8f23c6b859379cab"}, - {file = "xxhash-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fa9f547bd98f5553d03160967866a71056a60960be00356a15ecc44efb40ba8e"}, - {file = "xxhash-3.5.0-cp312-cp312-win32.whl", hash = "sha256:f7b58d1fd3551b8c80a971199543379be1cee3d0d409e1f6d8b01c1a2eebf1f8"}, - {file = "xxhash-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:fa0cafd3a2af231b4e113fba24a65d7922af91aeb23774a8b78228e6cd785e3e"}, - {file = "xxhash-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:586886c7e89cb9828bcd8a5686b12e161368e0064d040e225e72607b43858ba2"}, - {file = "xxhash-3.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:37889a0d13b0b7d739cfc128b1c902f04e32de17b33d74b637ad42f1c55101f6"}, - {file = "xxhash-3.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97a662338797c660178e682f3bc180277b9569a59abfb5925e8620fba00b9fc5"}, - {file = "xxhash-3.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f85e0108d51092bdda90672476c7d909c04ada6923c14ff9d913c4f7dc8a3bc"}, - {file = "xxhash-3.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2fd827b0ba763ac919440042302315c564fdb797294d86e8cdd4578e3bc7f3"}, - {file = "xxhash-3.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82085c2abec437abebf457c1d12fccb30cc8b3774a0814872511f0f0562c768c"}, - {file = "xxhash-3.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07fda5de378626e502b42b311b049848c2ef38784d0d67b6f30bb5008642f8eb"}, - {file = "xxhash-3.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c279f0d2b34ef15f922b77966640ade58b4ccdfef1c4d94b20f2a364617a493f"}, - {file = "xxhash-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:89e66ceed67b213dec5a773e2f7a9e8c58f64daeb38c7859d8815d2c89f39ad7"}, - {file = "xxhash-3.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bcd51708a633410737111e998ceb3b45d3dbc98c0931f743d9bb0a209033a326"}, - {file = "xxhash-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ff2c0a34eae7df88c868be53a8dd56fbdf592109e21d4bfa092a27b0bf4a7bf"}, - {file = "xxhash-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4e28503dccc7d32e0b9817aa0cbfc1f45f563b2c995b7a66c4c8a0d232e840c7"}, - {file = "xxhash-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a6c50017518329ed65a9e4829154626f008916d36295b6a3ba336e2458824c8c"}, - {file = "xxhash-3.5.0-cp313-cp313-win32.whl", hash = "sha256:53a068fe70301ec30d868ece566ac90d873e3bb059cf83c32e76012c889b8637"}, - {file = "xxhash-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:80babcc30e7a1a484eab952d76a4f4673ff601f54d5142c26826502740e70b43"}, - {file = "xxhash-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:4811336f1ce11cac89dcbd18f3a25c527c16311709a89313c3acaf771def2d4b"}, - {file = "xxhash-3.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6e5f70f6dca1d3b09bccb7daf4e087075ff776e3da9ac870f86ca316736bb4aa"}, - {file = "xxhash-3.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e76e83efc7b443052dd1e585a76201e40b3411fe3da7af4fe434ec51b2f163b"}, - {file = "xxhash-3.5.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:33eac61d0796ca0591f94548dcfe37bb193671e0c9bcf065789b5792f2eda644"}, - {file = "xxhash-3.5.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ec70a89be933ea49222fafc3999987d7899fc676f688dd12252509434636622"}, - {file = "xxhash-3.5.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86b8e7f703ec6ff4f351cfdb9f428955859537125904aa8c963604f2e9d3e7"}, - {file = "xxhash-3.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0adfbd36003d9f86c8c97110039f7539b379f28656a04097e7434d3eaf9aa131"}, - {file = "xxhash-3.5.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:63107013578c8a730419adc05608756c3fa640bdc6abe806c3123a49fb829f43"}, - {file = "xxhash-3.5.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:683b94dbd1ca67557850b86423318a2e323511648f9f3f7b1840408a02b9a48c"}, - {file = "xxhash-3.5.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5d2a01dcce81789cf4b12d478b5464632204f4c834dc2d064902ee27d2d1f0ee"}, - {file = "xxhash-3.5.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:a9d360a792cbcce2fe7b66b8d51274ec297c53cbc423401480e53b26161a290d"}, - {file = "xxhash-3.5.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:f0b48edbebea1b7421a9c687c304f7b44d0677c46498a046079d445454504737"}, - {file = "xxhash-3.5.0-cp37-cp37m-win32.whl", hash = "sha256:7ccb800c9418e438b44b060a32adeb8393764da7441eb52aa2aa195448935306"}, - {file = "xxhash-3.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c3bc7bf8cb8806f8d1c9bf149c18708cb1c406520097d6b0a73977460ea03602"}, - {file = "xxhash-3.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:74752ecaa544657d88b1d1c94ae68031e364a4d47005a90288f3bab3da3c970f"}, - {file = "xxhash-3.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dee1316133c9b463aa81aca676bc506d3f80d8f65aeb0bba2b78d0b30c51d7bd"}, - {file = "xxhash-3.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:602d339548d35a8579c6b013339fb34aee2df9b4e105f985443d2860e4d7ffaa"}, - {file = "xxhash-3.5.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:695735deeddfb35da1677dbc16a083445360e37ff46d8ac5c6fcd64917ff9ade"}, - {file = "xxhash-3.5.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1030a39ba01b0c519b1a82f80e8802630d16ab95dc3f2b2386a0b5c8ed5cbb10"}, - {file = "xxhash-3.5.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5bc08f33c4966f4eb6590d6ff3ceae76151ad744576b5fc6c4ba8edd459fdec"}, - {file = "xxhash-3.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:160e0c19ee500482ddfb5d5570a0415f565d8ae2b3fd69c5dcfce8a58107b1c3"}, - {file = "xxhash-3.5.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f1abffa122452481a61c3551ab3c89d72238e279e517705b8b03847b1d93d738"}, - {file = "xxhash-3.5.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:d5e9db7ef3ecbfc0b4733579cea45713a76852b002cf605420b12ef3ef1ec148"}, - {file = "xxhash-3.5.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:23241ff6423378a731d84864bf923a41649dc67b144debd1077f02e6249a0d54"}, - {file = "xxhash-3.5.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:82b833d5563fefd6fceafb1aed2f3f3ebe19f84760fdd289f8b926731c2e6e91"}, - {file = "xxhash-3.5.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0a80ad0ffd78bef9509eee27b4a29e56f5414b87fb01a888353e3d5bda7038bd"}, - {file = "xxhash-3.5.0-cp38-cp38-win32.whl", hash = "sha256:50ac2184ffb1b999e11e27c7e3e70cc1139047e7ebc1aa95ed12f4269abe98d4"}, - {file = "xxhash-3.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:392f52ebbb932db566973693de48f15ce787cabd15cf6334e855ed22ea0be5b3"}, - {file = "xxhash-3.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bfc8cdd7f33d57f0468b0614ae634cc38ab9202c6957a60e31d285a71ebe0301"}, - {file = "xxhash-3.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0c48b6300cd0b0106bf49169c3e0536408dfbeb1ccb53180068a18b03c662ab"}, - {file = "xxhash-3.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe1a92cfbaa0a1253e339ccec42dbe6db262615e52df591b68726ab10338003f"}, - {file = "xxhash-3.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:33513d6cc3ed3b559134fb307aae9bdd94d7e7c02907b37896a6c45ff9ce51bd"}, - {file = "xxhash-3.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eefc37f6138f522e771ac6db71a6d4838ec7933939676f3753eafd7d3f4c40bc"}, - {file = "xxhash-3.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a606c8070ada8aa2a88e181773fa1ef17ba65ce5dd168b9d08038e2a61b33754"}, - {file = "xxhash-3.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42eca420c8fa072cc1dd62597635d140e78e384a79bb4944f825fbef8bfeeef6"}, - {file = "xxhash-3.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:604253b2143e13218ff1ef0b59ce67f18b8bd1c4205d2ffda22b09b426386898"}, - {file = "xxhash-3.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6e93a5ad22f434d7876665444a97e713a8f60b5b1a3521e8df11b98309bff833"}, - {file = "xxhash-3.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7a46e1d6d2817ba8024de44c4fd79913a90e5f7265434cef97026215b7d30df6"}, - {file = "xxhash-3.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:30eb2efe6503c379b7ab99c81ba4a779748e3830241f032ab46bd182bf5873af"}, - {file = "xxhash-3.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c8aa771ff2c13dd9cda8166d685d7333d389fae30a4d2bb39d63ab5775de8606"}, - {file = "xxhash-3.5.0-cp39-cp39-win32.whl", hash = "sha256:5ed9ebc46f24cf91034544b26b131241b699edbfc99ec5e7f8f3d02d6eb7fba4"}, - {file = "xxhash-3.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:220f3f896c6b8d0316f63f16c077d52c412619e475f9372333474ee15133a558"}, - {file = "xxhash-3.5.0-cp39-cp39-win_arm64.whl", hash = "sha256:a7b1d8315d9b5e9f89eb2933b73afae6ec9597a258d52190944437158b49d38e"}, - {file = "xxhash-3.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2014c5b3ff15e64feecb6b713af12093f75b7926049e26a580e94dcad3c73d8c"}, - {file = "xxhash-3.5.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fab81ef75003eda96239a23eda4e4543cedc22e34c373edcaf744e721a163986"}, - {file = "xxhash-3.5.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e2febf914ace002132aa09169cc572e0d8959d0f305f93d5828c4836f9bc5a6"}, - {file = "xxhash-3.5.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d3a10609c51da2a1c0ea0293fc3968ca0a18bd73838455b5bca3069d7f8e32b"}, - {file = "xxhash-3.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5a74f23335b9689b66eb6dbe2a931a88fcd7a4c2cc4b1cb0edba8ce381c7a1da"}, - {file = "xxhash-3.5.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2b4154c00eb22e4d543f472cfca430e7962a0f1d0f3778334f2e08a7ba59363c"}, - {file = "xxhash-3.5.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d30bbc1644f726b825b3278764240f449d75f1a8bdda892e641d4a688b1494ae"}, - {file = "xxhash-3.5.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fa0b72f2423e2aa53077e54a61c28e181d23effeaafd73fcb9c494e60930c8e"}, - {file = "xxhash-3.5.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13de2b76c1835399b2e419a296d5b38dc4855385d9e96916299170085ef72f57"}, - {file = "xxhash-3.5.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:0691bfcc4f9c656bcb96cc5db94b4d75980b9d5589f2e59de790091028580837"}, - {file = "xxhash-3.5.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:297595fe6138d4da2c8ce9e72a04d73e58725bb60f3a19048bc96ab2ff31c692"}, - {file = "xxhash-3.5.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc1276d369452040cbb943300dc8abeedab14245ea44056a2943183822513a18"}, - {file = "xxhash-3.5.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2061188a1ba352fc699c82bff722f4baacb4b4b8b2f0c745d2001e56d0dfb514"}, - {file = "xxhash-3.5.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38c384c434021e4f62b8d9ba0bc9467e14d394893077e2c66d826243025e1f81"}, - {file = "xxhash-3.5.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e6a4dd644d72ab316b580a1c120b375890e4c52ec392d4aef3c63361ec4d77d1"}, - {file = "xxhash-3.5.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:531af8845aaadcadf951b7e0c1345c6b9c68a990eeb74ff9acd8501a0ad6a1c9"}, - {file = "xxhash-3.5.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ce379bcaa9fcc00f19affa7773084dd09f5b59947b3fb47a1ceb0179f91aaa1"}, - {file = "xxhash-3.5.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd1b2281d01723f076df3c8188f43f2472248a6b63118b036e641243656b1b0f"}, - {file = "xxhash-3.5.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c770750cc80e8694492244bca7251385188bc5597b6a39d98a9f30e8da984e0"}, - {file = "xxhash-3.5.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b150b8467852e1bd844387459aa6fbe11d7f38b56e901f9f3b3e6aba0d660240"}, - {file = "xxhash-3.5.0.tar.gz", hash = "sha256:84f2caddf951c9cbf8dc2e22a89d4ccf5d86391ac6418fe81e3c67d0cf60b45f"}, -] - -[[package]] -name = "yarl" -version = "1.11.1" -description = "Yet another URL library" -optional = false -python-versions = ">=3.8" -files = [ - {file = "yarl-1.11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:400cd42185f92de559d29eeb529e71d80dfbd2f45c36844914a4a34297ca6f00"}, - {file = "yarl-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8258c86f47e080a258993eed877d579c71da7bda26af86ce6c2d2d072c11320d"}, - {file = "yarl-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2164cd9725092761fed26f299e3f276bb4b537ca58e6ff6b252eae9631b5c96e"}, - {file = "yarl-1.11.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08ea567c16f140af8ddc7cb58e27e9138a1386e3e6e53982abaa6f2377b38cc"}, - {file = "yarl-1.11.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:768ecc550096b028754ea28bf90fde071c379c62c43afa574edc6f33ee5daaec"}, - {file = "yarl-1.11.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2909fa3a7d249ef64eeb2faa04b7957e34fefb6ec9966506312349ed8a7e77bf"}, - {file = "yarl-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01a8697ec24f17c349c4f655763c4db70eebc56a5f82995e5e26e837c6eb0e49"}, - {file = "yarl-1.11.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e286580b6511aac7c3268a78cdb861ec739d3e5a2a53b4809faef6b49778eaff"}, - {file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4179522dc0305c3fc9782549175c8e8849252fefeb077c92a73889ccbcd508ad"}, - {file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:27fcb271a41b746bd0e2a92182df507e1c204759f460ff784ca614e12dd85145"}, - {file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f61db3b7e870914dbd9434b560075e0366771eecbe6d2b5561f5bc7485f39efd"}, - {file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c92261eb2ad367629dc437536463dc934030c9e7caca861cc51990fe6c565f26"}, - {file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d95b52fbef190ca87d8c42f49e314eace4fc52070f3dfa5f87a6594b0c1c6e46"}, - {file = "yarl-1.11.1-cp310-cp310-win32.whl", hash = "sha256:489fa8bde4f1244ad6c5f6d11bb33e09cf0d1d0367edb197619c3e3fc06f3d91"}, - {file = "yarl-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:476e20c433b356e16e9a141449f25161e6b69984fb4cdbd7cd4bd54c17844998"}, - {file = "yarl-1.11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:946eedc12895873891aaceb39bceb484b4977f70373e0122da483f6c38faaa68"}, - {file = "yarl-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:21a7c12321436b066c11ec19c7e3cb9aec18884fe0d5b25d03d756a9e654edfe"}, - {file = "yarl-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c35f493b867912f6fda721a59cc7c4766d382040bdf1ddaeeaa7fa4d072f4675"}, - {file = "yarl-1.11.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25861303e0be76b60fddc1250ec5986c42f0a5c0c50ff57cc30b1be199c00e63"}, - {file = "yarl-1.11.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4b53f73077e839b3f89c992223f15b1d2ab314bdbdf502afdc7bb18e95eae27"}, - {file = "yarl-1.11.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:327c724b01b8641a1bf1ab3b232fb638706e50f76c0b5bf16051ab65c868fac5"}, - {file = "yarl-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4307d9a3417eea87715c9736d050c83e8c1904e9b7aada6ce61b46361b733d92"}, - {file = "yarl-1.11.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a28bed68ab8fb7e380775f0029a079f08a17799cb3387a65d14ace16c12e2b"}, - {file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:067b961853c8e62725ff2893226fef3d0da060656a9827f3f520fb1d19b2b68a"}, - {file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8215f6f21394d1f46e222abeb06316e77ef328d628f593502d8fc2a9117bde83"}, - {file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:498442e3af2a860a663baa14fbf23fb04b0dd758039c0e7c8f91cb9279799bff"}, - {file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:69721b8effdb588cb055cc22f7c5105ca6fdaa5aeb3ea09021d517882c4a904c"}, - {file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e969fa4c1e0b1a391f3fcbcb9ec31e84440253325b534519be0d28f4b6b533e"}, - {file = "yarl-1.11.1-cp311-cp311-win32.whl", hash = "sha256:7d51324a04fc4b0e097ff8a153e9276c2593106a811704025bbc1d6916f45ca6"}, - {file = "yarl-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:15061ce6584ece023457fb8b7a7a69ec40bf7114d781a8c4f5dcd68e28b5c53b"}, - {file = "yarl-1.11.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a4264515f9117be204935cd230fb2a052dd3792789cc94c101c535d349b3dab0"}, - {file = "yarl-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f41fa79114a1d2eddb5eea7b912d6160508f57440bd302ce96eaa384914cd265"}, - {file = "yarl-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:02da8759b47d964f9173c8675710720b468aa1c1693be0c9c64abb9d8d9a4867"}, - {file = "yarl-1.11.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9361628f28f48dcf8b2f528420d4d68102f593f9c2e592bfc842f5fb337e44fd"}, - {file = "yarl-1.11.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b91044952da03b6f95fdba398d7993dd983b64d3c31c358a4c89e3c19b6f7aef"}, - {file = "yarl-1.11.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74db2ef03b442276d25951749a803ddb6e270d02dda1d1c556f6ae595a0d76a8"}, - {file = "yarl-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e975a2211952a8a083d1b9d9ba26472981ae338e720b419eb50535de3c02870"}, - {file = "yarl-1.11.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aef97ba1dd2138112890ef848e17d8526fe80b21f743b4ee65947ea184f07a2"}, - {file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7915ea49b0c113641dc4d9338efa9bd66b6a9a485ffe75b9907e8573ca94b84"}, - {file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:504cf0d4c5e4579a51261d6091267f9fd997ef58558c4ffa7a3e1460bd2336fa"}, - {file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3de5292f9f0ee285e6bd168b2a77b2a00d74cbcfa420ed078456d3023d2f6dff"}, - {file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a34e1e30f1774fa35d37202bbeae62423e9a79d78d0874e5556a593479fdf239"}, - {file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:66b63c504d2ca43bf7221a1f72fbe981ff56ecb39004c70a94485d13e37ebf45"}, - {file = "yarl-1.11.1-cp312-cp312-win32.whl", hash = "sha256:a28b70c9e2213de425d9cba5ab2e7f7a1c8ca23a99c4b5159bf77b9c31251447"}, - {file = "yarl-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:17b5a386d0d36fb828e2fb3ef08c8829c1ebf977eef88e5367d1c8c94b454639"}, - {file = "yarl-1.11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1fa2e7a406fbd45b61b4433e3aa254a2c3e14c4b3186f6e952d08a730807fa0c"}, - {file = "yarl-1.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:750f656832d7d3cb0c76be137ee79405cc17e792f31e0a01eee390e383b2936e"}, - {file = "yarl-1.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b8486f322d8f6a38539136a22c55f94d269addb24db5cb6f61adc61eabc9d93"}, - {file = "yarl-1.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fce4da3703ee6048ad4138fe74619c50874afe98b1ad87b2698ef95bf92c96d"}, - {file = "yarl-1.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed653638ef669e0efc6fe2acb792275cb419bf9cb5c5049399f3556995f23c7"}, - {file = "yarl-1.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18ac56c9dd70941ecad42b5a906820824ca72ff84ad6fa18db33c2537ae2e089"}, - {file = "yarl-1.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:688654f8507464745ab563b041d1fb7dab5d9912ca6b06e61d1c4708366832f5"}, - {file = "yarl-1.11.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4973eac1e2ff63cf187073cd4e1f1148dcd119314ab79b88e1b3fad74a18c9d5"}, - {file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:964a428132227edff96d6f3cf261573cb0f1a60c9a764ce28cda9525f18f7786"}, - {file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6d23754b9939cbab02c63434776df1170e43b09c6a517585c7ce2b3d449b7318"}, - {file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c2dc4250fe94d8cd864d66018f8344d4af50e3758e9d725e94fecfa27588ff82"}, - {file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09696438cb43ea6f9492ef237761b043f9179f455f405279e609f2bc9100212a"}, - {file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:999bfee0a5b7385a0af5ffb606393509cfde70ecca4f01c36985be6d33e336da"}, - {file = "yarl-1.11.1-cp313-cp313-win32.whl", hash = "sha256:ce928c9c6409c79e10f39604a7e214b3cb69552952fbda8d836c052832e6a979"}, - {file = "yarl-1.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:501c503eed2bb306638ccb60c174f856cc3246c861829ff40eaa80e2f0330367"}, - {file = "yarl-1.11.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dae7bd0daeb33aa3e79e72877d3d51052e8b19c9025ecf0374f542ea8ec120e4"}, - {file = "yarl-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3ff6b1617aa39279fe18a76c8d165469c48b159931d9b48239065767ee455b2b"}, - {file = "yarl-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3257978c870728a52dcce8c2902bf01f6c53b65094b457bf87b2644ee6238ddc"}, - {file = "yarl-1.11.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f351fa31234699d6084ff98283cb1e852270fe9e250a3b3bf7804eb493bd937"}, - {file = "yarl-1.11.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8aef1b64da41d18026632d99a06b3fefe1d08e85dd81d849fa7c96301ed22f1b"}, - {file = "yarl-1.11.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7175a87ab8f7fbde37160a15e58e138ba3b2b0e05492d7351314a250d61b1591"}, - {file = "yarl-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba444bdd4caa2a94456ef67a2f383710928820dd0117aae6650a4d17029fa25e"}, - {file = "yarl-1.11.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ea9682124fc062e3d931c6911934a678cb28453f957ddccf51f568c2f2b5e05"}, - {file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8418c053aeb236b20b0ab8fa6bacfc2feaaf7d4683dd96528610989c99723d5f"}, - {file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:61a5f2c14d0a1adfdd82258f756b23a550c13ba4c86c84106be4c111a3a4e413"}, - {file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f3a6d90cab0bdf07df8f176eae3a07127daafcf7457b997b2bf46776da2c7eb7"}, - {file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:077da604852be488c9a05a524068cdae1e972b7dc02438161c32420fb4ec5e14"}, - {file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:15439f3c5c72686b6c3ff235279630d08936ace67d0fe5c8d5bbc3ef06f5a420"}, - {file = "yarl-1.11.1-cp38-cp38-win32.whl", hash = "sha256:238a21849dd7554cb4d25a14ffbfa0ef380bb7ba201f45b144a14454a72ffa5a"}, - {file = "yarl-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:67459cf8cf31da0e2cbdb4b040507e535d25cfbb1604ca76396a3a66b8ba37a6"}, - {file = "yarl-1.11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:884eab2ce97cbaf89f264372eae58388862c33c4f551c15680dd80f53c89a269"}, - {file = "yarl-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a336eaa7ee7e87cdece3cedb395c9657d227bfceb6781295cf56abcd3386a26"}, - {file = "yarl-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87f020d010ba80a247c4abc335fc13421037800ca20b42af5ae40e5fd75e7909"}, - {file = "yarl-1.11.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:637c7ddb585a62d4469f843dac221f23eec3cbad31693b23abbc2c366ad41ff4"}, - {file = "yarl-1.11.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48dfd117ab93f0129084577a07287376cc69c08138694396f305636e229caa1a"}, - {file = "yarl-1.11.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e0ae31fb5ccab6eda09ba1494e87eb226dcbd2372dae96b87800e1dcc98804"}, - {file = "yarl-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f46f81501160c28d0c0b7333b4f7be8983dbbc161983b6fb814024d1b4952f79"}, - {file = "yarl-1.11.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:04293941646647b3bfb1719d1d11ff1028e9c30199509a844da3c0f5919dc520"}, - {file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:250e888fa62d73e721f3041e3a9abf427788a1934b426b45e1b92f62c1f68366"}, - {file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e8f63904df26d1a66aabc141bfd258bf738b9bc7bc6bdef22713b4f5ef789a4c"}, - {file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:aac44097d838dda26526cffb63bdd8737a2dbdf5f2c68efb72ad83aec6673c7e"}, - {file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:267b24f891e74eccbdff42241c5fb4f974de2d6271dcc7d7e0c9ae1079a560d9"}, - {file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6907daa4b9d7a688063ed098c472f96e8181733c525e03e866fb5db480a424df"}, - {file = "yarl-1.11.1-cp39-cp39-win32.whl", hash = "sha256:14438dfc5015661f75f85bc5adad0743678eefee266ff0c9a8e32969d5d69f74"}, - {file = "yarl-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:94d0caaa912bfcdc702a4204cd5e2bb01eb917fc4f5ea2315aa23962549561b0"}, - {file = "yarl-1.11.1-py3-none-any.whl", hash = "sha256:72bf26f66456baa0584eff63e44545c9f0eaed9b73cb6601b647c91f14c11f38"}, - {file = "yarl-1.11.1.tar.gz", hash = "sha256:1bb2d9e212fb7449b8fb73bc461b51eaa17cc8430b4a87d87be7b25052d92f53"}, -] - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" - -[[package]] -name = "zipp" -version = "3.20.2" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, - {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.10, <3.13" -content-hash = "dad744a680f96bf5ffb495120e851c688a237d512eb2751a75d15a5e794124b0" diff --git a/tests/python/test_restrictedpython.py b/tests/python/test_restrictedpython.py deleted file mode 100644 index 59d3213d..00000000 --- a/tests/python/test_restrictedpython.py +++ /dev/null @@ -1,16 +0,0 @@ -from RestrictedPython import compile_restricted, safe_globals - - -def test_restrictedpython_import(): - code = """ -import sympy -import inspect -source = inspect.getsource(sympy) -""" - compiled = compile_restricted(code, filename="", mode='exec') - e = None - try: - exec(compiled, safe_globals) - except ImportError as err: - e = err - assert e is not None From 512b35f7d9e0593620b7eadd49319016ff0c2dec Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 7 Oct 2024 15:48:24 +0800 Subject: [PATCH 009/148] dev: container add global static container --- ghostos/container.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ghostos/container.py b/ghostos/container.py index 5efb39aa..39f06122 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -9,6 +9,8 @@ "ABSTRACT", "ProviderAdapter", 'provide', 'get_caller_info', + 'get_container', + 'set_container', ] INSTRUCTION = """ @@ -404,3 +406,22 @@ def wrapper(factory: Factory) -> Provider: return ProviderAdapter(abstract, factory, singleton, lineinfo=lineinfo) return wrapper + + +__container = Container() + + +def get_container() -> Container: + """ + get global static container + """ + return __container + + +def set_container(container: Container) -> None: + """ + change global static container + may cause unexpected behavior. + """ + global __container + __container = container From c5666391a6d5c704d0bc0e336281e8dd7abaea66 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 7 Oct 2024 15:49:07 +0800 Subject: [PATCH 010/148] feat: fix poetry dependencies and add streamlit-react-jsonschema --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 647f531b..8fe76e8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,13 @@ license = "MIT" readme = "README.md" [tool.poetry.dependencies] -python = "^3.10, <3.13" +python = "^3.12" pydantic = "^2.7.0" pytest = "^8.1.1" openai = "^1.19.0" pyyaml = "^6.0.1" rich = "^13.7.1" httpx-socks = "^0.9.1" -restrictedpython = "^7.1" datasets = "^2.20.0" anthropic = "^0.31.2" sympy = "^1.13.1" @@ -29,8 +28,9 @@ arxiv = "^2.1.3" llama-index-core = "^0.11.9" llama-index-llms-openai = "^0.2.7" streamlit = "^1.39.0" -streamlit-pydantic = "^0.6.0" pydantic-settings = "^2.5.2" +streamlit-antd-components = "^0.3.2" +streamlit-react-jsonschema = "^0.1.3" [tool.poetry.scripts] init = "ghostos.scripts.init:main" From cabfd3b67c794a4b812cab89fb5f0403bff860c1 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 7 Oct 2024 17:38:16 +0800 Subject: [PATCH 011/148] feat: modification about container 1. provider has default implementation about contract, get generic type that pass to the provider 2. define two generic type INSTANCE and ABSTRACT, make it less confusing. 3. fix the change's impact to other classes, and add a lot of comments. --- ghostos/container.py | 79 +++++++++++++++--------- ghostos/contracts/configs.py | 45 +++++++++++--- ghostos/contracts/modules.py | 8 +-- ghostos/contracts/pool.py | 10 +-- ghostos/core/aifunc/manager.py | 10 +-- ghostos/core/ghosts/ghost.py | 2 +- ghostos/core/ghosts/shells.py | 64 +++++++------------ ghostos/core/ghosts/workspace.py | 6 -- ghostos/core/messages/openai.py | 10 +-- ghostos/framework/cache/mock_cache.py | 2 +- ghostos/framework/configs/storageimpl.py | 21 +++---- ghostos/framework/ghostos/basic.py | 3 +- ghostos/framework/shells/basic.py | 12 ++-- ghostos/framework/storage/filestorage.py | 11 ++-- ghostos/framework/workspaces/basic.py | 33 +++++----- tests/test_container.py | 29 ++++++++- 16 files changed, 194 insertions(+), 151 deletions(-) diff --git a/ghostos/container.py b/ghostos/container.py index 39f06122..6d56a83b 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -2,11 +2,12 @@ import inspect from abc import ABCMeta, abstractmethod from typing import Type, Dict, TypeVar, Callable, Set, Optional, List, Generic, Any, Union, Iterable +from typing import get_args, get_origin __all__ = [ "Container", "IoCContainer", "Provider", "Factory", "Bootstrapper", - "ABSTRACT", + "INSTANCE", "ABSTRACT", "ProviderAdapter", 'provide', 'get_caller_info', 'get_container', @@ -17,7 +18,11 @@ 打算实现一个 IoC 容器用来管理大量可替换的中间库. """ -ABSTRACT = TypeVar('ABSTRACT', bound=object) +INSTANCE = TypeVar('INSTANCE', bound=object) +"""instance in the container""" + +ABSTRACT = Type[INSTANCE] +"""abstract of the instance""" class IoCContainer(metaclass=ABCMeta): @@ -26,7 +31,7 @@ class IoCContainer(metaclass=ABCMeta): """ @abstractmethod - def set(self, abstract: Type[ABSTRACT], instance: ABSTRACT) -> None: + def set(self, abstract: ABSTRACT, instance: INSTANCE) -> None: """ 设置一个实例, 不会污染父容器. """ @@ -56,7 +61,7 @@ def bootstrap(self) -> None: pass @abstractmethod - def get(self, abstract: Union[Type[ABSTRACT], Factory, Provider]) -> Optional[ABSTRACT]: + def get(self, abstract: Union[ABSTRACT, Factory, Provider]) -> Optional[INSTANCE]: """ get bound instance or initialize one from registered abstract, or generate one by factory or provider. :return: None if no bound instance. @@ -64,7 +69,7 @@ def get(self, abstract: Union[Type[ABSTRACT], Factory, Provider]) -> Optional[AB pass @abstractmethod - def fetch(self, abstract: Type[ABSTRACT], strict: bool = False) -> Optional[ABSTRACT]: + def fetch(self, abstract: ABSTRACT, strict: bool = False) -> Optional[INSTANCE]: """ :param abstract: use type of the object (usually an abstract class) to fetch the implementation. :param strict: autotype check @@ -73,7 +78,7 @@ def fetch(self, abstract: Type[ABSTRACT], strict: bool = False) -> Optional[ABST pass @abstractmethod - def force_fetch(self, contract: Type[ABSTRACT], strict: bool = False) -> ABSTRACT: + def force_fetch(self, contract: ABSTRACT, strict: bool = False) -> INSTANCE: """ if fetch contract failed, raise error. :exception: NotImplementedError if contract is not registered. @@ -82,14 +87,14 @@ def force_fetch(self, contract: Type[ABSTRACT], strict: bool = False) -> ABSTRAC pass @abstractmethod - def bound(self, contract: Type[ABSTRACT]) -> bool: + def bound(self, contract: ABSTRACT) -> bool: """ return whether contract is bound. """ pass @abstractmethod - def contracts(self, recursively: bool = True) -> Iterable[Type[ABSTRACT]]: + def contracts(self, recursively: bool = True) -> Iterable[ABSTRACT]: """ yield from bound contracts """ @@ -145,13 +150,13 @@ def bootstrap(self) -> None: for b in self._bootstrapper: b.bootstrap(self) - def set(self, abstract: Type[ABSTRACT], instance: ABSTRACT) -> None: + def set(self, abstract: ABSTRACT, instance: INSTANCE) -> None: """ 设置一个实例, 不会污染父容器. """ self._set_instance(abstract, instance) - def _bind_contract(self, abstract: Type[ABSTRACT]) -> None: + def _bind_contract(self, abstract: ABSTRACT) -> None: """ 添加好绑定关系, 方便快速查找. """ @@ -163,7 +168,7 @@ def bound(self, contract: Type) -> bool: """ return contract in self._bound or (self.parent is not None and self.parent.bound(contract)) - def get(self, abstract: Type[ABSTRACT]) -> Optional[ABSTRACT]: + def get(self, abstract: ABSTRACT) -> Optional[INSTANCE]: """ get bound instance or initialize one from registered factory or provider. @@ -202,8 +207,8 @@ def get(self, abstract: Type[ABSTRACT]) -> Optional[ABSTRACT]: def register_maker( self, - contract: Type[ABSTRACT], - maker: Callable[[], ABSTRACT], + contract: ABSTRACT, + maker: Callable[[], INSTANCE], singleton: bool = False, ): lineinfo = get_caller_info(2) @@ -231,7 +236,7 @@ def register(self, provider: Provider) -> None: if b not in self._bound: self._aliases[b] = contract - def _register_provider(self, contract: Type[ABSTRACT], provider: Provider) -> None: + def _register_provider(self, contract: ABSTRACT, provider: Provider) -> None: # remove singleton instance that already bound if contract in self._instances: del self._instances[contract] @@ -245,7 +250,7 @@ def add_bootstrapper(self, bootstrapper: Bootstrapper) -> None: """ self._bootstrapper.append(bootstrapper) - def fetch(self, abstract: Type[ABSTRACT], strict: bool = False) -> Optional[ABSTRACT]: + def fetch(self, abstract: ABSTRACT, strict: bool = False) -> Optional[INSTANCE]: """ get contract with type check :exception: TypeError if instance do not implement abstract @@ -257,7 +262,7 @@ def fetch(self, abstract: Type[ABSTRACT], strict: bool = False) -> Optional[ABST return instance return None - def force_fetch(self, contract: Type[ABSTRACT], strict: bool = False) -> ABSTRACT: + def force_fetch(self, contract: ABSTRACT, strict: bool = False) -> INSTANCE: """ if fetch contract failed, raise error. :exception: NotImplementedError if contract is not registered. @@ -275,7 +280,7 @@ def _set_instance(self, abstract: Any, instance: Any) -> None: self._bind_contract(abstract) self._instances[abstract] = instance - def contracts(self, recursively: bool = True) -> Iterable[Type[ABSTRACT]]: + def contracts(self, recursively: bool = True) -> Iterable[ABSTRACT]: done = set() for contract in self._bound: done.add(contract) @@ -302,7 +307,7 @@ def destroy(self) -> None: Factory = Callable[[Container], Any] -class Provider(Generic[ABSTRACT], metaclass=ABCMeta): +class Provider(Generic[INSTANCE], metaclass=ABCMeta): @abstractmethod def singleton(self) -> bool: @@ -311,26 +316,40 @@ def singleton(self) -> bool: """ pass - @abstractmethod - def contract(self) -> Type[ABSTRACT]: + def contract(self) -> ABSTRACT: """ - contract for this provider. + :return: contract for this provider. + override this method to define a contract without generic type """ - pass + return self.get_instance_type() - def aliases(self) -> Iterable[Type[ABSTRACT]]: + def aliases(self) -> Iterable[ABSTRACT]: """ additional contracts that shall bind to this provider if the binding contract is not Bound. """ return [] @abstractmethod - def factory(self, con: Container) -> Optional[ABSTRACT]: + def factory(self, con: Container) -> Optional[INSTANCE]: """ factory method to generate an instance of the contract. """ pass + def get_instance_type(self) -> ABSTRACT: + """ + get generic INSTANCE type from the instance of the provider. + """ + cls = self.__class__ + for parent in cls.__orig_bases__: + if get_origin(parent) is not Provider: + continue + args = get_args(parent) + if not args: + break + return args[0] + raise AttributeError("can not get instance type") + class Bootstrapper(metaclass=ABCMeta): """ @@ -342,7 +361,7 @@ def bootstrap(self, container: Container) -> None: pass -class BootstrappingProvider(Generic[ABSTRACT], Provider[ABSTRACT], Bootstrapper, metaclass=ABCMeta): +class BootstrappingProvider(Generic[INSTANCE], Provider[INSTANCE], Bootstrapper, metaclass=ABCMeta): """ 将 bootstrapper 和 Provider 可以融合在一起. """ @@ -356,8 +375,8 @@ class ProviderAdapter(Provider): def __init__( self, - contract_type: Type[ABSTRACT], - factory: Callable[[Container], Optional[ABSTRACT]], + contract_type: ABSTRACT, + factory: Callable[[Container], Optional[INSTANCE]], singleton: bool = True, lineinfo: str = "", ): @@ -369,10 +388,10 @@ def __init__( def singleton(self) -> bool: return self._singleton - def contract(self) -> Type[ABSTRACT]: + def contract(self) -> Type[INSTANCE]: return self._contract_type - def factory(self, con: Container) -> Optional[ABSTRACT]: + def factory(self, con: Container) -> Optional[INSTANCE]: return self._factory(con) def __repr__(self): @@ -391,7 +410,7 @@ def get_caller_info(backtrace: int = 1) -> str: def provide( - abstract: Type[ABSTRACT], + abstract: ABSTRACT, singleton: bool = True, lineinfo: str = "", ) -> Callable[[Factory], Provider]: diff --git a/ghostos/contracts/configs.py b/ghostos/contracts/configs.py index 5857ebb4..aab3cae8 100644 --- a/ghostos/contracts/configs.py +++ b/ghostos/contracts/configs.py @@ -1,25 +1,33 @@ -import os import yaml from abc import ABC, abstractmethod -from typing import ClassVar, TypeVar, Type, Dict, Optional +from typing import ClassVar, TypeVar, Type, Optional, AnyStr from pydantic import BaseModel -from ghostos.container import Container, Provider, ABSTRACT -from ghostos.contracts.storage import Storage __all__ = ['Config', 'Configs', 'YamlConfig', 'C'] class Config(ABC): + """ + Configuration class for something. + Once it implements the Config interface, + It could be managed by Configs. + """ @classmethod @abstractmethod def conf_path(cls) -> str: + """ + relative path to the Configs. + notice the Configs is not always based on FileSystem, so the path is also abstract identity of the conf. + """ pass @classmethod @abstractmethod - def load(cls, content: str) -> "Config": + def load(cls, content: AnyStr) -> "Config": """ + unmarshal the Config instance from content. + todo: rename it to unmarshal, and add marshal method. """ pass @@ -28,15 +36,37 @@ def load(cls, content: str) -> "Config": class Configs(ABC): + """ + Repository for variable kinds of Config. + Treat all the configs as a data object + todo: we still need save method. + """ @abstractmethod - def get(self, conf_type: Type[C], file_name: Optional[str] = None) -> C: + def get(self, conf_type: Type[C], relative_path: Optional[str] = None) -> C: + """ + get a Config instance or throw exception + :param conf_type: the Config class that shall unmarshal the config data + :param relative_path: the relative path of the config data, if pass, override the conf_type default path. + :return: instance of the Config. + :exception: FileNotFoundError + """ pass +TIP = """ +With object class Config and repository class Configs, +we decoupled the Model and IO of a Config system. + +When defining a Config Model, we never think about where to put it (file system or cloud object storage). +""" + + class YamlConfig(Config, BaseModel): """ - 通过 yaml 定义的配置. + Use Pydantic BaseModel to define a Config class. + And marshal the data to yaml. + todo: rename to YamlConfigModel """ relative_path: ClassVar[str] @@ -50,3 +80,4 @@ def load(cls, content: str) -> "Config": value = yaml.safe_load(content) return cls(**value) +# todo: toml config diff --git a/ghostos/contracts/modules.py b/ghostos/contracts/modules.py index ef6c29f1..5936d131 100644 --- a/ghostos/contracts/modules.py +++ b/ghostos/contracts/modules.py @@ -3,7 +3,7 @@ from importlib import import_module from typing import Optional, Type -from ghostos.container import Provider, Container, ABSTRACT +from ghostos.container import Provider, Container __all__ = [ 'Modules', 'ImportWrapper', 'DefaultModules', 'DefaultModulesProvider', @@ -52,12 +52,12 @@ def import_module(self, modulename) -> ModuleType: return import_module(modulename) -class DefaultModulesProvider(Provider): +class DefaultModulesProvider(Provider[Modules]): def singleton(self) -> bool: return True - def contract(self) -> Type[ABSTRACT]: + def contract(self) -> Type: return Modules - def factory(self, con: Container) -> Optional[ABSTRACT]: + def factory(self, con: Container) -> Optional[Modules]: return DefaultModules() diff --git a/ghostos/contracts/pool.py b/ghostos/contracts/pool.py index cf6cb133..69a3d687 100644 --- a/ghostos/contracts/pool.py +++ b/ghostos/contracts/pool.py @@ -1,12 +1,12 @@ from typing import Callable, Optional, Type from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor -from ghostos.container import Provider, Container, ABSTRACT +from ghostos.container import Provider, Container class Pool(ABC): """ - 建一个全局的池. + abstract class for pools like process pool or thread pool """ @abstractmethod @@ -30,7 +30,7 @@ def shutdown(self, wait=True, *, cancel_futures=False): self.pool.shutdown(wait=wait, cancel_futures=cancel_futures) -class DefaultPoolProvider(Provider): +class DefaultPoolProvider(Provider[Pool]): def __init__(self, size: int = 100): self.size = size @@ -38,8 +38,8 @@ def __init__(self, size: int = 100): def singleton(self) -> bool: return True - def contract(self) -> Type[ABSTRACT]: + def contract(self) -> Type: return Pool - def factory(self, con: Container) -> Optional[ABSTRACT]: + def factory(self, con: Container) -> Optional[Pool]: return DefaultPool(self.size) diff --git a/ghostos/core/aifunc/manager.py b/ghostos/core/aifunc/manager.py index 3bbd6224..f93034b9 100644 --- a/ghostos/core/aifunc/manager.py +++ b/ghostos/core/aifunc/manager.py @@ -1,9 +1,8 @@ -import threading from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Dict, Any, Optional, List, Type -from ghostos.container import Container, Provider, ABSTRACT +from ghostos.container import Container, Provider, INSTANCE from ghostos.core.llms import LLMApi, LLMs from ghostos.core.moss import MossCompiler from ghostos.core.aifunc.func import AIFunc, AIFuncResult, get_aifunc_result_type @@ -164,7 +163,7 @@ def destroy(self) -> None: del self._values -class DefaultAIFuncManagerProvider(Provider): +class DefaultAIFuncManagerProvider(Provider[AIFuncManager]): def __init__(self, llm_api_name: str = ""): self._llm_api_name = llm_api_name @@ -172,10 +171,7 @@ def __init__(self, llm_api_name: str = ""): def singleton(self) -> bool: return False - def contract(self) -> Type[ABSTRACT]: - return AIFuncManager - - def factory(self, con: Container) -> Optional[ABSTRACT]: + def factory(self, con: Container) -> Optional[AIFuncManager]: return DefaultAIFuncManagerImpl( container=con, llm_api_name=self._llm_api_name, diff --git a/ghostos/core/ghosts/ghost.py b/ghostos/core/ghosts/ghost.py index 119210e6..2a87fad3 100644 --- a/ghostos/core/ghosts/ghost.py +++ b/ghostos/core/ghosts/ghost.py @@ -137,7 +137,7 @@ def system_prompt(self) -> str: meta_prompt = task.assistant.meta_prompt return meta_prompt meta_prompt = self.meta_prompt() - shell_prompt = self.shell().shell_prompt() + shell_prompt = self.shell().status_description() content = "\n\n".join([meta_prompt, shell_prompt]) return content.strip() diff --git a/ghostos/core/ghosts/shells.py b/ghostos/core/ghosts/shells.py index de6a4368..35e8edb1 100644 --- a/ghostos/core/ghosts/shells.py +++ b/ghostos/core/ghosts/shells.py @@ -1,48 +1,30 @@ from abc import ABC, abstractmethod -from typing import Type, Iterable -from ghostos.container import ABSTRACT -from ghostos.core.llms import Chat, ChatPreparer +from typing import Iterable +from ghostos.container import INSTANCE, ABSTRACT from ghostos.core.ghosts.actions import Action -from ghostos.abc import Identifiable __all__ = ['Shell'] -# class Env(Identifiable, ABC): -# """ -# 对环境抽象的感知. -# """ -# -# @abstractmethod -# def update_chat(self, chat: Chat) -> Chat: -# pass -# -# @abstractmethod -# def driver(self) -> Type[ABSTRACT]: -# pass -# -# @abstractmethod -# def provide(self) -> ABSTRACT: -# pass - - class Shell(ABC): """ - Shell 是对端侧能力的抽象. - 这些能力不是在 Ghost 里预定义的, 而是端侧可能动态变更的. - Shell 通过 Process 里存储的 Meta 数据实例化而来. - 当 Meta 数据变更时, Shell 的信息也应该同时变更. + Shell is the cybernetic body of the Ghost, and this interface is an abstract for the shell. + The instance of the Shell may be changed during runtime. + The Ghost shall feel and understand the situation of the shell, and use it. """ @abstractmethod def id(self) -> str: + """ + :return: identity of the shell. + """ pass @abstractmethod - def shell_prompt(self) -> str: + def status_description(self) -> str: """ - 将端侧的信息注入到 Chat 中. - 这些讯息应该包含对自身和环境的感知信息. + the status description of the shell, for llm ghost. + combine this to the LLM instruction, shall prompt the LLM interact with the shell. """ pass @@ -50,27 +32,29 @@ def shell_prompt(self) -> str: def actions(self) -> Iterable[Action]: """ actions from the shell + Ghost(LLM) can interact with the shell by these actions. + Through function call or functional token protocol. """ pass @abstractmethod - def drivers(self) -> Iterable[Type[ABSTRACT]]: + def drivers(self) -> Iterable[ABSTRACT]: """ - 当前 Shell 可以供 Moss 调用的抽象. - 在 Shell 实例化时, 这些抽象就应该通过 Shell Provider 注册到 Container 中. - 方便对 Moss 进行依赖注入. + The drivers that this shell provided to the Ghost. + Driver is usually a class interface, not an implementation. + Ghost can operate the shell by generate codes in the MOSS to call these drivers. + And the Ghost's ai models do not need to know the details of the implementation. + + The GhostOS will bind the drivers and it's implementations to the Ghost IoCContainer. - 经常要搭配 Moss 功能设计使用. 举个例子: - 1. 某个 moss 文件依赖 class MusicPlayer(ABC) - 2. Shell 包含了 MusicPlayer 的实现, thought 调用 moss 时实际从 Shell 里获取了实例. - 3. Shell 如果不包含这个实现, 则 thought 应该得到错误信息的提示, 得知这个抽象不存在. + For example, a Thought can play music by calling a driver named MusicPlayer, + no matter the shell is a Robot, a Car, or a IM chatbot. """ pass @abstractmethod - def get_driver(self, driver: Type[ABSTRACT]) -> ABSTRACT: + def get_driver(self, driver: ABSTRACT) -> INSTANCE: """ - 获取某个抽象的实例. + get driver's INSTANCE that already bound to the Shell. """ pass - diff --git a/ghostos/core/ghosts/workspace.py b/ghostos/core/ghosts/workspace.py index e3abf85a..69d8b591 100644 --- a/ghostos/core/ghosts/workspace.py +++ b/ghostos/core/ghosts/workspace.py @@ -28,9 +28,3 @@ def configs(self) -> FileStorage: """ pass - @abstractmethod - def source(self) -> FileStorage: - """ - source code path - """ - pass diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index 3b542940..1c07ff86 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -13,8 +13,7 @@ from openai.types.chat.chat_completion_tool_message_param import ChatCompletionToolMessageParam from ghostos.core.messages.message import Message, DefaultMessageTypes, Role, Caller, PayloadItem -from ghostos.container import Provider, Container, ABSTRACT -from pydantic import BaseModel, Field +from ghostos.container import Provider, Container, INSTANCE __all__ = [ "OpenAIMessageParser", "DefaultOpenAIMessageParser", "DefaultOpenAIParserProvider", @@ -217,7 +216,7 @@ def _new_pack_from_delta(delta: ChoiceDelta, first: bool) -> Message: return pack -class DefaultOpenAIParserProvider(Provider): +class DefaultOpenAIParserProvider(Provider[OpenAIMessageParser]): """ 默认的 provider. """ @@ -225,8 +224,5 @@ class DefaultOpenAIParserProvider(Provider): def singleton(self) -> bool: return True - def contract(self) -> Type[ABSTRACT]: - return OpenAIMessageParser - - def factory(self, con: Container) -> Optional[ABSTRACT]: + def factory(self, con: Container) -> Optional[OpenAIMessageParser]: return DefaultOpenAIMessageParser() diff --git a/ghostos/framework/cache/mock_cache.py b/ghostos/framework/cache/mock_cache.py index 92b6257c..4f885e88 100644 --- a/ghostos/framework/cache/mock_cache.py +++ b/ghostos/framework/cache/mock_cache.py @@ -3,7 +3,7 @@ import time from typing import Dict, Type, Optional -from ghostos.container import Provider, Container, ABSTRACT +from ghostos.container import Provider, Container from ghostos.contracts.cache import Cache diff --git a/ghostos/framework/configs/storageimpl.py b/ghostos/framework/configs/storageimpl.py index b7f3bebb..c54c12a4 100644 --- a/ghostos/framework/configs/storageimpl.py +++ b/ghostos/framework/configs/storageimpl.py @@ -1,22 +1,22 @@ from typing import Type, Optional from ghostos.contracts.configs import Configs, C from ghostos.contracts.storage import Storage -from ghostos.container import Provider, Container, ABSTRACT +from ghostos.container import Provider, Container from ghostos.core.ghosts import Workspace class StorageConfigs(Configs): """ - 基于 storage 实现的 configs. + A Configs(repository) based on Storage, no matter what the Storage is. """ def __init__(self, storage: Storage, conf_dir: str): self._storage = storage.sub_storage(conf_dir) - def get(self, conf_type: Type[C], file_name: Optional[str] = None) -> C: + def get(self, conf_type: Type[C], relative_path: Optional[str] = None) -> C: path = conf_type.conf_path() - file_name = file_name if file_name else path - content = self._storage.get(file_name) + relative_path = relative_path if relative_path else path + content = self._storage.get(relative_path) return conf_type.load(content) @@ -28,22 +28,19 @@ def __init__(self, conf_dir: str): def singleton(self) -> bool: return True - def contract(self) -> Type[Configs]: - return Configs - def factory(self, con: Container) -> Optional[Configs]: storage = con.force_fetch(Storage) return StorageConfigs(storage, self._conf_dir) class WorkspaceConfigsProvider(Provider[Configs]): + """ + the Configs repository located at storage - workspace.configs() + """ def singleton(self) -> bool: return True - def contract(self) -> Type[ABSTRACT]: - return Configs - - def factory(self, con: Container) -> Optional[ABSTRACT]: + def factory(self, con: Container) -> Optional[Configs]: workspace = con.force_fetch(Workspace) return StorageConfigs(workspace.configs(), "") diff --git a/ghostos/framework/ghostos/basic.py b/ghostos/framework/ghostos/basic.py index c6ca9600..2fa9d938 100644 --- a/ghostos/framework/ghostos/basic.py +++ b/ghostos/framework/ghostos/basic.py @@ -94,10 +94,9 @@ def _default_providers(self) -> List[Provider]: return [ FileStorageProvider(self._root_dir), BasicWorkspaceProvider( - root_dir="", + workspace_dir="", configs_path=self._config_path, runtime_path=self._runtime_path, - source_path=self._source_path, ), DefaultModulesProvider(), WorkspaceConfigsProvider(), diff --git a/ghostos/framework/shells/basic.py b/ghostos/framework/shells/basic.py index c1c7d602..13d339b1 100644 --- a/ghostos/framework/shells/basic.py +++ b/ghostos/framework/shells/basic.py @@ -1,11 +1,15 @@ from typing import Type, Iterable, List, Tuple -from ghostos.container import ABSTRACT +from ghostos.container import INSTANCE, ABSTRACT from ghostos.core.ghosts import Action from ghostos.core.ghosts.shells import Shell class BasicShell(Shell): + """ + A shell implementation that almost do nothing important. + just for testing. + """ def __init__( self, *, @@ -22,17 +26,17 @@ def __init__( def id(self) -> str: return self._id - def shell_prompt(self) -> str: + def status_description(self) -> str: return self._prompt def actions(self) -> Iterable[Action]: return self._actions - def drivers(self) -> Iterable[Type[ABSTRACT]]: + def drivers(self) -> Iterable[ABSTRACT]: for driver in self._drivers: yield driver - def get_driver(self, driver: Type[ABSTRACT]) -> ABSTRACT: + def get_driver(self, driver: ABSTRACT) -> INSTANCE: if driver not in self._drivers: raise KeyError(f"Driver {driver} not supported") return self._drivers[driver] diff --git a/ghostos/framework/storage/filestorage.py b/ghostos/framework/storage/filestorage.py index c600480d..e36d8c3f 100644 --- a/ghostos/framework/storage/filestorage.py +++ b/ghostos/framework/storage/filestorage.py @@ -1,11 +1,15 @@ import os import re -from typing import Optional, AnyStr, Type, Iterable +from typing import Optional, Iterable from ghostos.container import Provider, Container, ABSTRACT from ghostos.contracts.storage import Storage, FileStorage class FileStorageImpl(FileStorage): + """ + FileStorage implementation based on python filesystem. + Simplest implementation. + """ def __init__(self, dir_: str): self._dir: str = os.path.abspath(dir_) @@ -77,10 +81,7 @@ def __init__(self, dir_: str): def singleton(self) -> bool: return True - def contract(self) -> Type[Storage]: - return FileStorage - - def aliases(self) -> Iterable[Type[ABSTRACT]]: + def aliases(self) -> Iterable[ABSTRACT]: yield Storage def factory(self, con: Container) -> Optional[Storage]: diff --git a/ghostos/framework/workspaces/basic.py b/ghostos/framework/workspaces/basic.py index 83d085cf..9ca2ec8f 100644 --- a/ghostos/framework/workspaces/basic.py +++ b/ghostos/framework/workspaces/basic.py @@ -2,25 +2,23 @@ from ghostos.core.ghosts.workspace import Workspace from ghostos.contracts.storage import FileStorage -from ghostos.container import Provider, Container, ABSTRACT +from ghostos.container import Provider, Container, INSTANCE class BasicWorkspace(Workspace): def __init__( self, - root_storage: FileStorage, + workspace_storage: FileStorage, runtime_path: str = "runtime", configs_path="configs", - source_path="sources", ): - self._root_storage: FileStorage = root_storage - self._runtime_storage = root_storage.sub_storage(runtime_path) - self._configs_storage = root_storage.sub_storage(configs_path) - self._source_storage = root_storage.sub_storage(source_path) + self._storage: FileStorage = workspace_storage + self._runtime_storage = workspace_storage.sub_storage(runtime_path) + self._configs_storage = workspace_storage.sub_storage(configs_path) def root(self) -> FileStorage: - return self._root_storage + return self._storage def runtime(self) -> FileStorage: return self._runtime_storage @@ -28,9 +26,6 @@ def runtime(self) -> FileStorage: def configs(self) -> FileStorage: return self._configs_storage - def source(self) -> FileStorage: - return self._source_storage - class BasicWorkspaceProvider(Provider): """ @@ -39,28 +34,30 @@ class BasicWorkspaceProvider(Provider): def __init__( self, - root_dir: str = "", + workspace_dir: str = "", runtime_path: str = "runtime", configs_path="configs", - source_path="src", ): - self._root_path = root_dir + """ + :param workspace_dir: relative workspace dir to the root path + :param runtime_path: relative runtime path to the workspace dir + :param configs_path: relative configs path to the workspace dir + """ + self._root_path = workspace_dir self._runtime_path = runtime_path self._configs_path = configs_path - self._source_path = source_path def singleton(self) -> bool: return True - def contract(self) -> Type[ABSTRACT]: + def contract(self) -> Type[INSTANCE]: return Workspace - def factory(self, con: Container) -> Optional[ABSTRACT]: + def factory(self, con: Container) -> Optional[INSTANCE]: storage = con.force_fetch(FileStorage) root_storage = storage.sub_storage(self._root_path) return BasicWorkspace( root_storage, runtime_path=self._runtime_path, configs_path=self._configs_path, - source_path=self._source_path, ) diff --git a/tests/test_container.py b/tests/test_container.py index bf74cbf7..cfaf4997 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -1,9 +1,9 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import Type, Dict +from typing import Type, Dict, get_args, get_origin -from ghostos.container import Container, Provider +from ghostos.container import Container, Provider, ABSTRACT def test_container_baseline(): @@ -96,3 +96,28 @@ class Foo: container.set(Foo, Foo()) container.bootstrap() assert container.force_fetch(Foo).foo == 1 + + +def test_provider_generic_types(): + class SomeProvider(Provider[int]): + + def singleton(self) -> bool: + return True + + def contract(self) -> ABSTRACT: + return self.get_instance_type() + + def factory(self, con: Container) -> int: + return 3 + + # baseline + args = get_args(Provider[int]) + assert args[0] is int + assert get_origin(Provider[int]) is Provider + + p = SomeProvider() + con = Container() + assert p.singleton() + assert p.factory(con) == 3 + assert p.contract() is int + From 537957876f8e1471f327e7ff8398c904270eea70 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 7 Oct 2024 23:20:25 +0800 Subject: [PATCH 012/148] feat: container add get_bound and Contracts --- ghostos/container.py | 53 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/ghostos/container.py b/ghostos/container.py index 6d56a83b..acfdb6f6 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -9,6 +9,7 @@ "Provider", "Factory", "Bootstrapper", "INSTANCE", "ABSTRACT", "ProviderAdapter", 'provide', + 'Contracts', 'get_caller_info', 'get_container', 'set_container', @@ -37,7 +38,7 @@ def set(self, abstract: ABSTRACT, instance: INSTANCE) -> None: """ @abstractmethod - def register(self, provider: Provider) -> None: + def register(self, *providers: Provider) -> None: """ register factory of the contract by provider """ @@ -61,13 +62,22 @@ def bootstrap(self) -> None: pass @abstractmethod - def get(self, abstract: Union[ABSTRACT, Factory, Provider]) -> Optional[INSTANCE]: + def get(self, abstract: ABSTRACT) -> Optional[INSTANCE]: """ get bound instance or initialize one from registered abstract, or generate one by factory or provider. :return: None if no bound instance. """ pass + @abstractmethod + def get_bound(self, abstract: ABSTRACT) -> Union[INSTANCE, Provider]: + """ + get bound of an abstract + useful to debug + :return: instance or provider + """ + pass + @abstractmethod def fetch(self, abstract: ABSTRACT, strict: bool = False) -> Optional[INSTANCE]: """ @@ -179,9 +189,6 @@ def get(self, abstract: ABSTRACT) -> Optional[INSTANCE]: # 进行初始化. self.bootstrap() - if isinstance(abstract, Provider): - return abstract.factory(self) - # get bound instance got = self._instances.get(abstract, None) if got is not None: @@ -205,6 +212,22 @@ def get(self, abstract: ABSTRACT) -> Optional[INSTANCE]: return self.parent.get(abstract) return None + def get_bound(self, abstract: ABSTRACT) -> Union[INSTANCE, Provider]: + """ + get bound of an abstract + :return: instance or provider + """ + if abstract in self._instances: + return self._instances[abstract] + elif abstract in self._providers: + return self._providers[abstract] + elif abstract in self._aliases: + alias = self._aliases[abstract] + return self.get_bound(alias) + elif self.parent is not None: + return self.parent.get_bound(abstract) + return None + def register_maker( self, contract: ABSTRACT, @@ -219,10 +242,14 @@ def _maker(c): provider = provide(contract, singleton=singleton, lineinfo=lineinfo)(_maker) self.register(provider) - def register(self, provider: Provider) -> None: + def register(self, *providers: Provider) -> None: """ register factory of the contract by provider """ + for provider in providers: + self._register(provider) + + def _register(self, provider: Provider) -> None: if isinstance(provider, Bootstrapper): # 添加 bootstrapper. self.add_bootstrapper(provider) @@ -427,6 +454,20 @@ def wrapper(factory: Factory) -> Provider: return wrapper +class Contracts: + """ + A contracts validator that both indicate the contract types and validate if they are bound to container + """ + + def __init__(self, contracts: List[ABSTRACT]): + self.contracts = contracts + + def validate(self, container: Container) -> None: + for contract in self.contracts: + if not container.bound(contract): + raise NotImplementedError(f'Contract {contract} not bound to container') + + __container = Container() From fb752a788a3a31be196de5f88218c0249ede14e8 Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 8 Oct 2024 01:30:42 +0800 Subject: [PATCH 013/148] refact: define a open-box ready app directory --- app/.example.env | 19 ++++ app/__init__.py | 22 ----- app/example_ghost_func.py | 25 +++++ app/ghostos_apps.py | 119 +++++++++++++++++++++++ app/workspace/configs/llms_conf.yml | 2 +- ghostos/container.py | 12 ++- ghostos/contracts/logger.py | 15 ++- ghostos/core/moss/__init__.py | 8 +- ghostos/core/moss/impl.py | 4 +- ghostos/core/session/processes.py | 6 +- ghostos/core/session/threads.py | 2 +- ghostos/framework/eventbuses/__init__.py | 1 + ghostos/framework/llms/__init__.py | 7 +- ghostos/framework/logger/__init__.py | 1 + ghostos/framework/processes/__init__.py | 1 + ghostos/framework/storage/__init__.py | 1 + ghostos/framework/storage/filestorage.py | 4 +- ghostos/framework/storage/memstorage.py | 2 +- ghostos/framework/tasks/__init__.py | 1 + ghostos/framework/threads/__init__.py | 1 + ghostos/framework/workspaces/__init__.py | 1 + ghostos/prototypes/ghostfunc/__init__.py | 30 ++---- ghostos/prototypes/ghostfunc/prepare.py | 43 ++++++-- ghostos/providers/__init__.py | 1 + ghostos/providers/application.py | 80 +++++++++++++++ pyproject.toml | 1 + 26 files changed, 334 insertions(+), 75 deletions(-) create mode 100644 app/.example.env delete mode 100644 app/__init__.py create mode 100644 app/example_ghost_func.py create mode 100644 app/ghostos_apps.py create mode 100644 ghostos/providers/__init__.py create mode 100644 ghostos/providers/application.py diff --git a/app/.example.env b/app/.example.env new file mode 100644 index 00000000..1f21c384 --- /dev/null +++ b/app/.example.env @@ -0,0 +1,19 @@ +# example of the .env file +# provide secret constants of this project through os.environ. +# copy this file to `.env` and fill the real values. +# or export the values to environment. + +# [openai](https://openai.com/) api key +OPENAI_API_KEY +# optional openai proxy +OPENAI_PROXY +# [moonshot](https://moonshot.cn/) api key +MOONSHOT_API_KEY="your moonshot model api key" +# [anthropic](https://www.anthropic.com/) api key +ANTHROPIC_API_KEY="your anthropic api key" +# optional anthropic proxy +ANTHROPIC_PROXY +# [deepseek](https://deepseek.com) api key +DEEPSEEK_API_KEY +# Logger name for your application +LoggerName=debug \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index 5c9c9d8c..00000000 --- a/app/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -from os.path import dirname, join -import sys - -__all__ = [ - 'app_dir', 'workspace_dir', - 'logging_conf_path', 'logger_name', -] - -app_dir = dirname(__file__) -"""application root path""" - -workspace_dir = join(app_dir, 'workspace') -"""workspace root path""" - -logging_conf_path = join(workspace_dir, 'configs/logging.yml') -"""logging configuration file""" - -logger_name = "debug" -"""default logger name for GhostOS""" - -sys.path.append(join(app_dir, 'src')) -"""add application source code to PYTHONPATH""" diff --git a/app/example_ghost_func.py b/app/example_ghost_func.py new file mode 100644 index 00000000..fd67a0c6 --- /dev/null +++ b/app/example_ghost_func.py @@ -0,0 +1,25 @@ +import sys +from os.path import dirname + +# I hate python imports +root_dir = dirname(__file__) +sys.path.append(root_dir) + +from ghostos_apps import ghost_func + + +@ghost_func.decorator(caching=False) +def get_weather(city: str, date: str) -> str: + """ + 搜寻一个城市在给定日期的天气. + :param city: 城市名 + :param date: 日期 + :return: 关于天气的自然语言描述 + """ + # 你的任务是, 先观察用户输入的 city, date 是什么, 确定了它的值, 再输出真正的函数. + # 然后 mock 一个自然语言的天气描述结果, 用自然语言返回. 你使用的语言必须要和入参语种一致. + pass + + +if __name__ == "__main__": + print(get_weather("beijing", "today")) diff --git a/app/ghostos_apps.py b/app/ghostos_apps.py new file mode 100644 index 00000000..10fdc097 --- /dev/null +++ b/app/ghostos_apps.py @@ -0,0 +1,119 @@ +from os.path import dirname, join +from ghostos.container import Container +from ghostos.contracts.logger import config_logging +from ghostos.providers import default_application_providers, application_contracts +from ghostos.prototypes.ghostfunc import init_ghost_func, GhostFunc +import dotenv +import os + +# Core Concepts +# +# 1. Ghost and Shell +# We take the word `Ghost` from famous manga movie as the abstract of an Agent. +# Ghost shall have concurrent thinking/action capabilities, each thought or task is a fragment of the Ghost mind; +# not like an independent agent in a multi-agent system. +# But you can take `Ghost` as `Agent` for now. +# Also, the word `Shell` in this project refers to the `Body` of the Agent, +# regardless if it is an Embodied Robot/IM chatbot/Website/IDE etc. +# +# 2. MOSS +# stands for "Model-oriented Operating System Simulation". +# - operating system: to operate an Agent's body (Shell), mind, tools. +# - model-oriented: the first class user of the OS is the brain of Ghost(AI models), not Human +# - simulation: we merely use python to simulate the OS, not create a real one. +# Instead of `JSON Schema Tool`, we provide a python code interface for LLMs through MOSS. +# The LLMs can read python context as prompt, then generate python code to do almost everything. +# MOSS can reflect the python module to prompt, and execute the generated python code within a specific python context. +# +# We are aiming to create Fractal Meta-Agent which can generate tools/libraries/Shells/ +# +# 3. GhostOS +# Is an agent framework for developers like myself, to define/test/use/modify Model-based Agents. +# Not like MOSS which serve the Models (Large Language Model mostly), +# GhostOS is a framework works for me the Human developer. +# +# 4. Application +# Is the production built with GhostOS. +# There are light-weight applications like `GhostFunc` which is a python function decorator, +# and heavy applications like Streamlit app. +# +# todo: let the gpt4o or moonshot fix my pool english expressions above. + + + +__all__ = [ + 'app_dir', 'workspace_dir', + + # >>> container + # GhostOS use IoC Container to manage dependency injections at everywhere. + # IoCContainer inherit all the bindings from parent Container, and also able to override them. + # The singletons in the container shall always be thread-safe. + # + # The containers nest in multiple levels like a tree: + # - Application level (global static container that instanced in this file) + # - GhostOS level (a GhostOS manage as many ghost as it able to) + # - Ghost level (a Ghost is a instance frame of the Agent's thought) + # - Moss level (each MossCompiler has it own container) + # <<< + + # application level container (global static IoC container) + 'container', + + # >>> GhostFunc + # is a test library, which is able to define dynamic code for a in-complete function. + # We develop it for early experiments. + # Check example_ghost_func.py + # <<< + 'GhostFunc', + 'ghost_func', +] + +# --- prepare application paths --- # + +app_dir = dirname(__file__) +"""application root path""" + +workspace_dir = join(app_dir, 'workspace') +"""workspace root path""" + +logging_conf_path = join(workspace_dir, 'configs/logging.yml') +"""logging configuration file""" + +# --- system initialization --- # + +# load env from dotenv file +dotenv.load_dotenv(dotenv_path=join(app_dir, '.env')) + +# default logger name for GhostOS application +logger_name = os.environ.get("LoggerName", "debug") + +# initialize logging configs +config_logging(logging_conf_path) + +# --- prepare application container --- # + +container = Container() +"""application root static container""" + +# get default application providers. +application_providers = default_application_providers(root_dir=app_dir, logger_name=logger_name) +# register application providers +container.register(*application_providers) + +# validate the application contracts +application_contracts.validate(container) + +# --- init ghost func decorator --- # + +ghost_func: GhostFunc = init_ghost_func(container) +""" +ghost func is a function decorator, that produce dynamic codes and exec it for the function during calling +""" + +# --- test the module by python -i --- # + +if __name__ == '__main__': + """ + run `python -i __init__.py` to interact with the current file + """ + pass diff --git a/app/workspace/configs/llms_conf.yml b/app/workspace/configs/llms_conf.yml index 6024fe64..1c9aa466 100644 --- a/app/workspace/configs/llms_conf.yml +++ b/app/workspace/configs/llms_conf.yml @@ -9,7 +9,7 @@ services: proxy: $OPENAI_PROXY - name: anthropic token: $ANTHROPIC_API_KEY - proxy: $OPENAI_PROXY + proxy: $ANTHROPIC_PROXY base_url: https://api.anthropic.com/v1 - name: deepseek token: $DEEPSEEK_API_KEY diff --git a/ghostos/container.py b/ghostos/container.py index acfdb6f6..8f2999ee 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -223,7 +223,7 @@ def get_bound(self, abstract: ABSTRACT) -> Union[INSTANCE, Provider]: return self._providers[abstract] elif abstract in self._aliases: alias = self._aliases[abstract] - return self.get_bound(alias) + return alias elif self.parent is not None: return self.parent.get_bound(abstract) return None @@ -259,9 +259,13 @@ def _register(self, provider: Provider) -> None: self._register_provider(contract, provider) # additional bindings - for b in provider.aliases(): - if b not in self._bound: - self._aliases[b] = contract + for alias in provider.aliases(): + if alias not in self._bound: + self._bind_alias(alias, contract) + + def _bind_alias(self, alias: ABSTRACT, contract: ABSTRACT) -> None: + self._aliases[alias] = contract + self._bound.add(alias) def _register_provider(self, contract: ABSTRACT, provider: Provider) -> None: # remove singleton instance that already bound diff --git a/ghostos/contracts/logger.py b/ghostos/contracts/logger.py index 74e3861d..dee8bb58 100644 --- a/ghostos/contracts/logger.py +++ b/ghostos/contracts/logger.py @@ -1,8 +1,10 @@ from abc import ABC, abstractmethod from logging import LoggerAdapter, Logger, getLogger +from logging.config import dictConfig from typing import Union, Dict +import yaml -__all__ = ['LoggerItf', 'LoggerAdapter', 'LoggerType', 'LoggerWrapper'] +__all__ = ['LoggerItf', 'LoggerAdapter', 'LoggerType', 'LoggerWrapper', 'config_logging'] class LoggerItf(ABC): @@ -139,3 +141,14 @@ def with_trace(self, trace: Dict) -> "LoggerItf": def get_logger(logger_name: str) -> LoggerItf: return LoggerWrapper(getLogger(logger_name)) + + +def config_logging(conf_path: str) -> None: + """ + configurate logging by yaml config + :param conf_path: absolute path of yaml config file + """ + with open(conf_path) as f: + content = f.read() + data = yaml.safe_load(content) + dictConfig(data) diff --git a/ghostos/core/moss/__init__.py b/ghostos/core/moss/__init__.py index a98968bc..517abaeb 100644 --- a/ghostos/core/moss/__init__.py +++ b/ghostos/core/moss/__init__.py @@ -1,4 +1,4 @@ -from ghostos.container import Container, Provider +from ghostos.container import Container from ghostos.core.moss.abc import ( Moss, MossCompiler, MossRuntime, MossPrompter, MossResult, AttrPrompts, @@ -6,7 +6,7 @@ MOSS_EXEC_EVENT, MOSS_PROMPT_EVENT, MOSS_COMPILE_EVENT, MOSS_ATTR_PROMPTS_EVENT, moss_message, ) -from ghostos.core.moss.impl import TestMOSSProvider +from ghostos.core.moss.impl import DefaultMOSSProvider from ghostos.core.moss.test_suites import MossTestSuite from ghostos.core.moss.pycontext import PyContext, Injection, Property, attr, SerializableType, SerializableData from ghostos.core.moss.functional_token import ( @@ -31,7 +31,7 @@ # pycontext related PyContext, Injection, Property, attr, SerializableType, SerializableData, # testing - TestMOSSProvider, + DefaultMOSSProvider, MossTestSuite, 'test_container', 'moss_test_suite', @@ -45,7 +45,7 @@ def test_container() -> Container: """ from ghostos.contracts.modules import DefaultModulesProvider container = Container() - container.register(TestMOSSProvider()) + container.register(DefaultMOSSProvider()) container.register(DefaultModulesProvider()) return container diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index 525286a1..ce7ee0a7 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -295,7 +295,7 @@ def destroy(self) -> None: del self._moss -class TestMOSSProvider(Provider[MossCompiler]): +class DefaultMOSSProvider(Provider[MossCompiler]): """ 用于测试的标准 compiler. 但实际上好像也是这个样子. @@ -308,4 +308,4 @@ def contract(self) -> Type[MossCompiler]: return MossCompiler def factory(self, con: Container) -> MossCompiler: - return MossCompilerImpl(container=con, pycontext=PyContext()) + return MossCompilerImpl(container=con, pycontext=None) diff --git a/ghostos/core/session/processes.py b/ghostos/core/session/processes.py index 24ed92c8..b1f103c8 100644 --- a/ghostos/core/session/processes.py +++ b/ghostos/core/session/processes.py @@ -61,7 +61,7 @@ def new( class Processes(ABC): """ - 管理进程存储的模块. 通常集成到 Session 里. + repository to save or load process """ @abstractmethod @@ -90,4 +90,8 @@ def save_process(self, process: Process) -> None: @contextmanager def transaction(self): + """ + transaction to process io + do nothing as default. + """ yield diff --git a/ghostos/core/session/threads.py b/ghostos/core/session/threads.py index e921bf72..32255a37 100644 --- a/ghostos/core/session/threads.py +++ b/ghostos/core/session/threads.py @@ -298,7 +298,7 @@ def thread_to_chat(chat_id: str, system: List[Message], thread: MsgThread) -> Ch class Threads(ABC): """ - 管理 Threads 存取的模块. 通常集成到 Session 里. + the repository to save and load threads """ @abstractmethod diff --git a/ghostos/framework/eventbuses/__init__.py b/ghostos/framework/eventbuses/__init__.py index 2d4e0f66..4114b5fa 100644 --- a/ghostos/framework/eventbuses/__init__.py +++ b/ghostos/framework/eventbuses/__init__.py @@ -1 +1,2 @@ +from ghostos.core.session import EventBus from ghostos.framework.eventbuses.memimpl import MemEventBusImplProvider diff --git a/ghostos/framework/llms/__init__.py b/ghostos/framework/llms/__init__.py index e3a4af97..4e2f8bf7 100644 --- a/ghostos/framework/llms/__init__.py +++ b/ghostos/framework/llms/__init__.py @@ -1,9 +1,4 @@ +from ghostos.core.llms import LLMs from ghostos.framework.llms.llms import LLMsImpl from ghostos.framework.llms.openai_driver import OpenAIDriver, OpenAIAdapter, LitellmAdapter from ghostos.framework.llms.providers import ConfigBasedLLMsProvider - - -default_llms_provider = ConfigBasedLLMsProvider("llms/llms_conf.yaml") -"""default llms provider based by configs contract """ - - diff --git a/ghostos/framework/logger/__init__.py b/ghostos/framework/logger/__init__.py index 362e3eed..d5eb62e3 100644 --- a/ghostos/framework/logger/__init__.py +++ b/ghostos/framework/logger/__init__.py @@ -1,2 +1,3 @@ +from ghostos.contracts.logger import LoggerItf from ghostos.framework.logger.named import NamedLoggerProvider from ghostos.framework.logger.fake import FakeLogger diff --git a/ghostos/framework/processes/__init__.py b/ghostos/framework/processes/__init__.py index 0ea9b26e..91a4721b 100644 --- a/ghostos/framework/processes/__init__.py +++ b/ghostos/framework/processes/__init__.py @@ -1 +1,2 @@ +from ghostos.core.session import Processes from ghostos.framework.processes.storage_processes import StorageProcessImplProvider, WorkspaceProcessesProvider diff --git a/ghostos/framework/storage/__init__.py b/ghostos/framework/storage/__init__.py index cf4511d2..aa087a8a 100644 --- a/ghostos/framework/storage/__init__.py +++ b/ghostos/framework/storage/__init__.py @@ -1,2 +1,3 @@ +from ghostos.contracts.storage import Storage, FileStorage from ghostos.framework.storage.filestorage import FileStorageProvider, FileStorageImpl from ghostos.framework.storage.memstorage import MemStorage diff --git a/ghostos/framework/storage/filestorage.py b/ghostos/framework/storage/filestorage.py index e36d8c3f..597a162e 100644 --- a/ghostos/framework/storage/filestorage.py +++ b/ghostos/framework/storage/filestorage.py @@ -4,6 +4,8 @@ from ghostos.container import Provider, Container, ABSTRACT from ghostos.contracts.storage import Storage, FileStorage +__all__ = ["FileStorageProvider", "FileStorageImpl"] + class FileStorageImpl(FileStorage): """ @@ -73,7 +75,7 @@ def _match_file_pattern(filename: str, pattern: Optional[str]) -> bool: return r is not None -class FileStorageProvider(Provider[Storage]): +class FileStorageProvider(Provider[FileStorage]): def __init__(self, dir_: str): self._dir: str = dir_ diff --git a/ghostos/framework/storage/memstorage.py b/ghostos/framework/storage/memstorage.py index 1a2ce997..e588d3f1 100644 --- a/ghostos/framework/storage/memstorage.py +++ b/ghostos/framework/storage/memstorage.py @@ -1,4 +1,4 @@ -from typing import Optional, Iterable, AnyStr, Dict +from typing import Optional, Iterable, Dict from ghostos.contracts.storage import Storage from os.path import join diff --git a/ghostos/framework/tasks/__init__.py b/ghostos/framework/tasks/__init__.py index 9fec82f5..3f856f18 100644 --- a/ghostos/framework/tasks/__init__.py +++ b/ghostos/framework/tasks/__init__.py @@ -1 +1,2 @@ +from ghostos.core.session import Tasks from ghostos.framework.tasks.storage_tasks import StorageTasksImplProvider, WorkspaceTasksProvider diff --git a/ghostos/framework/threads/__init__.py b/ghostos/framework/threads/__init__.py index a8284341..30eb0a38 100644 --- a/ghostos/framework/threads/__init__.py +++ b/ghostos/framework/threads/__init__.py @@ -1 +1,2 @@ +from ghostos.core.session import Threads from ghostos.framework.threads.storage_threads import StorageThreadsProvider, WorkspaceThreadsProvider diff --git a/ghostos/framework/workspaces/__init__.py b/ghostos/framework/workspaces/__init__.py index 43cc7c6a..a6a425b3 100644 --- a/ghostos/framework/workspaces/__init__.py +++ b/ghostos/framework/workspaces/__init__.py @@ -1 +1,2 @@ +from ghostos.core.ghosts.workspace import Workspace from ghostos.framework.workspaces.basic import BasicWorkspaceProvider diff --git a/ghostos/prototypes/ghostfunc/__init__.py b/ghostos/prototypes/ghostfunc/__init__.py index 8d28f14a..eb2bb1a3 100644 --- a/ghostos/prototypes/ghostfunc/__init__.py +++ b/ghostos/prototypes/ghostfunc/__init__.py @@ -1,30 +1,12 @@ -from os.path import dirname, join from ghostos.prototypes.ghostfunc.decorator import GhostFunc -from ghostos.prototypes.ghostfunc.prepare import init_ghost_func_container +from ghostos.prototypes.ghostfunc.prepare import init_ghost_func, init_ghost_func_container """ this is a toy that using MOSS to implement a light-weight dynamic function based by llm code generation. """ -__all__ = ['ghost_func', 'GhostFunc', 'init_ghost_func_container', 'init_ghost_func'] - -demo_dir = join(dirname(dirname(dirname(__file__))), 'demo') -_container = init_ghost_func_container(demo_dir) - -ghost_func = GhostFunc(_container) - - -def init_ghost_func( - root_dir: str, - configs_path: str = "configs", - llm_conf_path: str = "llms_conf.yml", -) -> GhostFunc: - """ - init ghost func instance from a dir keeping configs. - :param root_dir: root dir of runtime and configs. - :param configs_path: configs dir path from root dir - :param llm_conf_path: llm config path in configs dir - :return: instance of GhostFunc, with decorators. - """ - ghost_func_container = init_ghost_func_container(root_dir, configs_path, llm_conf_path) - return GhostFunc(ghost_func_container) +__all__ = [ + 'GhostFunc', + 'init_ghost_func_container', + 'init_ghost_func', +] diff --git a/ghostos/prototypes/ghostfunc/prepare.py b/ghostos/prototypes/ghostfunc/prepare.py index 5dfe96b0..3c19ed23 100644 --- a/ghostos/prototypes/ghostfunc/prepare.py +++ b/ghostos/prototypes/ghostfunc/prepare.py @@ -1,19 +1,48 @@ +from typing import Optional from ghostos.container import Container -from ghostos.core.moss import test_container +from ghostos.core.moss import test_container, MossCompiler +from ghostos.core.llms import LLMs from ghostos.framework.configs import ConfigsByStorageProvider from ghostos.framework.storage import FileStorageProvider from ghostos.framework.llms import ConfigBasedLLMsProvider +from ghostos.prototypes.ghostfunc.decorator import GhostFunc +from ghostos.container import Contracts -__all__ = ["init_ghost_func_container"] +__all__ = ["init_ghost_func_container", "init_ghost_func", 'ghost_func_contracts'] + +ghost_func_contracts = Contracts([ + LLMs, + MossCompiler, +]) def init_ghost_func_container( - root_path: str, - configs_path: str = "configs", + workspace_dir: str, + configs_dir: str = "configs", llm_conf_path: str = "llms_conf.yml", + container: Optional[Container] = None, ) -> Container: - container = test_container() - container.register(FileStorageProvider(root_path)) - container.register(ConfigsByStorageProvider(configs_path)) + """ + init ghost_func's container + :param workspace_dir: + :param configs_dir: relative directory from workspace + :param llm_conf_path: llms conf path + :param container: parent container. + """ + if container is None: + container = test_container() + container.register(FileStorageProvider(workspace_dir)) + container.register(ConfigsByStorageProvider(configs_dir)) container.register(ConfigBasedLLMsProvider(llm_conf_path)) return container + + +def init_ghost_func( + container: Container, +) -> GhostFunc: + """ + return ghost func instance + :param container: application container. + """ + ghost_func_contracts.validate(container) + return GhostFunc(container) diff --git a/ghostos/providers/__init__.py b/ghostos/providers/__init__.py new file mode 100644 index 00000000..56bf606c --- /dev/null +++ b/ghostos/providers/__init__.py @@ -0,0 +1 @@ +from ghostos.providers.application import default_application_providers, application_contracts \ No newline at end of file diff --git a/ghostos/providers/application.py b/ghostos/providers/application.py new file mode 100644 index 00000000..e93205e8 --- /dev/null +++ b/ghostos/providers/application.py @@ -0,0 +1,80 @@ +from typing import List +from ghostos.container import Provider, Contracts +from ghostos.contracts.pool import Pool, DefaultPoolProvider +from ghostos.contracts.shutdown import Shutdown, ShutdownProvider +from ghostos.contracts.modules import Modules, DefaultModulesProvider +from ghostos.core.moss import MossCompiler, DefaultMOSSProvider +from ghostos.framework.storage import Storage, FileStorage, FileStorageProvider +from ghostos.framework.workspaces import Workspace, BasicWorkspaceProvider +from ghostos.framework.configs import Configs, WorkspaceConfigsProvider +from ghostos.framework.processes import WorkspaceProcessesProvider, Processes +from ghostos.framework.threads import Threads, WorkspaceThreadsProvider +from ghostos.framework.tasks import Tasks, WorkspaceTasksProvider +from ghostos.framework.eventbuses import EventBus, MemEventBusImplProvider +from ghostos.framework.llms import LLMs, ConfigBasedLLMsProvider +from ghostos.framework.logger import LoggerItf, NamedLoggerProvider + + +""" +Application level contracts and providers. +""" + +application_contracts = Contracts([ + # workspace contracts + Storage, # workspace root storage + FileStorage, # workspace root storage is a file storage + Workspace, # application workspace implementation + Configs, # application configs repository + + # system contracts + Pool, # multi-thread or process pool to submit async tasks + Shutdown, # graceful shutdown register + LLMs, # LLMs interface + LoggerItf, # the logger instance of application + Modules, # the import_module proxy + + # moss + MossCompiler, + + # session contracts + Processes, # application processes repository + Threads, # application threads repository + Tasks, # application tasks repository + EventBus, # application session eventbus +]) + + +def default_application_providers( + root_dir: str, + logger_name: str, + workspace_dir: str = "workspace", + workspace_configs_dir: str = "configs", + workspace_runtime_dir: str = "runtime", + runtime_processes_dir: str = "processes", + runtime_tasks_dir: str = "tasks", + runtime_threads_dir: str = "threads", + llms_conf_path: str = "llms_conf.yml", +) -> List[Provider]: + """ + application default providers that bind contracts to application level container. + todo: use manager provider to configurate multiple kinds of implementation + """ + return [ + FileStorageProvider(root_dir), + BasicWorkspaceProvider( + workspace_dir=workspace_dir, + configs_path=workspace_configs_dir, + runtime_path=workspace_runtime_dir, + ), + WorkspaceConfigsProvider(), + WorkspaceProcessesProvider(runtime_processes_dir), + WorkspaceTasksProvider(runtime_tasks_dir), + WorkspaceThreadsProvider(runtime_threads_dir), + DefaultPoolProvider(100), + ConfigBasedLLMsProvider(llms_conf_path), + DefaultModulesProvider(), + MemEventBusImplProvider(), + ShutdownProvider(), + NamedLoggerProvider(logger_name), + DefaultMOSSProvider(), + ] diff --git a/pyproject.toml b/pyproject.toml index 8fe76e8d..ab2866bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ streamlit = "^1.39.0" pydantic-settings = "^2.5.2" streamlit-antd-components = "^0.3.2" streamlit-react-jsonschema = "^0.1.3" +python-dotenv = "^1.0.1" [tool.poetry.scripts] init = "ghostos.scripts.init:main" From 2ae428d8692ba54ea2d22e241200f87eb6d074e8 Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 8 Oct 2024 01:43:43 +0800 Subject: [PATCH 014/148] dev: more streamlit tests, and prepare remove ghostos.demo later --- README.md | 2 +- ghostos/demo/{src => }/aifuncs/__init__.py | 0 ghostos/demo/{src => }/aifuncs/agentic.py | 4 +- ghostos/demo/{src => }/aifuncs/baseline.py | 0 ghostos/demo/{src => }/aifuncs/news.py | 0 ghostos/demo/{src => }/aifuncs/utils.py | 0 ghostos/demo/{src => }/aifuncs/weather.py | 2 +- ghostos/demo/src/examples/run_aifunc_test.py | 2 +- .../streamlitapp/design2/aifuncs/details.py | 53 +++++++++++++++++++ .../streamlitapp/design2/aifuncs/index.py | 28 ++++++++++ .../design2/homepage/applications.py | 19 +++++++ .../streamlitapp/design2/homepage/home.py | 44 +++++++++++++++ .../streamlitapp/design2/homepage/host.py | 3 ++ .../prototypes/streamlitapp/design2/index.py | 33 ++++++++++++ .../prototypes/streamlitapp/design2/router.py | 0 .../streamlitapp/patches/__init__.py | 1 - .../tests/aifunc/aifunc_elements.py | 30 ++--------- .../tests/sidebar/echo_spinner.py | 10 ++++ 18 files changed, 199 insertions(+), 32 deletions(-) rename ghostos/demo/{src => }/aifuncs/__init__.py (100%) rename ghostos/demo/{src => }/aifuncs/agentic.py (86%) rename ghostos/demo/{src => }/aifuncs/baseline.py (100%) rename ghostos/demo/{src => }/aifuncs/news.py (100%) rename ghostos/demo/{src => }/aifuncs/utils.py (100%) rename ghostos/demo/{src => }/aifuncs/weather.py (96%) create mode 100644 ghostos/prototypes/streamlitapp/design2/aifuncs/details.py create mode 100644 ghostos/prototypes/streamlitapp/design2/aifuncs/index.py create mode 100644 ghostos/prototypes/streamlitapp/design2/homepage/applications.py create mode 100644 ghostos/prototypes/streamlitapp/design2/homepage/home.py create mode 100644 ghostos/prototypes/streamlitapp/design2/homepage/host.py create mode 100644 ghostos/prototypes/streamlitapp/design2/index.py create mode 100644 ghostos/prototypes/streamlitapp/design2/router.py delete mode 100644 ghostos/prototypes/streamlitapp/patches/__init__.py create mode 100644 ghostos/prototypes/streamlitapp/tests/sidebar/echo_spinner.py diff --git a/README.md b/README.md index 699965ed..ee26295f 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ In [this case](ghostos/demo/src/examples/run_aifunc_test.py) we ask an agent-lik We expect the `AgentFn` will call `WeatherAIFunc` and `NewsAIFunc` to help with subtasks, and give a final result to us. -The testing AIFuncs are defined at [aifuncs](ghostos/demo/src/aifuncs). +The testing AIFuncs are defined at [aifuncs](ghostos/demo/aifuncs). ### File Editor Agent Test diff --git a/ghostos/demo/src/aifuncs/__init__.py b/ghostos/demo/aifuncs/__init__.py similarity index 100% rename from ghostos/demo/src/aifuncs/__init__.py rename to ghostos/demo/aifuncs/__init__.py diff --git a/ghostos/demo/src/aifuncs/agentic.py b/ghostos/demo/aifuncs/agentic.py similarity index 86% rename from ghostos/demo/src/aifuncs/agentic.py rename to ghostos/demo/aifuncs/agentic.py index 30b42475..35e7fd0a 100644 --- a/ghostos/demo/src/aifuncs/agentic.py +++ b/ghostos/demo/aifuncs/agentic.py @@ -1,8 +1,8 @@ from typing import Optional from ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx from ghostos.core.moss import Moss as Parent -from ghostos.demo.src.aifuncs.weather import WeatherAIFunc -from ghostos.demo.src.aifuncs.news import NewsAIFunc +from ghostos.demo.aifuncs.weather import WeatherAIFunc +from ghostos.demo.aifuncs.news import NewsAIFunc from pydantic import Field diff --git a/ghostos/demo/src/aifuncs/baseline.py b/ghostos/demo/aifuncs/baseline.py similarity index 100% rename from ghostos/demo/src/aifuncs/baseline.py rename to ghostos/demo/aifuncs/baseline.py diff --git a/ghostos/demo/src/aifuncs/news.py b/ghostos/demo/aifuncs/news.py similarity index 100% rename from ghostos/demo/src/aifuncs/news.py rename to ghostos/demo/aifuncs/news.py diff --git a/ghostos/demo/src/aifuncs/utils.py b/ghostos/demo/aifuncs/utils.py similarity index 100% rename from ghostos/demo/src/aifuncs/utils.py rename to ghostos/demo/aifuncs/utils.py diff --git a/ghostos/demo/src/aifuncs/weather.py b/ghostos/demo/aifuncs/weather.py similarity index 96% rename from ghostos/demo/src/aifuncs/weather.py rename to ghostos/demo/aifuncs/weather.py index e8f4a90f..62a3aca6 100644 --- a/ghostos/demo/src/aifuncs/weather.py +++ b/ghostos/demo/aifuncs/weather.py @@ -1,6 +1,6 @@ from typing import Optional from ghostos.core.aifunc import AIFunc, AIFuncResult -from ghostos.demo.src.aifuncs.utils import get_weather +from ghostos.demo.aifuncs.utils import get_weather from pydantic import Field diff --git a/ghostos/demo/src/examples/run_aifunc_test.py b/ghostos/demo/src/examples/run_aifunc_test.py index 6429366f..b419a354 100644 --- a/ghostos/demo/src/examples/run_aifunc_test.py +++ b/ghostos/demo/src/examples/run_aifunc_test.py @@ -1,5 +1,5 @@ from ghostos.prototypes.aifunc import quick_run_aifunc -from ghostos.demo.src.aifuncs.agentic import AgentFn +from ghostos.demo.aifuncs import AgentFn from ghostos.helpers import yaml_pretty_dump if __name__ == '__main__': diff --git a/ghostos/prototypes/streamlitapp/design2/aifuncs/details.py b/ghostos/prototypes/streamlitapp/design2/aifuncs/details.py new file mode 100644 index 00000000..bfab37c3 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design2/aifuncs/details.py @@ -0,0 +1,53 @@ +import streamlit as st +from streamlit_react_jsonschema import pydantic_form +import inspect +from app.examples.aifuncs.weather import WeatherAIFunc +from ghostos.core.aifunc import get_aifunc_result_type + + +def show_tab_detail(): + st.subheader("Import Path") + st.code(f"from {WeatherAIFunc.__module__} import {WeatherAIFunc.__qualname__}", language="python") + + st.subheader("Request Type of the Func") + instance = pydantic_form(WeatherAIFunc) + + st.subheader("Result Type of the Func") + result_type = get_aifunc_result_type(WeatherAIFunc) + instance = pydantic_form(result_type) + + st.subheader("Source Code") + with st.expander("Full Code", expanded=False): + mod = inspect.getmodule(WeatherAIFunc) + codes = inspect.getsource(mod) + st.code(codes, language="python") + + with st.expander("Request Code", expanded=False): + codes = inspect.getsource(WeatherAIFunc) + st.code(codes, language="python") + + with st.expander("Result code", expanded=False): + result_type = get_aifunc_result_type(WeatherAIFunc) + codes = inspect.getsource(result_type) + st.code(codes, language="python") + + +with st.sidebar: + st.page_link("aifuncs/index.py", label="AI Function List", icon=":material/list:") + +st.title(WeatherAIFunc.__name__) +st.markdown(WeatherAIFunc.__doc__) + +tab_detail, tab_run, tab_test, tab_history = st.tabs(["Detail", "Run", "Tests", "History"]) + +with tab_detail: + show_tab_detail() + +with tab_run: + st.caption("run") + +with tab_test: + st.caption("test") + +with tab_history: + st.caption("history") diff --git a/ghostos/prototypes/streamlitapp/design2/aifuncs/index.py b/ghostos/prototypes/streamlitapp/design2/aifuncs/index.py new file mode 100644 index 00000000..47262753 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design2/aifuncs/index.py @@ -0,0 +1,28 @@ +import streamlit as st + +AIFUNC_SEARCH_MESSAGES = "aifunc.index.messages" + +with st.sidebar: + if search_aifunc := st.text_input( + "Search AIFUNC", + placeholder="description of the AIFunc you want", + ): + st.write(search_aifunc) + +st.title("AI Functions") + +with st.expander("introduction"): + st.write("hello world") + +pressed = False +for i in range(5): + with st.container(border=True): + st.subheader("AIFuncName") + st.caption("from foo.bar.zoo import xxx") + st.text("description of the AIFunc __doc__") + hit = st.button("enter", key=f"button-{i}") + if not pressed and hit: + pressed = True + +if pressed: + st.switch_page("aifuncs/details.py") diff --git a/ghostos/prototypes/streamlitapp/design2/homepage/applications.py b/ghostos/prototypes/streamlitapp/design2/homepage/applications.py new file mode 100644 index 00000000..9ef314ae --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design2/homepage/applications.py @@ -0,0 +1,19 @@ +import streamlit as st + +st.title("Applications") + +with st.expander("description"): + st.write(""" +hello world +""") + +with st.container(border=True): + st.subheader("Chatbots") + st.text("chatbots that predefined") + col1, col2, col3 = st.columns([1,2,3]) + with col1: + st.button("all", help="show all the AIFuncs or search one", type="primary") + with col2: + st.button("WeatherAIFunc", help="show weather AIFuncs") + with col3: + st.button("AgenticAIFunc", help="show weather AIFuncs") diff --git a/ghostos/prototypes/streamlitapp/design2/homepage/home.py b/ghostos/prototypes/streamlitapp/design2/homepage/home.py new file mode 100644 index 00000000..b1c2dc6f --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design2/homepage/home.py @@ -0,0 +1,44 @@ +import streamlit as st +import streamlit_antd_components as sac + +st.title("GhostOS") +with st.expander(label="Introduction"): + st.markdown("hello world") +with st.expander(label="How to"): + st.markdown("hello world") + +with st.container(border=True): + st.subheader("Page links for test") + st.page_link( + "aifuncs/index.py", + label="AI Functions", + use_container_width=True, + ) + +with st.container(border=True): + st.subheader("Navigation") + label = sac.menu([ + sac.MenuItem( + 'Home', + icon='house-fill', + children=[ + sac.MenuItem("Host", icon="robot", description="GhostOS official chatbot"), + sac.MenuItem("Documents", icon="book-fill", description="guide book"), + sac.MenuItem("Settings", icon="gear-fill", description="configs"), + sac.MenuItem("Tools", icon="hammer", description="System scripts"), + ], + ), + sac.MenuItem('Applications', icon='box-fill', children=[ + sac.MenuItem('ChatBots', icon='chat-dots'), + sac.MenuItem('AIFuncs', icon='code-square'), + ]), + sac.MenuItem('Resources', icon='database-fill-gear', children=[ + sac.MenuItem('LLMs', icon='heart-fill'), + sac.MenuItem('Moss Files', icon='heart-fill'), + sac.MenuItem('Thoughts', icon='heart-fill'), + sac.MenuItem('Registry', icon='heart-fill'), + sac.MenuItem('Knowledge', icon='heart-fill'), + sac.MenuItem('Data Objects', icon='heart-fill'), + ]), + ], open_all=True, index=0, variant="left-bar") + st.write(label) diff --git a/ghostos/prototypes/streamlitapp/design2/homepage/host.py b/ghostos/prototypes/streamlitapp/design2/homepage/host.py new file mode 100644 index 00000000..cc7e36b8 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design2/homepage/host.py @@ -0,0 +1,3 @@ +import streamlit as st + +st.title("home") diff --git a/ghostos/prototypes/streamlitapp/design2/index.py b/ghostos/prototypes/streamlitapp/design2/index.py new file mode 100644 index 00000000..b33e379a --- /dev/null +++ b/ghostos/prototypes/streamlitapp/design2/index.py @@ -0,0 +1,33 @@ +import streamlit as st + +pages = st.navigation( + [ + st.Page('homepage/home.py', title="Home", default=True), + st.Page('homepage/host.py'), + st.Page('homepage/applications.py'), + st.Page('aifuncs/index.py'), + st.Page('aifuncs/details.py'), + ], + position="hidden", +) + + +with st.sidebar: + st.page_link( + "homepage/home.py", + label="Home", + icon=":material/home:", + ) +# st.page_link( +# "homepage/home.py", +# label="Home", +# icon=":material/home:", +# help="GhostOS homepage", +# ) +# st.page_link( +# "homepage/applications.py", +# label="Applications", +# icon=":material/apps:", +# ) + +pages.run() diff --git a/ghostos/prototypes/streamlitapp/design2/router.py b/ghostos/prototypes/streamlitapp/design2/router.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/streamlitapp/patches/__init__.py b/ghostos/prototypes/streamlitapp/patches/__init__.py deleted file mode 100644 index 5ec0aec3..00000000 --- a/ghostos/prototypes/streamlitapp/patches/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ghostos.prototypes.streamlitapp.patches.streamlit_pydantic_patch import streamlit_pydantic diff --git a/ghostos/prototypes/streamlitapp/tests/aifunc/aifunc_elements.py b/ghostos/prototypes/streamlitapp/tests/aifunc/aifunc_elements.py index 0abad0a3..f03184c3 100644 --- a/ghostos/prototypes/streamlitapp/tests/aifunc/aifunc_elements.py +++ b/ghostos/prototypes/streamlitapp/tests/aifunc/aifunc_elements.py @@ -1,9 +1,9 @@ import streamlit as st -from ghostos.prototypes.streamlitapp.patches import streamlit_pydantic as sp +import streamlit_react_jsonschema as srj import inspect from ghostos.core.aifunc import get_aifunc_result_type -from ghostos.app.src.aifuncs.weather import WeatherAIFunc, WeatherAIFuncResult +from ghostos.demo.aifuncs.weather import WeatherAIFunc, WeatherAIFuncResult # source code st.title("Source Code") @@ -30,30 +30,8 @@ # aifunc pydantic output request = WeatherAIFunc() st.title("AiFunc output") -sp.pydantic_output(request) +srj.pydantic_instance_form(request) # aifunc result pydantic output -result = WeatherAIFuncResult() st.title("AiFuncs Result output") -sp.pydantic_output(result.model_copy()) - -# input form -st.title("Aifunc Input Form") -if input_data := sp.pydantic_input( - "weather_input", - model=WeatherAIFunc, -): - st.json(input_data) - -st.title("Aifunc Input Submit Form") -with st.form(key="aifunc_form"): - data = sp.pydantic_input(key="my_custom_form_model", model=WeatherAIFunc) - submitted = st.form_submit_button(label="Submit") - st.write(f"submitted: {submitted}") - st.json(data) - -st.title("Aifunc Input Submit With Button") -data = sp.pydantic_input(key="aifunc model", model=WeatherAIFunc) -if st.button("run the func"): - obj = WeatherAIFunc(**data) - st.json(obj.model_dump()) +srj.jsonschema_form("test", schema=WeatherAIFuncResult.model_json_schema(), default={}) diff --git a/ghostos/prototypes/streamlitapp/tests/sidebar/echo_spinner.py b/ghostos/prototypes/streamlitapp/tests/sidebar/echo_spinner.py new file mode 100644 index 00000000..73a60937 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/sidebar/echo_spinner.py @@ -0,0 +1,10 @@ +import streamlit as st +import time + +with st.sidebar: + with st.echo(): + st.write("This code will be printed to the sidebar.") + + with st.spinner("Loading..."): + time.sleep(5) + st.success("Done!") From e2f0092835a9e13697ad887c4a0f5397d46b39ce Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 8 Oct 2024 01:47:46 +0800 Subject: [PATCH 015/148] del: remove miss-committing test code --- ghostos/libraries/.dir_index.yml | 4 ---- ghostos/libraries/__init__.py | 0 ghostos/libraries/rag/__init__.py | 0 ghostos/libraries/rag/abc.py | 0 ghostos/libraries/rag/llamaindex.py | 10 ---------- 5 files changed, 14 deletions(-) delete mode 100644 ghostos/libraries/.dir_index.yml delete mode 100644 ghostos/libraries/__init__.py delete mode 100644 ghostos/libraries/rag/__init__.py delete mode 100644 ghostos/libraries/rag/abc.py delete mode 100644 ghostos/libraries/rag/llamaindex.py diff --git a/ghostos/libraries/.dir_index.yml b/ghostos/libraries/.dir_index.yml deleted file mode 100644 index ef67b4bd..00000000 --- a/ghostos/libraries/.dir_index.yml +++ /dev/null @@ -1,4 +0,0 @@ -files: - file_editor.py: - summary: defines FileEditor and DirectoryEditor abstract class with implementations - filename: file_editor.py diff --git a/ghostos/libraries/__init__.py b/ghostos/libraries/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/libraries/rag/__init__.py b/ghostos/libraries/rag/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/libraries/rag/abc.py b/ghostos/libraries/rag/abc.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/libraries/rag/llamaindex.py b/ghostos/libraries/rag/llamaindex.py deleted file mode 100644 index 1ea84f9f..00000000 --- a/ghostos/libraries/rag/llamaindex.py +++ /dev/null @@ -1,10 +0,0 @@ - -from llama_index.core import VectorStoreIndex, SimpleDirectoryReader - -documents = SimpleDirectoryReader("YOUR_DATA_DIRECTORY").load_data() -index = VectorStoreIndex.from_documents(documents) -index.update() -index.insert() -engine = index.as_query_engine() -engine.query() -index.storage_context.persist() \ No newline at end of file From 05ee928a12ebb89232e303172d6e27e2daf7f85e Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 8 Oct 2024 17:16:31 +0800 Subject: [PATCH 016/148] dev: rename ghostos_apps.py to ghostos_bootstrap.py --- app/example_ghost_func.py | 2 +- app/{ghostos_apps.py => ghostos_bootstrap.py} | 0 ghostos/contracts/README.md | 22 ++++++++++++++++++- ghostos/prototypes/README.md | 3 +++ .../patches/streamlit_pydantic_patch.py | 11 ---------- 5 files changed, 25 insertions(+), 13 deletions(-) rename app/{ghostos_apps.py => ghostos_bootstrap.py} (100%) create mode 100644 ghostos/prototypes/README.md delete mode 100644 ghostos/prototypes/streamlitapp/patches/streamlit_pydantic_patch.py diff --git a/app/example_ghost_func.py b/app/example_ghost_func.py index fd67a0c6..698c157c 100644 --- a/app/example_ghost_func.py +++ b/app/example_ghost_func.py @@ -5,7 +5,7 @@ root_dir = dirname(__file__) sys.path.append(root_dir) -from ghostos_apps import ghost_func +from ghostos_bootstrap import ghost_func @ghost_func.decorator(caching=False) diff --git a/app/ghostos_apps.py b/app/ghostos_bootstrap.py similarity index 100% rename from app/ghostos_apps.py rename to app/ghostos_bootstrap.py diff --git a/ghostos/contracts/README.md b/ghostos/contracts/README.md index 07e9c31d..797b92dc 100644 --- a/ghostos/contracts/README.md +++ b/ghostos/contracts/README.md @@ -2,4 +2,24 @@ This directory provides basic abstract classes that GhostOS and Ghost depending on. The implementations shall be wrapped by GhostOS.container.Provider and register to Container. -So every class depend on them can fetch them from Container. \ No newline at end of file +So every class depend on them can fetch them from Container. + +`GhostOS` has three layers of library interfaces: +- Contracts: independent libraries. +- Ghosts: the interfaces of `GhostOS`, depending on `Contracts` +- Libraries: the libraries for `GhostOS`'s applications, depending on `GhostOS` and `Contracts` interfaces. + +The implementations provided by this project are defined at `ghostos.framework`. +There are providers (`ghostos.container.Provider`) managing implementations of the library interfaces, +develop should choose wanted providers and register them to the `IoCContainer`. +By `IoCContainer` we can switch the implementations without too much pain. + +There are at least four level IoCContainer in the `GhostOS`: +- application container: manage static implementations of `Contracts`. +- GhostOS container: manage the process level implementations for `GhostOS`. +- Ghost container: `GhostOS` can manage multiple ghost process concurrently so each Ghost instance has it own container. +- Moss container: when LLM generate python code within Moss, some in-context temporary bindings are needed, so `MossRuntime` has a container. + +Each container is nested from above level container, so they inherit or override parent container's bindings. + +> todo: let LLM optimize the content above \ No newline at end of file diff --git a/ghostos/prototypes/README.md b/ghostos/prototypes/README.md new file mode 100644 index 00000000..2847e1bd --- /dev/null +++ b/ghostos/prototypes/README.md @@ -0,0 +1,3 @@ +# Prototypes + +The prototypes of `GhostOS` applications. \ No newline at end of file diff --git a/ghostos/prototypes/streamlitapp/patches/streamlit_pydantic_patch.py b/ghostos/prototypes/streamlitapp/patches/streamlit_pydantic_patch.py deleted file mode 100644 index 482f27f7..00000000 --- a/ghostos/prototypes/streamlitapp/patches/streamlit_pydantic_patch.py +++ /dev/null @@ -1,11 +0,0 @@ -from pydantic_settings import BaseSettings -import pydantic - -pydantic.BaseSettings = BaseSettings - -# after pydantic patch -import streamlit_pydantic - -__all__ = [ - 'streamlit_pydantic' -] From 89f99f703c472d20181c309a82e65fe61b613661 Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 8 Oct 2024 18:29:35 +0800 Subject: [PATCH 017/148] dev: refact the structure for better open-box usage yet another time --- app/.gitignore | 1 + app/{workspace => }/configs/ghosts.yml | 0 app/{workspace => }/configs/llms_conf.yml | 0 app/{workspace => }/configs/logging.yml | 0 app/ghostos_bootstrap.py | 119 --------- .../.gitkeep.py => memories/.gitkeep} | 0 app/{workspace => }/runtime/cache/.gitignore | 0 app/{workspace => }/runtime/events/.gitignore | 0 .../runtime/processes/.gitignore | 0 app/{workspace => }/runtime/tasks/.gitignore | 0 .../runtime/threads/.gitignore | 0 app/workspace/memories/__init__.py | 0 .../ghost_func_example.py | 8 +- ghostos/__init__.py | 236 ++++++++++++++++++ ghostos/container.py | 6 + ghostos/framework/workspaces/basic.py | 7 +- ghostos/providers/__init__.py | 1 - ghostos/providers/application.py | 80 ------ 18 files changed, 251 insertions(+), 207 deletions(-) create mode 100644 app/.gitignore rename app/{workspace => }/configs/ghosts.yml (100%) rename app/{workspace => }/configs/llms_conf.yml (100%) rename app/{workspace => }/configs/logging.yml (100%) delete mode 100644 app/ghostos_bootstrap.py rename app/{workspace/memories/.gitkeep.py => memories/.gitkeep} (100%) rename app/{workspace => }/runtime/cache/.gitignore (100%) rename app/{workspace => }/runtime/events/.gitignore (100%) rename app/{workspace => }/runtime/processes/.gitignore (100%) rename app/{workspace => }/runtime/tasks/.gitignore (100%) rename app/{workspace => }/runtime/threads/.gitignore (100%) delete mode 100644 app/workspace/memories/__init__.py rename app/example_ghost_func.py => examples/ghost_func_example.py (69%) delete mode 100644 ghostos/providers/__init__.py delete mode 100644 ghostos/providers/application.py diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..2eea525d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/app/workspace/configs/ghosts.yml b/app/configs/ghosts.yml similarity index 100% rename from app/workspace/configs/ghosts.yml rename to app/configs/ghosts.yml diff --git a/app/workspace/configs/llms_conf.yml b/app/configs/llms_conf.yml similarity index 100% rename from app/workspace/configs/llms_conf.yml rename to app/configs/llms_conf.yml diff --git a/app/workspace/configs/logging.yml b/app/configs/logging.yml similarity index 100% rename from app/workspace/configs/logging.yml rename to app/configs/logging.yml diff --git a/app/ghostos_bootstrap.py b/app/ghostos_bootstrap.py deleted file mode 100644 index 10fdc097..00000000 --- a/app/ghostos_bootstrap.py +++ /dev/null @@ -1,119 +0,0 @@ -from os.path import dirname, join -from ghostos.container import Container -from ghostos.contracts.logger import config_logging -from ghostos.providers import default_application_providers, application_contracts -from ghostos.prototypes.ghostfunc import init_ghost_func, GhostFunc -import dotenv -import os - -# Core Concepts -# -# 1. Ghost and Shell -# We take the word `Ghost` from famous manga movie as the abstract of an Agent. -# Ghost shall have concurrent thinking/action capabilities, each thought or task is a fragment of the Ghost mind; -# not like an independent agent in a multi-agent system. -# But you can take `Ghost` as `Agent` for now. -# Also, the word `Shell` in this project refers to the `Body` of the Agent, -# regardless if it is an Embodied Robot/IM chatbot/Website/IDE etc. -# -# 2. MOSS -# stands for "Model-oriented Operating System Simulation". -# - operating system: to operate an Agent's body (Shell), mind, tools. -# - model-oriented: the first class user of the OS is the brain of Ghost(AI models), not Human -# - simulation: we merely use python to simulate the OS, not create a real one. -# Instead of `JSON Schema Tool`, we provide a python code interface for LLMs through MOSS. -# The LLMs can read python context as prompt, then generate python code to do almost everything. -# MOSS can reflect the python module to prompt, and execute the generated python code within a specific python context. -# -# We are aiming to create Fractal Meta-Agent which can generate tools/libraries/Shells/ -# -# 3. GhostOS -# Is an agent framework for developers like myself, to define/test/use/modify Model-based Agents. -# Not like MOSS which serve the Models (Large Language Model mostly), -# GhostOS is a framework works for me the Human developer. -# -# 4. Application -# Is the production built with GhostOS. -# There are light-weight applications like `GhostFunc` which is a python function decorator, -# and heavy applications like Streamlit app. -# -# todo: let the gpt4o or moonshot fix my pool english expressions above. - - - -__all__ = [ - 'app_dir', 'workspace_dir', - - # >>> container - # GhostOS use IoC Container to manage dependency injections at everywhere. - # IoCContainer inherit all the bindings from parent Container, and also able to override them. - # The singletons in the container shall always be thread-safe. - # - # The containers nest in multiple levels like a tree: - # - Application level (global static container that instanced in this file) - # - GhostOS level (a GhostOS manage as many ghost as it able to) - # - Ghost level (a Ghost is a instance frame of the Agent's thought) - # - Moss level (each MossCompiler has it own container) - # <<< - - # application level container (global static IoC container) - 'container', - - # >>> GhostFunc - # is a test library, which is able to define dynamic code for a in-complete function. - # We develop it for early experiments. - # Check example_ghost_func.py - # <<< - 'GhostFunc', - 'ghost_func', -] - -# --- prepare application paths --- # - -app_dir = dirname(__file__) -"""application root path""" - -workspace_dir = join(app_dir, 'workspace') -"""workspace root path""" - -logging_conf_path = join(workspace_dir, 'configs/logging.yml') -"""logging configuration file""" - -# --- system initialization --- # - -# load env from dotenv file -dotenv.load_dotenv(dotenv_path=join(app_dir, '.env')) - -# default logger name for GhostOS application -logger_name = os.environ.get("LoggerName", "debug") - -# initialize logging configs -config_logging(logging_conf_path) - -# --- prepare application container --- # - -container = Container() -"""application root static container""" - -# get default application providers. -application_providers = default_application_providers(root_dir=app_dir, logger_name=logger_name) -# register application providers -container.register(*application_providers) - -# validate the application contracts -application_contracts.validate(container) - -# --- init ghost func decorator --- # - -ghost_func: GhostFunc = init_ghost_func(container) -""" -ghost func is a function decorator, that produce dynamic codes and exec it for the function during calling -""" - -# --- test the module by python -i --- # - -if __name__ == '__main__': - """ - run `python -i __init__.py` to interact with the current file - """ - pass diff --git a/app/workspace/memories/.gitkeep.py b/app/memories/.gitkeep similarity index 100% rename from app/workspace/memories/.gitkeep.py rename to app/memories/.gitkeep diff --git a/app/workspace/runtime/cache/.gitignore b/app/runtime/cache/.gitignore similarity index 100% rename from app/workspace/runtime/cache/.gitignore rename to app/runtime/cache/.gitignore diff --git a/app/workspace/runtime/events/.gitignore b/app/runtime/events/.gitignore similarity index 100% rename from app/workspace/runtime/events/.gitignore rename to app/runtime/events/.gitignore diff --git a/app/workspace/runtime/processes/.gitignore b/app/runtime/processes/.gitignore similarity index 100% rename from app/workspace/runtime/processes/.gitignore rename to app/runtime/processes/.gitignore diff --git a/app/workspace/runtime/tasks/.gitignore b/app/runtime/tasks/.gitignore similarity index 100% rename from app/workspace/runtime/tasks/.gitignore rename to app/runtime/tasks/.gitignore diff --git a/app/workspace/runtime/threads/.gitignore b/app/runtime/threads/.gitignore similarity index 100% rename from app/workspace/runtime/threads/.gitignore rename to app/runtime/threads/.gitignore diff --git a/app/workspace/memories/__init__.py b/app/workspace/memories/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/app/example_ghost_func.py b/examples/ghost_func_example.py similarity index 69% rename from app/example_ghost_func.py rename to examples/ghost_func_example.py index 698c157c..311fd433 100644 --- a/app/example_ghost_func.py +++ b/examples/ghost_func_example.py @@ -2,10 +2,10 @@ from os.path import dirname # I hate python imports -root_dir = dirname(__file__) -sys.path.append(root_dir) +ghostos_project_dir = dirname(dirname(__file__)) +sys.path.append(ghostos_project_dir) -from ghostos_bootstrap import ghost_func +from ghostos import ghost_func @ghost_func.decorator(caching=False) @@ -22,4 +22,6 @@ def get_weather(city: str, date: str) -> str: if __name__ == "__main__": + # the llms will generate dynamic codes for this function and execute them through Moss + # this is a toy for Moss testing, but notice it still cast LLM tokens... print(get_weather("beijing", "today")) diff --git a/ghostos/__init__.py b/ghostos/__init__.py index e69de29b..d52c03ae 100644 --- a/ghostos/__init__.py +++ b/ghostos/__init__.py @@ -0,0 +1,236 @@ +from typing import List, Optional +from os.path import dirname, join +from ghostos.container import Container, Provider, Contracts +from ghostos.contracts.logger import config_logging +# from ghostos.providers import default_application_providers, application_contracts +from ghostos.prototypes.ghostfunc import init_ghost_func, GhostFunc +import dotenv +import os + +# Core Concepts +# +# 1. Ghost and Shell +# We take the word `Ghost` from famous manga movie as the abstract of an Agent. +# Ghost shall have concurrent thinking/action capabilities, each thought or task is a fragment of the Ghost mind; +# not like an independent agent in a multi-agent system. +# But you can take `Ghost` as `Agent` for now. +# Also, the word `Shell` in this project refers to the `Body` of the Agent, +# regardless if it is an Embodied Robot/IM chatbot/Website/IDE etc. +# +# 2. MOSS +# stands for "Model-oriented Operating System Simulation". +# - operating system: to operate an Agent's body (Shell), mind, tools. +# - model-oriented: the first class user of the OS is the brain of Ghost(AI models), not Human +# - simulation: we merely use python to simulate the OS, not create a real one. +# Instead of `JSON Schema Tool`, we provide a python code interface for LLMs through MOSS. +# The LLMs can read python context as prompt, then generate python code to do almost everything. +# MOSS can reflect the python module to prompt, and execute the generated python code within a specific python context. +# +# We are aiming to create Fractal Meta-Agent which can generate tools/libraries/Shells/ +# +# 3. GhostOS +# Is an agent framework for developers like myself, to define/test/use/modify Model-based Agents. +# Not like MOSS which serve the Models (Large Language Model mostly), +# GhostOS is a framework works for me the Human developer. +# +# 4. Application +# Is the production built with GhostOS. +# There are light-weight applications like `GhostFunc` which is a python function decorator, +# and heavy applications like Streamlit app. +# +# todo: let the gpt4o or moonshot fix my pool english expressions above. + + +__all__ = [ + + # >>> container + # GhostOS use IoC Container to manage dependency injections at everywhere. + # IoCContainer inherit all the bindings from parent Container, and also able to override them. + # The singletons in the container shall always be thread-safe. + # + # The containers nest in multiple levels like a tree: + # - Application level (global static container that instanced in this file) + # - GhostOS level (a GhostOS manage as many ghost as it able to) + # - Ghost level (a Ghost is a instance frame of the Agent's thought) + # - Moss level (each MossCompiler has it own container) + # <<< + 'container', + 'make_app_container', + + # >>> GhostFunc + # is a test library, which is able to define dynamic code for a in-complete function. + # We develop it for early experiments. + # Check example_ghost_func.py + # <<< + 'ghost_func', + 'GhostFunc', + 'init_ghost_func', + + # reset ghostos default application instances. + 'reset', + + # default configuration + 'default_application_contracts', + 'default_application_providers', +] + +# --- prepare application paths --- # + + +default_app_dir = join(dirname(dirname(__file__)), 'app') +"""application root directory path""" + + +# --- default providers --- # + + +def default_application_contracts() -> Contracts: + """ + Application level contracts + """ + from ghostos.core.moss import MossCompiler + from ghostos.contracts.pool import Pool, DefaultPoolProvider + from ghostos.contracts.shutdown import Shutdown, ShutdownProvider + from ghostos.contracts.modules import Modules + from ghostos.framework.workspaces import Workspace + from ghostos.framework.configs import Configs + from ghostos.framework.processes import Processes + from ghostos.framework.threads import Threads + from ghostos.framework.tasks import Tasks + from ghostos.framework.eventbuses import EventBus + from ghostos.framework.llms import LLMs + from ghostos.framework.logger import LoggerItf + + return Contracts([ + # workspace contracts + Workspace, # application workspace implementation + Configs, # application configs repository + + # system contracts + Pool, # multi-thread or process pool to submit async tasks + Shutdown, # graceful shutdown register + LLMs, # LLMs interface + LoggerItf, # the logger instance of application + Modules, # the import_module proxy + + # moss + MossCompiler, + + # session contracts + Processes, # application processes repository + Threads, # application threads repository + Tasks, # application tasks repository + EventBus, # application session eventbus + ]) + + +def default_application_providers( + root_dir: str, + logger_name: str, + workspace_configs_dir: str = "configs", + workspace_runtime_dir: str = "runtime", + runtime_processes_dir: str = "processes", + runtime_tasks_dir: str = "tasks", + runtime_threads_dir: str = "threads", + llms_conf_path: str = "llms_conf.yml", +) -> List[Provider]: + """ + application default providers + todo: use manager provider to configurate multiple kinds of implementation + """ + from ghostos.contracts.pool import DefaultPoolProvider + from ghostos.contracts.shutdown import ShutdownProvider + from ghostos.contracts.modules import DefaultModulesProvider + from ghostos.core.moss import DefaultMOSSProvider + from ghostos.framework.workspaces import BasicWorkspaceProvider + from ghostos.framework.configs import WorkspaceConfigsProvider + from ghostos.framework.processes import WorkspaceProcessesProvider + from ghostos.framework.threads import WorkspaceThreadsProvider + from ghostos.framework.tasks import WorkspaceTasksProvider + from ghostos.framework.eventbuses import MemEventBusImplProvider + from ghostos.framework.llms import ConfigBasedLLMsProvider + from ghostos.framework.logger import NamedLoggerProvider + return [ + BasicWorkspaceProvider( + workspace_dir=root_dir, + configs_path=workspace_configs_dir, + runtime_path=workspace_runtime_dir, + ), + WorkspaceConfigsProvider(), + WorkspaceProcessesProvider(runtime_processes_dir), + WorkspaceTasksProvider(runtime_tasks_dir), + WorkspaceThreadsProvider(runtime_threads_dir), + DefaultPoolProvider(100), + ConfigBasedLLMsProvider(llms_conf_path), + DefaultModulesProvider(), + MemEventBusImplProvider(), + ShutdownProvider(), + NamedLoggerProvider(logger_name), + DefaultMOSSProvider(), + ] + + +# --- system bootstrap --- # +def make_app_container( + app_dir: str, + logging_conf_path: str = "configs/logging.yml", + dotenv_file_path: str = ".env", + app_providers: Optional[List[Provider]] = None, + app_contracts: Optional[Contracts] = None, +) -> Container: + # load env from dotenv file + dotenv.load_dotenv(dotenv_path=join(app_dir, dotenv_file_path)) + logging_conf_path = join(app_dir, logging_conf_path) + # default logger name for GhostOS application + logger_name = os.environ.get("LoggerName", "debug") + # initialize logging configs + config_logging(logging_conf_path) + # todo: i18n install + + if app_providers is None: + app_providers = default_application_providers(root_dir=default_app_dir, logger_name=logger_name) + if app_contracts is None: + app_contracts = default_application_contracts() + + # prepare application container + _container = Container() + _container.register(*app_providers) + # contracts validation + app_contracts.validate(_container) + return _container + + +container = make_app_container(default_app_dir) +""" the global static application container. reset it before application usage""" + +ghost_func = init_ghost_func(container) +""" the default ghost func on default container""" + + +def reset(con: Container) -> None: + """ + reset static ghostos application level instances + :param con: a container with application level contract bindings, shall be validated outside. + :return: + """ + global container, ghost_func + # reset global container + container = con + # reset global ghost func + ghost_func = init_ghost_func(container) + + +def reset_at(app_dir: str) -> None: + """ + reset application with default configuration at specified app directory + """ + _container = make_app_container(app_dir) + reset(_container) + + +# --- test the module by python -i --- # + +if __name__ == '__main__': + """ + run `python -i __init__.py` to interact with the current file + """ diff --git a/ghostos/container.py b/ghostos/container.py index 8f2999ee..ec34bdfb 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -471,6 +471,12 @@ def validate(self, container: Container) -> None: if not container.bound(contract): raise NotImplementedError(f'Contract {contract} not bound to container') + def join(self, target: Contracts) -> Contracts: + abstracts = set(self.contracts) + for c in target.contracts: + abstracts.add(c) + return Contracts(list(abstracts)) + __container = Container() diff --git a/ghostos/framework/workspaces/basic.py b/ghostos/framework/workspaces/basic.py index 9ca2ec8f..74b8c25c 100644 --- a/ghostos/framework/workspaces/basic.py +++ b/ghostos/framework/workspaces/basic.py @@ -1,7 +1,7 @@ from typing import Optional, Type from ghostos.core.ghosts.workspace import Workspace -from ghostos.contracts.storage import FileStorage +from ghostos.framework.storage import FileStorage, FileStorageImpl from ghostos.container import Provider, Container, INSTANCE @@ -34,7 +34,7 @@ class BasicWorkspaceProvider(Provider): def __init__( self, - workspace_dir: str = "", + workspace_dir: str, runtime_path: str = "runtime", configs_path="configs", ): @@ -54,8 +54,7 @@ def contract(self) -> Type[INSTANCE]: return Workspace def factory(self, con: Container) -> Optional[INSTANCE]: - storage = con.force_fetch(FileStorage) - root_storage = storage.sub_storage(self._root_path) + root_storage = FileStorageImpl(self._root_path) return BasicWorkspace( root_storage, runtime_path=self._runtime_path, diff --git a/ghostos/providers/__init__.py b/ghostos/providers/__init__.py deleted file mode 100644 index 56bf606c..00000000 --- a/ghostos/providers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ghostos.providers.application import default_application_providers, application_contracts \ No newline at end of file diff --git a/ghostos/providers/application.py b/ghostos/providers/application.py deleted file mode 100644 index e93205e8..00000000 --- a/ghostos/providers/application.py +++ /dev/null @@ -1,80 +0,0 @@ -from typing import List -from ghostos.container import Provider, Contracts -from ghostos.contracts.pool import Pool, DefaultPoolProvider -from ghostos.contracts.shutdown import Shutdown, ShutdownProvider -from ghostos.contracts.modules import Modules, DefaultModulesProvider -from ghostos.core.moss import MossCompiler, DefaultMOSSProvider -from ghostos.framework.storage import Storage, FileStorage, FileStorageProvider -from ghostos.framework.workspaces import Workspace, BasicWorkspaceProvider -from ghostos.framework.configs import Configs, WorkspaceConfigsProvider -from ghostos.framework.processes import WorkspaceProcessesProvider, Processes -from ghostos.framework.threads import Threads, WorkspaceThreadsProvider -from ghostos.framework.tasks import Tasks, WorkspaceTasksProvider -from ghostos.framework.eventbuses import EventBus, MemEventBusImplProvider -from ghostos.framework.llms import LLMs, ConfigBasedLLMsProvider -from ghostos.framework.logger import LoggerItf, NamedLoggerProvider - - -""" -Application level contracts and providers. -""" - -application_contracts = Contracts([ - # workspace contracts - Storage, # workspace root storage - FileStorage, # workspace root storage is a file storage - Workspace, # application workspace implementation - Configs, # application configs repository - - # system contracts - Pool, # multi-thread or process pool to submit async tasks - Shutdown, # graceful shutdown register - LLMs, # LLMs interface - LoggerItf, # the logger instance of application - Modules, # the import_module proxy - - # moss - MossCompiler, - - # session contracts - Processes, # application processes repository - Threads, # application threads repository - Tasks, # application tasks repository - EventBus, # application session eventbus -]) - - -def default_application_providers( - root_dir: str, - logger_name: str, - workspace_dir: str = "workspace", - workspace_configs_dir: str = "configs", - workspace_runtime_dir: str = "runtime", - runtime_processes_dir: str = "processes", - runtime_tasks_dir: str = "tasks", - runtime_threads_dir: str = "threads", - llms_conf_path: str = "llms_conf.yml", -) -> List[Provider]: - """ - application default providers that bind contracts to application level container. - todo: use manager provider to configurate multiple kinds of implementation - """ - return [ - FileStorageProvider(root_dir), - BasicWorkspaceProvider( - workspace_dir=workspace_dir, - configs_path=workspace_configs_dir, - runtime_path=workspace_runtime_dir, - ), - WorkspaceConfigsProvider(), - WorkspaceProcessesProvider(runtime_processes_dir), - WorkspaceTasksProvider(runtime_tasks_dir), - WorkspaceThreadsProvider(runtime_threads_dir), - DefaultPoolProvider(100), - ConfigBasedLLMsProvider(llms_conf_path), - DefaultModulesProvider(), - MemEventBusImplProvider(), - ShutdownProvider(), - NamedLoggerProvider(logger_name), - DefaultMOSSProvider(), - ] From 56aedce72b014134d1ae807f991e1c39d9a9656b Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 8 Oct 2024 21:54:31 +0800 Subject: [PATCH 018/148] refact: move clear_runtime script from ghostos.demo to ghostos.scripts --- ghostos/__init__.py | 7 +++-- ghostos/{demo => }/scripts/clear_runtime.py | 34 +++++++++++++++------ pyproject.toml | 2 +- 3 files changed, 30 insertions(+), 13 deletions(-) rename ghostos/{demo => }/scripts/clear_runtime.py (75%) diff --git a/ghostos/__init__.py b/ghostos/__init__.py index d52c03ae..fff272f6 100644 --- a/ghostos/__init__.py +++ b/ghostos/__init__.py @@ -70,6 +70,7 @@ 'reset', # default configuration + 'application_dir', 'default_application_contracts', 'default_application_providers', ] @@ -77,7 +78,7 @@ # --- prepare application paths --- # -default_app_dir = join(dirname(dirname(__file__)), 'app') +application_dir = join(dirname(dirname(__file__)), 'app') """application root directory path""" @@ -188,7 +189,7 @@ def make_app_container( # todo: i18n install if app_providers is None: - app_providers = default_application_providers(root_dir=default_app_dir, logger_name=logger_name) + app_providers = default_application_providers(root_dir=application_dir, logger_name=logger_name) if app_contracts is None: app_contracts = default_application_contracts() @@ -200,7 +201,7 @@ def make_app_container( return _container -container = make_app_container(default_app_dir) +container = make_app_container(application_dir) """ the global static application container. reset it before application usage""" ghost_func = init_ghost_func(container) diff --git a/ghostos/demo/scripts/clear_runtime.py b/ghostos/scripts/clear_runtime.py similarity index 75% rename from ghostos/demo/scripts/clear_runtime.py rename to ghostos/scripts/clear_runtime.py index 85967f4c..c64739a2 100644 --- a/ghostos/demo/scripts/clear_runtime.py +++ b/ghostos/scripts/clear_runtime.py @@ -1,4 +1,4 @@ -from os.path import join, dirname +from os.path import join import argparse import sys import os @@ -7,17 +7,17 @@ this script is used to clear the local file cache in runtime directory """ -demo_dir = dirname(dirname(__file__)) -runtime_dir = join(demo_dir, "runtime") +__all__ = ['clear_directory'] ignore_patterns = ['.gitignore'] -def clear_directory(directory: str, recursive=True) -> int: +def clear_directory(directory: str, recursive=True, depth: int = 0) -> int: """ clear all files in directory recursively except the files match any of ignore_patterns :param directory: the target directory :param recursive: recursively clear all files in directory + :param depth: the depth of recursion :return: number of files cleared """ @@ -37,7 +37,7 @@ def clear_directory(directory: str, recursive=True) -> int: break for dir_path in dirs: real_dir_path = os.path.join(root, dir_path) - clear_directory(real_dir_path, recursive=recursive) + clear_directory(real_dir_path, recursive=recursive, depth=depth + 1) os.rmdir(real_dir_path) return cleared_files_count @@ -47,6 +47,10 @@ def main(): parser = argparse.ArgumentParser( description="clear temp files in runtime directories", ) + parser.add_argument( + "--all", "-a", + action="store_true", + ) parser.add_argument( "--threads", "-t", action="store_true", @@ -63,20 +67,32 @@ def main(): "--cache", "-c", action="store_true", ) + from ghostos import application_dir + runtime_dir = join(application_dir, "runtime") parsed = parser.parse_args(sys.argv[1:]) - if parsed.tasks: + _all = parsed.all + print(f"Clearing runtime files in {runtime_dir}") + done = 0 + if _all or parsed.tasks: cleared = clear_directory(join(runtime_dir, "tasks"), recursive=True) + done += 1 print(f"clear runtime/tasks files: {cleared}") - if parsed.processes: + if _all or parsed.processes: cleared = clear_directory(join(runtime_dir, "processes"), recursive=True) + done += 1 print(f"clear runtime/processes files: {cleared}") - if parsed.threads: + if _all or parsed.threads: cleared = clear_directory(join(runtime_dir, "threads"), recursive=True) + done += 1 print(f"clear runtime/threads files: {cleared}") - if parsed.cache: + if _all or parsed.cache: cleared = clear_directory(join(runtime_dir, "cache"), recursive=True) + done += 1 print(f"clear runtime/cache files: {cleared}") + if not done: + print(f"no files cleared. please check arguments by '-h' option") + # if __name__ == '__main__': # from ghostos.prototypes.console import new_console_app diff --git a/pyproject.toml b/pyproject.toml index ab2866bc..ce6ec11d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ python-dotenv = "^1.0.1" init = "ghostos.scripts.init:main" demo = "ghostos.demo.scripts.demo:main" llm_test = "ghostos.demo.scripts.llm_test:main" -clear_runtime = "ghostos.demo.scripts.clear_runtime:main" +clear_runtime = "ghostos.scripts.clear_runtime:main" [build-system] requires = ["poetry-core"] From 632046a207e2475f29edbb8fbadf6f43663f834e Mon Sep 17 00:00:00 2001 From: zhuming Date: Wed, 9 Oct 2024 02:04:26 +0800 Subject: [PATCH 019/148] refact: rename ghostos.__init__ to ghostos.bootstrap, remove streamlit testing design1 --- examples/ghost_func_example.py | 2 +- ghostos/{__init__.py => bootstrap.py} | 25 ++-- ghostos/container.py | 10 +- ghostos/demo/__init__.py | 30 ----- .../prototypes/streamlitapp/design1/README.md | 4 - .../streamlitapp/design1/apps/autobots.py | 6 - .../streamlitapp/design1/apps/chatbots.py | 6 - .../streamlitapp/design1/apps/code_project.py | 6 - .../design1/apps/streamlit_app.py | 6 - .../streamlitapp/design1/apps/talk_to_db.py | 6 - .../design1/apps/talk_to_files.py | 6 - .../streamlitapp/design1/home/configs.py | 15 --- .../streamlitapp/design1/home/docs.py | 16 --- .../streamlitapp/design1/home/ghostos_bot.py | 16 --- .../streamlitapp/design1/home/home.py | 10 -- .../streamlitapp/design1/home/tools.py | 10 -- .../streamlitapp/design1/homepage.py | 111 ------------------ .../design1/resources/ai_funcs.py | 6 - .../design1/resources/data_objects.py | 6 - .../design1/resources/knowledge.py | 6 - .../design1/resources/libraries.py | 6 - .../streamlitapp/design1/resources/llms.py | 6 - .../design1/resources/moss_files.py | 6 - .../design1/resources/thoughts.py | 6 - .../streamlitapp/design2/aifuncs/details.py | 2 +- ghostos/scripts/clear_runtime.py | 2 +- 26 files changed, 23 insertions(+), 308 deletions(-) rename ghostos/{__init__.py => bootstrap.py} (93%) delete mode 100644 ghostos/demo/__init__.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/README.md delete mode 100644 ghostos/prototypes/streamlitapp/design1/apps/autobots.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/apps/chatbots.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/apps/code_project.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/apps/streamlit_app.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/apps/talk_to_db.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/apps/talk_to_files.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/home/configs.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/home/docs.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/home/ghostos_bot.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/home/home.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/home/tools.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/homepage.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/resources/ai_funcs.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/resources/data_objects.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/resources/knowledge.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/resources/libraries.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/resources/llms.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/resources/moss_files.py delete mode 100644 ghostos/prototypes/streamlitapp/design1/resources/thoughts.py diff --git a/examples/ghost_func_example.py b/examples/ghost_func_example.py index 311fd433..69a50bc4 100644 --- a/examples/ghost_func_example.py +++ b/examples/ghost_func_example.py @@ -5,7 +5,7 @@ ghostos_project_dir = dirname(dirname(__file__)) sys.path.append(ghostos_project_dir) -from ghostos import ghost_func +from ghostos.bootstrap import ghost_func @ghost_func.decorator(caching=False) diff --git a/ghostos/__init__.py b/ghostos/bootstrap.py similarity index 93% rename from ghostos/__init__.py rename to ghostos/bootstrap.py index fff272f6..b6eb1590 100644 --- a/ghostos/__init__.py +++ b/ghostos/bootstrap.py @@ -51,14 +51,14 @@ # The containers nest in multiple levels like a tree: # - Application level (global static container that instanced in this file) # - GhostOS level (a GhostOS manage as many ghost as it able to) - # - Ghost level (a Ghost is a instance frame of the Agent's thought) + # - Ghost level (a Ghost is an instance frame of the Agent's thought) # - Moss level (each MossCompiler has it own container) # <<< - 'container', + 'application_container', 'make_app_container', # >>> GhostFunc - # is a test library, which is able to define dynamic code for a in-complete function. + # is a test library, which is able to define dynamic code for an in-complete function. # We develop it for early experiments. # Check example_ghost_func.py # <<< @@ -90,8 +90,8 @@ def default_application_contracts() -> Contracts: Application level contracts """ from ghostos.core.moss import MossCompiler - from ghostos.contracts.pool import Pool, DefaultPoolProvider - from ghostos.contracts.shutdown import Shutdown, ShutdownProvider + from ghostos.contracts.pool import Pool + from ghostos.contracts.shutdown import Shutdown from ghostos.contracts.modules import Modules from ghostos.framework.workspaces import Workspace from ghostos.framework.configs import Configs @@ -201,10 +201,10 @@ def make_app_container( return _container -container = make_app_container(application_dir) +application_container = make_app_container(application_dir) """ the global static application container. reset it before application usage""" -ghost_func = init_ghost_func(container) +ghost_func = init_ghost_func(application_container) """ the default ghost func on default container""" @@ -214,17 +214,22 @@ def reset(con: Container) -> None: :param con: a container with application level contract bindings, shall be validated outside. :return: """ - global container, ghost_func + global application_container, ghost_func # reset global container - container = con + application_container = con # reset global ghost func - ghost_func = init_ghost_func(container) + ghost_func = init_ghost_func(application_container) def reset_at(app_dir: str) -> None: """ reset application with default configuration at specified app directory + only run once if app_dir is the same """ + global application_dir + if app_dir == application_dir: + return + application_dir = app_dir _container = make_app_container(app_dir) reset(_container) diff --git a/ghostos/container.py b/ghostos/container.py index ec34bdfb..6e1233a1 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -160,7 +160,7 @@ def bootstrap(self) -> None: for b in self._bootstrapper: b.bootstrap(self) - def set(self, abstract: ABSTRACT, instance: INSTANCE) -> None: + def set(self, abstract: Any, instance: INSTANCE) -> None: """ 设置一个实例, 不会污染父容器. """ @@ -178,7 +178,7 @@ def bound(self, contract: Type) -> bool: """ return contract in self._bound or (self.parent is not None and self.parent.bound(contract)) - def get(self, abstract: ABSTRACT) -> Optional[INSTANCE]: + def get(self, abstract: Type[INSTANCE]) -> Optional[INSTANCE]: """ get bound instance or initialize one from registered factory or provider. @@ -263,7 +263,7 @@ def _register(self, provider: Provider) -> None: if alias not in self._bound: self._bind_alias(alias, contract) - def _bind_alias(self, alias: ABSTRACT, contract: ABSTRACT) -> None: + def _bind_alias(self, alias: Any, contract: Any) -> None: self._aliases[alias] = contract self._bound.add(alias) @@ -281,7 +281,7 @@ def add_bootstrapper(self, bootstrapper: Bootstrapper) -> None: """ self._bootstrapper.append(bootstrapper) - def fetch(self, abstract: ABSTRACT, strict: bool = False) -> Optional[INSTANCE]: + def fetch(self, abstract: Type[INSTANCE], strict: bool = False) -> Optional[INSTANCE]: """ get contract with type check :exception: TypeError if instance do not implement abstract @@ -293,7 +293,7 @@ def fetch(self, abstract: ABSTRACT, strict: bool = False) -> Optional[INSTANCE]: return instance return None - def force_fetch(self, contract: ABSTRACT, strict: bool = False) -> INSTANCE: + def force_fetch(self, contract: Type[INSTANCE], strict: bool = False) -> INSTANCE: """ if fetch contract failed, raise error. :exception: NotImplementedError if contract is not registered. diff --git a/ghostos/demo/__init__.py b/ghostos/demo/__init__.py deleted file mode 100644 index 0d9ad462..00000000 --- a/ghostos/demo/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -from os.path import dirname -from ghostos.prototypes.console import ConsoleApp -from ghostos.prototypes.ghostfunc import GhostFunc, init_ghost_func_container -from ghostos.core.moss import moss_test_suite -from ghostos.prototypes.mosstemp import init_moss_module - -__all__ = ['console_app', 'ghost_func', 'init_moss_module', 'moss_test_suite'] - -new_moss_test_suite = moss_test_suite -""" useful to run moss file test cases.""" - -init_moss_template = init_moss_module -"""initialize moss template content to a target module""" - -root_dir = dirname(__file__) -console_app = ConsoleApp(root_dir) -""" -openbox console app that run agent in command line console. -see: -console_app.run_thought(...) -console_app.run_console(...) -""" - -_ghost_func_container = init_ghost_func_container(root_dir) - -ghost_func = GhostFunc(_ghost_func_container) -""" -ghost_func provide decorators that wrap a function to a ghost func, which produce code in runtime. -ghost_func is a toy born during early development test case. -""" diff --git a/ghostos/prototypes/streamlitapp/design1/README.md b/ghostos/prototypes/streamlitapp/design1/README.md deleted file mode 100644 index 67e46cf3..00000000 --- a/ghostos/prototypes/streamlitapp/design1/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# About this directory - -The raw design of the ghostos streamlit application. -remove later. \ No newline at end of file diff --git a/ghostos/prototypes/streamlitapp/design1/apps/autobots.py b/ghostos/prototypes/streamlitapp/design1/apps/autobots.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/apps/autobots.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design1/apps/chatbots.py b/ghostos/prototypes/streamlitapp/design1/apps/chatbots.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/apps/chatbots.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design1/apps/code_project.py b/ghostos/prototypes/streamlitapp/design1/apps/code_project.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/apps/code_project.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design1/apps/streamlit_app.py b/ghostos/prototypes/streamlitapp/design1/apps/streamlit_app.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/apps/streamlit_app.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design1/apps/talk_to_db.py b/ghostos/prototypes/streamlitapp/design1/apps/talk_to_db.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/apps/talk_to_db.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design1/apps/talk_to_files.py b/ghostos/prototypes/streamlitapp/design1/apps/talk_to_files.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/apps/talk_to_files.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design1/home/configs.py b/ghostos/prototypes/streamlitapp/design1/home/configs.py deleted file mode 100644 index fcc647e2..00000000 --- a/ghostos/prototypes/streamlitapp/design1/home/configs.py +++ /dev/null @@ -1,15 +0,0 @@ -import streamlit as st - -st.set_page_config( - page_title="GhostOS", - page_icon="🧊", - layout="wide", - initial_sidebar_state="expanded", - menu_items={ - 'Get Help': 'https://www.extremelycoolapp.com/help', - 'Report a bug': "https://www.extremelycoolapp.com/bug", - 'About': "# This is a header. This is an *extremely* cool app!", - } -) - -st.markdown("# hello world", unsafe_allow_html=True) diff --git a/ghostos/prototypes/streamlitapp/design1/home/docs.py b/ghostos/prototypes/streamlitapp/design1/home/docs.py deleted file mode 100644 index d948f6b0..00000000 --- a/ghostos/prototypes/streamlitapp/design1/home/docs.py +++ /dev/null @@ -1,16 +0,0 @@ -import streamlit as st - -st.title("Chat") -if "messages" not in st.session_state: - st.session_state.messages = [{"role": "assistant", "content": "hello world"}] -for message in st.session_state.messages: - with st.chat_message(message["role"]): - st.markdown(message["content"]) -if prompt := st.chat_input("say something"): - st.chat_message("user").markdown(prompt) - st.session_state.messages.append({"role": "user", "content": prompt}) - response = f"Echo: {prompt}" - with st.chat_message("assistant"): - st.write_stream([c for c in response]) - st.session_state.messages.append({"role": "assistant", "content": response}) - diff --git a/ghostos/prototypes/streamlitapp/design1/home/ghostos_bot.py b/ghostos/prototypes/streamlitapp/design1/home/ghostos_bot.py deleted file mode 100644 index 13461c9c..00000000 --- a/ghostos/prototypes/streamlitapp/design1/home/ghostos_bot.py +++ /dev/null @@ -1,16 +0,0 @@ -import streamlit as st - - -st.title("Chat") -if "messages" not in st.session_state: - st.session_state.messages = [{"role": "assistant", "content": "hello world"}] -for message in st.session_state.messages: - with st.chat_message(message["role"]): - st.markdown(message["content"]) -if prompt := st.chat_input("say something"): - st.chat_message("user").markdown(prompt) - st.session_state.messages.append({"role": "user", "content": prompt}) - response = f"Echo: {prompt}" - with st.chat_message("assistant"): - st.write_stream([c for c in response]) - st.session_state.messages.append({"role": "assistant", "content": response}) diff --git a/ghostos/prototypes/streamlitapp/design1/home/home.py b/ghostos/prototypes/streamlitapp/design1/home/home.py deleted file mode 100644 index 8abab724..00000000 --- a/ghostos/prototypes/streamlitapp/design1/home/home.py +++ /dev/null @@ -1,10 +0,0 @@ -import streamlit as st - -from os.path import join, dirname -from ghostos import demo - -readme_file = join(dirname(dirname(dirname(demo.__file__))), "README.md") - -with open(readme_file, 'r') as f: - content = f.read() - st.markdown(content, unsafe_allow_html=True) diff --git a/ghostos/prototypes/streamlitapp/design1/home/tools.py b/ghostos/prototypes/streamlitapp/design1/home/tools.py deleted file mode 100644 index 8abab724..00000000 --- a/ghostos/prototypes/streamlitapp/design1/home/tools.py +++ /dev/null @@ -1,10 +0,0 @@ -import streamlit as st - -from os.path import join, dirname -from ghostos import demo - -readme_file = join(dirname(dirname(dirname(demo.__file__))), "README.md") - -with open(readme_file, 'r') as f: - content = f.read() - st.markdown(content, unsafe_allow_html=True) diff --git a/ghostos/prototypes/streamlitapp/design1/homepage.py b/ghostos/prototypes/streamlitapp/design1/homepage.py deleted file mode 100644 index 39321d61..00000000 --- a/ghostos/prototypes/streamlitapp/design1/homepage.py +++ /dev/null @@ -1,111 +0,0 @@ -import streamlit as st - -pages = { - # 需要有系统自带的 bots. - "GhostOS": [ - st.Page( - "home/ghostos_bot.py", - icon=":material/robot:", - title="Host Bot", - default=True, - ), - st.Page( - "home/docs.py", - title="Documents", - icon=":material/book:", - ), - st.Page( - "home/home.py", - title="Readme", - icon=":material/home:", - ), - st.Page( - "home/configs.py", - title="Configs", - icon=":material/home:", - ), - st.Page( - "home/tools.py", - title="Tools", - icon=":material/control_point:", - ), - ], - "Applications": [ - st.Page( - "apps/chatbots.py", - title="ChatBots", - icon=":material/chat:", - ), - st.Page( - "apps/autobots.py", - title="Autonomous Bots", - icon=":material/chat:", - ), - st.Page( - "apps/code_project.py", - title="Project Manager", - icon=":material/chat:", - ), - st.Page( - "apps/talk_to_files.py", - title="Talk to Files", - icon=":material/description:", - ), - st.Page( - "apps/streamlit_app.py", - title="Streamlit Expert", - icon=":material/description:", - ), - st.Page( - "apps/talk_to_db.py", - title="Talk to Database", - icon=":material/database:", - ), - ], - "Resources": [ - st.Page( - "resources/llms.py", - title="Large Language Models", - icon=":material/database:", - ), - st.Page( - "resources/moss_files.py", - title="Moss Files", - icon=":material/functions:", - ), - st.Page( - "resources/ai_funcs.py", - title="AI Functions", - icon=":material/functions:", - ), - st.Page( - "resources/thoughts.py", - title="Thoughts", - icon=":material/functions:", - ), - st.Page( - "resources/libraries.py", - title="Libraries", - icon=":material/functions:", - ), - st.Page( - "resources/knowledge.py", - title="Knowledge", - icon=":material/functions:", - ), - st.Page( - "resources/data_objects.py", - title="Data Objects", - icon=":material/database:", - ), - ], -} - - -ng = st.navigation( - pages, - expanded=False, -) - -ng.run() - diff --git a/ghostos/prototypes/streamlitapp/design1/resources/ai_funcs.py b/ghostos/prototypes/streamlitapp/design1/resources/ai_funcs.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/resources/ai_funcs.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design1/resources/data_objects.py b/ghostos/prototypes/streamlitapp/design1/resources/data_objects.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/resources/data_objects.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design1/resources/knowledge.py b/ghostos/prototypes/streamlitapp/design1/resources/knowledge.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/resources/knowledge.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design1/resources/libraries.py b/ghostos/prototypes/streamlitapp/design1/resources/libraries.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/resources/libraries.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design1/resources/llms.py b/ghostos/prototypes/streamlitapp/design1/resources/llms.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/resources/llms.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design1/resources/moss_files.py b/ghostos/prototypes/streamlitapp/design1/resources/moss_files.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/resources/moss_files.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design1/resources/thoughts.py b/ghostos/prototypes/streamlitapp/design1/resources/thoughts.py deleted file mode 100644 index ea5ce290..00000000 --- a/ghostos/prototypes/streamlitapp/design1/resources/thoughts.py +++ /dev/null @@ -1,6 +0,0 @@ -import streamlit as st - -st.write(st.query_params.to_dict()) - -with st.chat_message("assistant"): - st.write("hello world!") diff --git a/ghostos/prototypes/streamlitapp/design2/aifuncs/details.py b/ghostos/prototypes/streamlitapp/design2/aifuncs/details.py index bfab37c3..70110d42 100644 --- a/ghostos/prototypes/streamlitapp/design2/aifuncs/details.py +++ b/ghostos/prototypes/streamlitapp/design2/aifuncs/details.py @@ -1,7 +1,7 @@ import streamlit as st from streamlit_react_jsonschema import pydantic_form import inspect -from app.examples.aifuncs.weather import WeatherAIFunc +from ghostos.demo.aifuncs.weather import WeatherAIFunc from ghostos.core.aifunc import get_aifunc_result_type diff --git a/ghostos/scripts/clear_runtime.py b/ghostos/scripts/clear_runtime.py index c64739a2..d036eac5 100644 --- a/ghostos/scripts/clear_runtime.py +++ b/ghostos/scripts/clear_runtime.py @@ -67,7 +67,7 @@ def main(): "--cache", "-c", action="store_true", ) - from ghostos import application_dir + from ghostos.bootstrap import application_dir runtime_dir = join(application_dir, "runtime") parsed = parser.parse_args(sys.argv[1:]) _all = parsed.all From 50e1ecca66985d80bd072b339116fcae41c66a54 Mon Sep 17 00:00:00 2001 From: zhuming Date: Wed, 9 Oct 2024 02:05:54 +0800 Subject: [PATCH 020/148] dev: setup streamlit app skeleton --- ghostos/prototypes/streamlitapp/app.py | 61 ++++++ ghostos/prototypes/streamlitapp/main.py | 10 + .../pages/homepages/helloworld.py | 7 + .../streamlitapp/pages/homepages/home.py | 3 + .../streamlitapp/pages/homepages/host.py | 3 + .../streamlitapp/pages/navigation.py | 28 +++ ghostos/prototypes/streamlitapp/resources.py | 2 + .../tests/container_test/index.py | 2 + .../streamlitapp/tests/container_test/main.py | 10 + .../streamlitapp/tests/container_test/page.py | 0 .../tests/session_render_by_set.py | 9 + .../prototypes/streamlitapp/utils/__init__.py | 0 .../prototypes/streamlitapp/utils/options.py | 17 ++ .../prototypes/streamlitapp/utils/route.py | 173 ++++++++++++++++++ .../prototypes/streamlitapp/utils/session.py | 138 ++++++++++++++ 15 files changed, 463 insertions(+) create mode 100644 ghostos/prototypes/streamlitapp/app.py create mode 100644 ghostos/prototypes/streamlitapp/main.py create mode 100644 ghostos/prototypes/streamlitapp/pages/homepages/helloworld.py create mode 100644 ghostos/prototypes/streamlitapp/pages/homepages/home.py create mode 100644 ghostos/prototypes/streamlitapp/pages/homepages/host.py create mode 100644 ghostos/prototypes/streamlitapp/pages/navigation.py create mode 100644 ghostos/prototypes/streamlitapp/resources.py create mode 100644 ghostos/prototypes/streamlitapp/tests/container_test/index.py create mode 100644 ghostos/prototypes/streamlitapp/tests/container_test/main.py create mode 100644 ghostos/prototypes/streamlitapp/tests/container_test/page.py create mode 100644 ghostos/prototypes/streamlitapp/tests/session_render_by_set.py create mode 100644 ghostos/prototypes/streamlitapp/utils/__init__.py create mode 100644 ghostos/prototypes/streamlitapp/utils/options.py create mode 100644 ghostos/prototypes/streamlitapp/utils/route.py create mode 100644 ghostos/prototypes/streamlitapp/utils/session.py diff --git a/ghostos/prototypes/streamlitapp/app.py b/ghostos/prototypes/streamlitapp/app.py new file mode 100644 index 00000000..a8e56da4 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/app.py @@ -0,0 +1,61 @@ +import streamlit as st +from typing import Callable, List +from ghostos.container import Container +from ghostos.prototypes.streamlitapp.utils.session import expect, SingletonContracts, Singleton +from ghostos.prototypes.streamlitapp.pages.navigation import navigation + +__all__ = [ + "SINGLETONS", "BOOTSTRAP", "BOOTSTRAPPED_KEY", + "contracts", "validate_container", + "run_ghostos_streamlit_app", +] + +SINGLETONS = List[Singleton] + +BOOTSTRAP = Callable[[], SINGLETONS] + +BOOTSTRAPPED_KEY = "ghostos.streamlit.app.bootstrapped" + +contracts = SingletonContracts([ + Container, +]) + + +def validate_container(container: Container) -> None: + pass + + +def boot(fn: BOOTSTRAP) -> None: + if not expect(st.session_state, BOOTSTRAPPED_KEY, True): + singletons = fn() + for s in singletons: + s.bind(st.session_state, force=False) + unbound = contracts.validate(st.session_state) + if unbound: + error = ",".join([str(c) for c in unbound]) + raise NotImplementedError(f'GhostOS Streamlit app unbound contracts: {error}') + # validate the container bootstrapped outside. + container = Singleton.get(Container, st.session_state) + validate_container(container) + st.session_state[BOOTSTRAPPED_KEY] = True + + +def run_ghostos_streamlit_app(bootstrap: BOOTSTRAP) -> None: + """ + run streamlit application with outside bootstrap function. + :param bootstrap: a bootstrap function defined outside the streamlit app run + + Why we need bootstrap argument when there is streamlit.cache_resource? + 1. I need a streamlit app func which can run everywhere + 2. The ghostos streamlit app need some contracts (for example, workspace), bootstrapped outside cause 1. + 3. @st.cache_resource wrap a function who control the resources lifecycle, conflict to 2. + """ + # bootstrap once + boot(bootstrap) + # load pages + pgs = st.navigation(navigation.pages(), position="hidden") + # define sidebar + with st.sidebar: + navigation.render_sidebar_page_links() + + pgs.run() diff --git a/ghostos/prototypes/streamlitapp/main.py b/ghostos/prototypes/streamlitapp/main.py new file mode 100644 index 00000000..bbafe49d --- /dev/null +++ b/ghostos/prototypes/streamlitapp/main.py @@ -0,0 +1,10 @@ +from ghostos.bootstrap import application_container +from ghostos.prototypes.streamlitapp.app import run_ghostos_streamlit_app, SINGLETONS +from ghostos.prototypes.streamlitapp.utils.session import Singleton + + +def bootstrap() -> SINGLETONS: + yield Singleton(application_container) + + +run_ghostos_streamlit_app(bootstrap) diff --git a/ghostos/prototypes/streamlitapp/pages/homepages/helloworld.py b/ghostos/prototypes/streamlitapp/pages/homepages/helloworld.py new file mode 100644 index 00000000..e92e9e2b --- /dev/null +++ b/ghostos/prototypes/streamlitapp/pages/homepages/helloworld.py @@ -0,0 +1,7 @@ +import streamlit as st +from ghostos.container import Container +from ghostos.prototypes.streamlitapp.utils.session import Singleton + +st.write("hello world!") +container = Singleton.get(Container, st.session_state) +st.write(str(container)) diff --git a/ghostos/prototypes/streamlitapp/pages/homepages/home.py b/ghostos/prototypes/streamlitapp/pages/homepages/home.py new file mode 100644 index 00000000..c292c398 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/pages/homepages/home.py @@ -0,0 +1,3 @@ +import streamlit as st + +st.write("HomePage") diff --git a/ghostos/prototypes/streamlitapp/pages/homepages/host.py b/ghostos/prototypes/streamlitapp/pages/homepages/host.py new file mode 100644 index 00000000..c3385985 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/pages/homepages/host.py @@ -0,0 +1,3 @@ +import streamlit as st + +st.write("Host") diff --git a/ghostos/prototypes/streamlitapp/pages/navigation.py b/ghostos/prototypes/streamlitapp/pages/navigation.py new file mode 100644 index 00000000..6908b1cb --- /dev/null +++ b/ghostos/prototypes/streamlitapp/pages/navigation.py @@ -0,0 +1,28 @@ +from ghostos.prototypes.streamlitapp.utils.route import Route, Router, Link + + +class Home(Route): + link = Link( + name="home", + page="pages/homepages/home.py", + title="Home", + icon=":material/home:", + ) + + +class Helloworld(Route): + link = Link( + name="helloworld", + page="pages/homepages/helloworld.py", + title="Hello World", + icon=":material/home:", + ) + + +navigation = Router( + [ + Home(), + Helloworld(), + ], + sidebar_buttons=["home", "helloworld"], +) diff --git a/ghostos/prototypes/streamlitapp/resources.py b/ghostos/prototypes/streamlitapp/resources.py new file mode 100644 index 00000000..60912369 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/resources.py @@ -0,0 +1,2 @@ +import streamlit as st +from ghostos.container import Container diff --git a/ghostos/prototypes/streamlitapp/tests/container_test/index.py b/ghostos/prototypes/streamlitapp/tests/container_test/index.py new file mode 100644 index 00000000..1f0556a5 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/container_test/index.py @@ -0,0 +1,2 @@ +import streamlit as st + diff --git a/ghostos/prototypes/streamlitapp/tests/container_test/main.py b/ghostos/prototypes/streamlitapp/tests/container_test/main.py new file mode 100644 index 00000000..162f73b7 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/container_test/main.py @@ -0,0 +1,10 @@ +import streamlit as st +from ghostos.container import Container + + + + +def main(con: Container): + st.navigation([ + st.Page('page.py', title='Page'), + ]) diff --git a/ghostos/prototypes/streamlitapp/tests/container_test/page.py b/ghostos/prototypes/streamlitapp/tests/container_test/page.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/streamlitapp/tests/session_render_by_set.py b/ghostos/prototypes/streamlitapp/tests/session_render_by_set.py new file mode 100644 index 00000000..4c3402ae --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/session_render_by_set.py @@ -0,0 +1,9 @@ +import streamlit as st +from random import randint + +st.write(randint(0, 1000)) + +if value := st.button("test render"): + # always set same value, see if the session trigger rendering + st.write("button: " + str(value)) + st.session_state["some"] = dict(foo="bar") diff --git a/ghostos/prototypes/streamlitapp/utils/__init__.py b/ghostos/prototypes/streamlitapp/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/streamlitapp/utils/options.py b/ghostos/prototypes/streamlitapp/utils/options.py new file mode 100644 index 00000000..eaecf59c --- /dev/null +++ b/ghostos/prototypes/streamlitapp/utils/options.py @@ -0,0 +1,17 @@ +import streamlit as st +from enum import Enum + + +class Bool(str, Enum): + HELP_MODE = "ghostos.streamlit.app.help_mode" + """global help mode""" + + def set(self, val: bool) -> None: + st.session_state[self.value] = val + + def get(self) -> bool: + key = self.value + return key in st.session_state and st.session_state[self.value] is True + + def toggle(self) -> None: + st.session_state[self.value] = not self.get() diff --git a/ghostos/prototypes/streamlitapp/utils/route.py b/ghostos/prototypes/streamlitapp/utils/route.py new file mode 100644 index 00000000..d2f96271 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/utils/route.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from typing import ClassVar, Callable, Optional, MutableMapping, Literal, List, Dict, Set +from abc import ABC +from typing_extensions import Self + +from ghostos.prototypes.streamlitapp.utils.session import SessionStateValue +from pydantic import BaseModel, Field +import streamlit as st +from pathlib import Path +from ghostos.helpers import generate_import_path + +__all__ = ["Router", 'Route', 'Link'] + + +class Link: + """ + wrap streamlit page functions + """ + + def __init__( + self, + name: str, + page: str | Path | Callable[[], None], + *, + title: str | None = None, + icon: str | None = None, + url_path: str | None = None, + ): + self.name = name + self.page = page + self.title = title if title else name + self.icon = icon + self.url_path = url_path if url_path else name + + def st_page(self, *, default: bool = False, url_path: Optional[str] = None) -> st.Page: + return st.Page( + page=self.page, + title=self.title, + icon=self.icon, + url_path=url_path, + default=default, + ) + + def switch_page(self, url_path: Optional[str] = None) -> None: + st.switch_page(self.st_page(url_path=url_path)) + + +class Route(SessionStateValue, BaseModel, ABC): + """ + wrap the basic methods: + 1. the data useful to render a streamlit page + 2. switch to a streamlit page + 3. render a navigation + 4. render a page switch button + 5. render a page switch dialog + """ + + link: ClassVar[Link] + + help: Optional[str] = Field(None, description="help message of the route") + query: str = Field("", description="urlpath query") + + def page(self, default: bool = False) -> st.Page: + url_path = self.full_url_path() + return self.link.st_page(url_path=url_path, default=default) + + def full_url_path(self) -> str: + url_path = self.link.url_path + if self.query: + url_path += "?" + self.query + return url_path + + def switch_page(self) -> None: + """ + bind self to the session state and switch the page. + """ + # bind the route value to the session state + url_path = self.full_url_path() + self.bind(st.session_state) + self.link.switch_page(url_path=url_path) + + def render_page_link( + self, *, + typ: Literal["primary", "secondary"] = "secondary", + disabled: bool = False, + use_container_width: bool = False, + ): + """ + shall run under `with st.sidebar` + """ + label = self.link.title + help_ = self.help + st.page_link( + self.page(), + label=label, + help=help_, + icon=self.link.icon, + disabled=disabled, + use_container_width=use_container_width, + ) + + @classmethod + def session_state_key(cls) -> str: + return generate_import_path(cls) + + @classmethod + def get(cls, session_state: MutableMapping) -> Optional[Self]: + key = cls.session_state_key() + if key in session_state: + return session_state[key] + return None + + @classmethod + def default(cls) -> Self: + return cls() + + def bind(self, session_state: MutableMapping) -> None: + key = self.session_state_key() + session_state[key] = self + + +class Router: + + def __init__(self, routes: List[Route], *, sidebar_buttons: List[str] = None): + self.routes: Dict[str, Route] = {} + self.routes_order = [] + self.append(*routes) + self.sidebar_buttons = sidebar_buttons + + def append(self, *routes: Route): + for route in routes: + name = route.link.name + if name in self.routes: + raise KeyError(f"Duplicate route name: {name}") + self.routes[name] = route + self.routes_order.append(name) + + def pages(self, default: Optional[str] = None, names: Optional[List[str]] = None) -> List[st.Page]: + pages = [] + if names is None: + names = self.routes_order + idx = 0 + for name in names: + route = self.routes[name] + if default is None: + is_default = idx == 0 + else: + is_default = name == default + idx += 1 + page = route.page(default=is_default) + pages.append(page) + return pages + + def render_sidebar_page_links( + self, + names: Optional[List[str]] = None, + primary: Optional[Set[str]] = None, + disabled: Optional[Set[str]] = None, + use_container_width: bool = True, + ) -> None: + if names is None: + names = self.sidebar_buttons + if names is None: + names = self.routes_order + for name in names: + route = self.routes[name] + is_disabled = disabled is not None and name in disabled + route.render_page_link( + typ="primary" if primary and name in primary else "secondary", + disabled=is_disabled, + use_container_width=use_container_width, + ) diff --git a/ghostos/prototypes/streamlitapp/utils/session.py b/ghostos/prototypes/streamlitapp/utils/session.py new file mode 100644 index 00000000..3a520827 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/utils/session.py @@ -0,0 +1,138 @@ +from abc import ABC, abstractmethod +from typing import MutableMapping, Optional, ClassVar, Any, TypeVar, Type, List +from typing_extensions import Self +from pydantic import BaseModel +from ghostos.helpers import generate_import_path + +__all__ = [ + 'SessionStateValue', 'ModelSingleton', + 'SingletonContracts', + 'Singleton', + # functions + 'expect', +] + + +class SessionStateValue(ABC): + """ + Value that bind to streamlit.session_state + """ + + @classmethod + @abstractmethod + def get(cls, session_state: MutableMapping) -> Optional[Self]: + """ + load self value from session_state + :param session_state: the streamlit session state + :return: None if not bound yet + """ + pass + + @classmethod + def load(cls, session_state: MutableMapping) -> Self: + value = cls.get(session_state) + if value is None: + default_value = cls.default() + default_value.bind(session_state) + return default_value + if not isinstance(value, cls): + raise ValueError(f"type {cls} can not find self in streamlit.session_state, {value} found") + return value + + @classmethod + @abstractmethod + def default(cls) -> Self: + """ + default self value + """ + pass + + @abstractmethod + def bind(self, session_state: MutableMapping) -> None: + """ + bind self to session_state + :param session_state: streamlit.session_state + """ + pass + + +class ModelSingleton(BaseModel, SessionStateValue, ABC): + """ + use pydantic.BaseModel to define state value + """ + + session_key: ClassVar[str] + """Streamlit Session State Key""" + + @classmethod + def get(cls, session_state: MutableMapping) -> Optional[Self]: + """ + load self value from session_state + :param session_state: the streamlit session state + :return: None if not bound yet + """ + return session_state.get(cls.session_key, None) + + @classmethod + def default(cls) -> Self: + # SingletonModel shall have default value for each field. + return cls() + + def bind(self, session_state: MutableMapping) -> None: + session_state[self.session_key] = self + + +T = TypeVar('T') + + +class Singleton: + """ + session state singleton, key is the class type + """ + + def __init__(self, value: object): + self.value = value + self.key = self.gen_key(type(value)) + + def bind(self, session_state: MutableMapping, force: bool = False) -> None: + """ + :param session_state: streamlit session state + :param force: if False, only bind when target is not exists. + """ + if force or self.key not in session_state: + session_state[self.key] = self.value + + @classmethod + def get(cls, t: Type[T], session_state: MutableMapping) -> T: + key = cls.gen_key(t) + if key not in session_state: + raise KeyError(f'key {key} not found in session state') + value = session_state[key] + return value + + @classmethod + def gen_key(cls, t: Type) -> str: + return generate_import_path(t) + + @classmethod + def bound(cls, t: Type, session_state: MutableMapping) -> bool: + key = cls.gen_key(t) + return key in session_state + + +class SingletonContracts: + def __init__(self, types: List[Type]): + self.types = types + + def validate(self, session_state: MutableMapping) -> List[Type]: + unbound = [] + for typ in self.types: + if not Singleton.bound(typ, session_state): + unbound.append(typ) + return unbound + + +def expect(session_state: MutableMapping, key: str, value: Any) -> bool: + if key not in session_state: + return False + return value == session_state[key] From 7f225f7f927bd3aeef693cada12c1b5671a72773 Mon Sep 17 00:00:00 2001 From: zhuming Date: Wed, 9 Oct 2024 22:19:54 +0800 Subject: [PATCH 021/148] dev: prepare app structure --- app/streamlit_main.py | 16 ++ ghostos/prototypes/streamlitapp/app.py | 34 +++- ghostos/prototypes/streamlitapp/main.py | 10 -- ghostos/prototypes/streamlitapp/navigation.py | 69 ++++++++ .../prototypes/streamlitapp/pages/homepage.py | 37 ++++ .../pages/homepages/helloworld.py | 7 - .../streamlitapp/pages/homepages/home.py | 3 - .../streamlitapp/pages/homepages/host.py | 3 - .../streamlitapp/pages/navigation.py | 28 --- .../streamlitapp/tests/async_test.py | 24 +++ .../prototypes/streamlitapp/utils/options.py | 32 +++- .../prototypes/streamlitapp/utils/route.py | 159 ++++++++++++++---- ghostos/prototypes/streamlitapp/widgets.py | 12 ++ 13 files changed, 345 insertions(+), 89 deletions(-) create mode 100644 app/streamlit_main.py delete mode 100644 ghostos/prototypes/streamlitapp/main.py create mode 100644 ghostos/prototypes/streamlitapp/navigation.py create mode 100644 ghostos/prototypes/streamlitapp/pages/homepage.py delete mode 100644 ghostos/prototypes/streamlitapp/pages/homepages/helloworld.py delete mode 100644 ghostos/prototypes/streamlitapp/pages/homepages/home.py delete mode 100644 ghostos/prototypes/streamlitapp/pages/homepages/host.py delete mode 100644 ghostos/prototypes/streamlitapp/pages/navigation.py create mode 100644 ghostos/prototypes/streamlitapp/tests/async_test.py create mode 100644 ghostos/prototypes/streamlitapp/widgets.py diff --git a/app/streamlit_main.py b/app/streamlit_main.py new file mode 100644 index 00000000..10fa9ac7 --- /dev/null +++ b/app/streamlit_main.py @@ -0,0 +1,16 @@ +from ghostos.prototypes.streamlitapp.app import run_ghostos_streamlit_app, SINGLETONS +from ghostos.prototypes.streamlitapp.utils.session import Singleton + + +def bootstrap() -> SINGLETONS: + from os.path import dirname + from ghostos.bootstrap import make_app_container + from ghostos.prototypes.streamlitapp.navigation import default_router + + app_dir = dirname(__file__) + app_container = make_app_container(app_dir) + yield Singleton(app_container) + yield Singleton(default_router) + + +run_ghostos_streamlit_app(bootstrap) diff --git a/ghostos/prototypes/streamlitapp/app.py b/ghostos/prototypes/streamlitapp/app.py index a8e56da4..1d53db7e 100644 --- a/ghostos/prototypes/streamlitapp/app.py +++ b/ghostos/prototypes/streamlitapp/app.py @@ -2,7 +2,10 @@ from typing import Callable, List from ghostos.container import Container from ghostos.prototypes.streamlitapp.utils.session import expect, SingletonContracts, Singleton -from ghostos.prototypes.streamlitapp.pages.navigation import navigation +from ghostos.prototypes.streamlitapp.utils.route import Router +from ghostos.prototypes.streamlitapp.utils.options import BoolOpts +from ghostos.prototypes.streamlitapp.widgets import application_navigator_menu +from gettext import gettext as _ __all__ = [ "SINGLETONS", "BOOTSTRAP", "BOOTSTRAPPED_KEY", @@ -18,6 +21,7 @@ contracts = SingletonContracts([ Container, + Router, ]) @@ -53,9 +57,33 @@ def run_ghostos_streamlit_app(bootstrap: BOOTSTRAP) -> None: # bootstrap once boot(bootstrap) # load pages - pgs = st.navigation(navigation.pages(), position="hidden") + router = Singleton.get(Router, st.session_state) + pgs = st.navigation(router.pages(), position="hidden") # define sidebar with st.sidebar: - navigation.render_sidebar_page_links() + router.render_homepage() + # render page links + with st.expander(label=_("Navigator"), expanded=False, icon=":material/menu:"): + router.render_navigator(use_container_width=True) + # with helper mode toggle + # open_navigator = st.button( + # label=_("GhostOS Navigator"), + # help=_("the navigations"), + # icon=":material/menu:", + # use_container_width=True, + # ) + with st.expander(label="Options", expanded=False, icon=":material/settings:"): + BoolOpts.HELP_MODE.render_toggle( + label=_("Help Mode"), + tips=_("switch help mode at every page"), + ) + BoolOpts.DEBUG_MODE.render_toggle( + label=_("Debug Mode"), + tips=_("switch debug mode at every page"), + ) + + # global navigator dialog + # if open_navigator: + # app_navigator_dialog() pgs.run() diff --git a/ghostos/prototypes/streamlitapp/main.py b/ghostos/prototypes/streamlitapp/main.py deleted file mode 100644 index bbafe49d..00000000 --- a/ghostos/prototypes/streamlitapp/main.py +++ /dev/null @@ -1,10 +0,0 @@ -from ghostos.bootstrap import application_container -from ghostos.prototypes.streamlitapp.app import run_ghostos_streamlit_app, SINGLETONS -from ghostos.prototypes.streamlitapp.utils.session import Singleton - - -def bootstrap() -> SINGLETONS: - yield Singleton(application_container) - - -run_ghostos_streamlit_app(bootstrap) diff --git a/ghostos/prototypes/streamlitapp/navigation.py b/ghostos/prototypes/streamlitapp/navigation.py new file mode 100644 index 00000000..a8355df2 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/navigation.py @@ -0,0 +1,69 @@ +from ghostos.prototypes.streamlitapp.utils.route import Route, Router, Link +from ghostos.prototypes.streamlitapp.pages import homepage +from enum import Enum + + +class PagePath(str, Enum): + HOMEPAGE = "ghostos.prototypes.streamlitapp.pages.homepage" + + def spec(self, attr_name: str): + return self.value + ':' + attr_name + + +class Home(Route): + link = Link( + name="Home", + import_path=PagePath.HOMEPAGE.spec("home"), + streamlit_icon=":material/home:", + button_help="help", + antd_icon="house-fill", + ) + + +class Navigator(Route): + link = Link( + name="Navigator", + import_path=PagePath.HOMEPAGE.spec("navigator"), + streamlit_icon=":material/home:", + antd_icon="box-fill", + ) + + +class GhostOSHost(Route): + link = Link( + name="GhostOS Host", + import_path=PagePath.HOMEPAGE.spec("ghostos_host"), + streamlit_icon=":material/smart_toy:", + ) + + +class Helloworld(Route): + """ + test only + """ + link = Link( + name="Hello World", + import_path=PagePath.HOMEPAGE.spec("helloworld"), + streamlit_icon=":material/home:", + ) + + +default_router = Router( + [ + Home(), + Helloworld(), + Navigator(), + GhostOSHost(), + ], + home=Home.label(), + navigator_names=[ + GhostOSHost.label(), + Helloworld.label(), + ], + default_menu={ + Home.label(): None, + Helloworld.label(): None, + }, + default_sidebar_buttons=[ + ], +) diff --git a/ghostos/prototypes/streamlitapp/pages/homepage.py b/ghostos/prototypes/streamlitapp/pages/homepage.py new file mode 100644 index 00000000..f7f418c3 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/pages/homepage.py @@ -0,0 +1,37 @@ +from gettext import gettext as _ + + +def home(): + import streamlit as st + from ghostos.prototypes.streamlitapp.widgets import application_navigator_menu + + st.title(_("GhostOS Homepage")) + with st.expander(_("App Menu"), expanded=False): + application_navigator_menu() + + +def helloworld(): + import streamlit as st + from ghostos.container import Container + from ghostos.prototypes.streamlitapp.utils.session import Singleton + + st.write("hello world!") + container = Singleton.get(Container, st.session_state) + st.write(str(container)) + + +def navigator(): + import streamlit as st + from ghostos.prototypes.streamlitapp.utils.route import Router + from ghostos.prototypes.streamlitapp.utils.session import Singleton + + router = Singleton.get(Router, st.session_state) + menu = router.default_antd_menu_items() + route = router.render_antd_menu(menu) + if route is not None: + route.switch_page() + + +def ghostos_host(): + import streamlit as st + st.title("GhostOS Host") diff --git a/ghostos/prototypes/streamlitapp/pages/homepages/helloworld.py b/ghostos/prototypes/streamlitapp/pages/homepages/helloworld.py deleted file mode 100644 index e92e9e2b..00000000 --- a/ghostos/prototypes/streamlitapp/pages/homepages/helloworld.py +++ /dev/null @@ -1,7 +0,0 @@ -import streamlit as st -from ghostos.container import Container -from ghostos.prototypes.streamlitapp.utils.session import Singleton - -st.write("hello world!") -container = Singleton.get(Container, st.session_state) -st.write(str(container)) diff --git a/ghostos/prototypes/streamlitapp/pages/homepages/home.py b/ghostos/prototypes/streamlitapp/pages/homepages/home.py deleted file mode 100644 index c292c398..00000000 --- a/ghostos/prototypes/streamlitapp/pages/homepages/home.py +++ /dev/null @@ -1,3 +0,0 @@ -import streamlit as st - -st.write("HomePage") diff --git a/ghostos/prototypes/streamlitapp/pages/homepages/host.py b/ghostos/prototypes/streamlitapp/pages/homepages/host.py deleted file mode 100644 index c3385985..00000000 --- a/ghostos/prototypes/streamlitapp/pages/homepages/host.py +++ /dev/null @@ -1,3 +0,0 @@ -import streamlit as st - -st.write("Host") diff --git a/ghostos/prototypes/streamlitapp/pages/navigation.py b/ghostos/prototypes/streamlitapp/pages/navigation.py deleted file mode 100644 index 6908b1cb..00000000 --- a/ghostos/prototypes/streamlitapp/pages/navigation.py +++ /dev/null @@ -1,28 +0,0 @@ -from ghostos.prototypes.streamlitapp.utils.route import Route, Router, Link - - -class Home(Route): - link = Link( - name="home", - page="pages/homepages/home.py", - title="Home", - icon=":material/home:", - ) - - -class Helloworld(Route): - link = Link( - name="helloworld", - page="pages/homepages/helloworld.py", - title="Hello World", - icon=":material/home:", - ) - - -navigation = Router( - [ - Home(), - Helloworld(), - ], - sidebar_buttons=["home", "helloworld"], -) diff --git a/ghostos/prototypes/streamlitapp/tests/async_test.py b/ghostos/prototypes/streamlitapp/tests/async_test.py new file mode 100644 index 00000000..aa21c5d8 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/async_test.py @@ -0,0 +1,24 @@ +import streamlit as st +import time + +st.title("Test") + + +@st.fragment +def messages(): + count = 0 + while "run" not in st.session_state or st.session_state["run"] is True: + count += 1 + with st.chat_message("ai"): + st.write(f"Hello world! {count}") + time.sleep(1) + + +# st.toggle(label="run", key="run", value=True) + +def callback(): + st.session_state["run"] = False + + +st.chat_input("test", on_submit=callback) +messages() diff --git a/ghostos/prototypes/streamlitapp/utils/options.py b/ghostos/prototypes/streamlitapp/utils/options.py index eaecf59c..7c319731 100644 --- a/ghostos/prototypes/streamlitapp/utils/options.py +++ b/ghostos/prototypes/streamlitapp/utils/options.py @@ -1,11 +1,15 @@ import streamlit as st +from typing import Optional from enum import Enum -class Bool(str, Enum): +class BoolOpts(str, Enum): + HELP_MODE = "ghostos.streamlit.app.help_mode" """global help mode""" + DEBUG_MODE = "ghostos.streamlit.app.debug_mode" + def set(self, val: bool) -> None: st.session_state[self.value] = val @@ -15,3 +19,29 @@ def get(self) -> bool: def toggle(self) -> None: st.session_state[self.value] = not self.get() + + def render_toggle( + self, + label: str, *, + tips: Optional[str] = None, + disabled: bool = False, + ) -> None: + st.toggle( + label, + key=self.value, + disabled=disabled, + help=tips, + ) + + def render_button( + self, + label: str, *, + tips: Optional[str] = None, + disabled: bool = False, + ) -> bool: + return st.button( + label, + key=self.value, + disabled=disabled, + help=tips, + ) diff --git a/ghostos/prototypes/streamlitapp/utils/route.py b/ghostos/prototypes/streamlitapp/utils/route.py index d2f96271..26ea3dd5 100644 --- a/ghostos/prototypes/streamlitapp/utils/route.py +++ b/ghostos/prototypes/streamlitapp/utils/route.py @@ -1,14 +1,15 @@ from __future__ import annotations -from typing import ClassVar, Callable, Optional, MutableMapping, Literal, List, Dict, Set +from typing import ClassVar, Callable, Optional, MutableMapping, Literal, List, Dict, Set, Union from abc import ABC from typing_extensions import Self - from ghostos.prototypes.streamlitapp.utils.session import SessionStateValue +from ghostos.helpers import generate_import_path, import_from_path from pydantic import BaseModel, Field import streamlit as st from pathlib import Path -from ghostos.helpers import generate_import_path +from gettext import gettext as _ +import streamlit_antd_components as sac __all__ = ["Router", 'Route', 'Link'] @@ -21,23 +22,39 @@ class Link: def __init__( self, name: str, - page: str | Path | Callable[[], None], + import_path: str, *, - title: str | None = None, - icon: str | None = None, + button_help: Optional[str] = None, + menu_desc: Optional[str] = None, url_path: str | None = None, + streamlit_icon: str = ":material/box:", + antd_icon: str = "box-fill", ): self.name = name - self.page = page - self.title = title if title else name - self.icon = icon + self.import_path = import_path + self.streamlit_icon = streamlit_icon + self.antd_icon = antd_icon + self.button_help = button_help + self.menu_desc = menu_desc self.url_path = url_path if url_path else name - def st_page(self, *, default: bool = False, url_path: Optional[str] = None) -> st.Page: + def st_page( + self, *, + default: bool = False, + title: Optional[str] = None, + url_path: Optional[str] = None, + ) -> st.Page: + title = _(title) if title is not None else None + # function + if ':' in self.import_path: + page = import_from_path(self.import_path) + else: + page = self.import_path + return st.Page( - page=self.page, - title=self.title, - icon=self.icon, + page=page, + title=title, + icon=self.streamlit_icon, url_path=url_path, default=default, ) @@ -57,18 +74,20 @@ class Route(SessionStateValue, BaseModel, ABC): """ link: ClassVar[Link] - - help: Optional[str] = Field(None, description="help message of the route") - query: str = Field("", description="urlpath query") + url_query: str = Field("", description="urlpath query") def page(self, default: bool = False) -> st.Page: url_path = self.full_url_path() return self.link.st_page(url_path=url_path, default=default) + @classmethod + def label(cls) -> str: + return _(cls.link.name) + def full_url_path(self) -> str: url_path = self.link.url_path - if self.query: - url_path += "?" + self.query + if self.url_query: + url_path += "?" + self.url_query return url_path def switch_page(self) -> None: @@ -82,24 +101,39 @@ def switch_page(self) -> None: def render_page_link( self, *, - typ: Literal["primary", "secondary"] = "secondary", disabled: bool = False, use_container_width: bool = False, ): """ shall run under `with st.sidebar` """ - label = self.link.title - help_ = self.help + label = self.label() + help_ = self.link.button_help + if help_ is not None: + help_ = _(help_) st.page_link( self.page(), label=label, help=help_, - icon=self.link.icon, + icon=self.link.streamlit_icon, disabled=disabled, use_container_width=use_container_width, ) + def antd_menu_item(self, children: Optional[List[sac.MenuItem]] = None) -> sac.MenuItem: + """ + generate menu item + """ + menu_desc = self.link.menu_desc + if menu_desc is not None: + menu_desc = _(menu_desc) + return sac.MenuItem( + label=self.label(), + description=menu_desc, + children=children, + icon=self.link.antd_icon, + ) + @classmethod def session_state_key(cls) -> str: return generate_import_path(cls) @@ -122,20 +156,34 @@ def bind(self, session_state: MutableMapping) -> None: class Router: - def __init__(self, routes: List[Route], *, sidebar_buttons: List[str] = None): + def __init__( + self, + routes: List[Route], *, + home: str, + navigator_names: List[str], + default_menu: Dict[str, Union[sac.MenuItem, Dict, None]], + default_sidebar_buttons: List[str], + ): self.routes: Dict[str, Route] = {} self.routes_order = [] + self.home = home self.append(*routes) - self.sidebar_buttons = sidebar_buttons + self.default_menu_tree = default_menu + self.default_sidebar_buttons = default_sidebar_buttons + self.default_navigator_names = navigator_names def append(self, *routes: Route): for route in routes: - name = route.link.name + name = route.label() if name in self.routes: raise KeyError(f"Duplicate route name: {name}") self.routes[name] = route self.routes_order.append(name) + def render_homepage(self) -> None: + route = self.routes[self.home] + route.render_page_link(use_container_width=True) + def pages(self, default: Optional[str] = None, names: Optional[List[str]] = None) -> List[st.Page]: pages = [] if names is None: @@ -152,22 +200,65 @@ def pages(self, default: Optional[str] = None, names: Optional[List[str]] = None pages.append(page) return pages - def render_sidebar_page_links( - self, - names: Optional[List[str]] = None, - primary: Optional[Set[str]] = None, + def render_page_links( + self, *, + names: Optional[List[str]], disabled: Optional[Set[str]] = None, use_container_width: bool = True, ) -> None: - if names is None: - names = self.sidebar_buttons - if names is None: - names = self.routes_order for name in names: route = self.routes[name] is_disabled = disabled is not None and name in disabled route.render_page_link( - typ="primary" if primary and name in primary else "secondary", disabled=is_disabled, use_container_width=use_container_width, ) + + def render_navigator( + self, + disabled: Optional[Set[str]] = None, + use_container_width: bool = True, + ): + self.render_page_links( + names=self.default_navigator_names, + disabled=disabled, + use_container_width=use_container_width, + ) + + def render_default_sidebar_buttons( + self, + disabled: Optional[Set[str]] = None, + use_container_width: bool = True, + ) -> None: + self.render_page_links( + names=self.routes_order, + disabled=disabled, + use_container_width=use_container_width, + ) + + def antd_menu_items(self, node_tree: Dict[str, Union[sac.MenuItem, Dict, None]]) -> List[sac.MenuItem]: + result = [] + for label in node_tree: + item = node_tree[label] + if isinstance(item, sac.MenuItem): + item.label = label + result.append(item) + else: + if label not in self.routes: + raise KeyError(f"menu label : {label} not found in Route") + route = self.routes[label] + children = None + if isinstance(item, dict) and len(item) > 0: + children = self.antd_menu_items(item) + menu_item = route.antd_menu_item(children) + result.append(menu_item) + return result + + def default_antd_menu_items(self) -> List[sac.MenuItem]: + return self.antd_menu_items(self.default_menu_tree) + + def render_antd_menu(self, items: List[sac.MenuItem]) -> Optional[Route]: + choose = sac.menu(items, index=-1) + if choose in self.routes: + return self.routes[choose] + return None diff --git a/ghostos/prototypes/streamlitapp/widgets.py b/ghostos/prototypes/streamlitapp/widgets.py new file mode 100644 index 00000000..7770f1c0 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/widgets.py @@ -0,0 +1,12 @@ +import streamlit as st +from gettext import gettext as _ +from ghostos.prototypes.streamlitapp.utils.route import Router +from ghostos.prototypes.streamlitapp.utils.session import Singleton + + +def application_navigator_menu(): + router = Singleton.get(Router, st.session_state) + menu = router.default_antd_menu_items() + route = router.render_antd_menu(menu) + if route is not None: + route.switch_page() From 393427fe27b5d182fa8d51b5c655c598485dcac4 Mon Sep 17 00:00:00 2001 From: zhuming Date: Wed, 9 Oct 2024 22:53:02 +0800 Subject: [PATCH 022/148] dev: add EntityFactory to application root providers --- ghostos/bootstrap.py | 4 ++++ ghostos/entity.py | 10 ++++++++++ ghostos/framework/entities/__init__.py | 1 + ghostos/framework/entities/basic.py | 14 ++++++++++++++ 4 files changed, 29 insertions(+) create mode 100644 ghostos/framework/entities/__init__.py create mode 100644 ghostos/framework/entities/basic.py diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index b6eb1590..6de5e2cd 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -93,6 +93,7 @@ def default_application_contracts() -> Contracts: from ghostos.contracts.pool import Pool from ghostos.contracts.shutdown import Shutdown from ghostos.contracts.modules import Modules + from ghostos.entity import EntityFactory from ghostos.framework.workspaces import Workspace from ghostos.framework.configs import Configs from ghostos.framework.processes import Processes @@ -113,6 +114,7 @@ def default_application_contracts() -> Contracts: LLMs, # LLMs interface LoggerItf, # the logger instance of application Modules, # the import_module proxy + EntityFactory, # wrap and un-wrap Entity class # moss MossCompiler, @@ -151,6 +153,7 @@ def default_application_providers( from ghostos.framework.eventbuses import MemEventBusImplProvider from ghostos.framework.llms import ConfigBasedLLMsProvider from ghostos.framework.logger import NamedLoggerProvider + from ghostos.framework.entities import EntityFactoryProvider return [ BasicWorkspaceProvider( workspace_dir=root_dir, @@ -168,6 +171,7 @@ def default_application_providers( ShutdownProvider(), NamedLoggerProvider(logger_name), DefaultMOSSProvider(), + EntityFactoryProvider(), ] diff --git a/ghostos/entity.py b/ghostos/entity.py index dc616894..87730910 100644 --- a/ghostos/entity.py +++ b/ghostos/entity.py @@ -11,6 +11,7 @@ 'EntityFactory', 'ModelEntity', 'EntityFactoryImpl', + 'model_to_entity_meta', ] @@ -28,6 +29,15 @@ class EntityMeta(TypedDict, total=False): """ use dict to restore the serializable data""" +def model_to_entity_meta(model: BaseModel) -> EntityMeta: + type_ = generate_import_path(type(model)) + data = model.model_dump(exclude_defaults=True) + return EntityMeta( + type=type_, + data=data, + ) + + class Entity(ABC): """ meta is a strong type-hint class that can generate meta-data to transport diff --git a/ghostos/framework/entities/__init__.py b/ghostos/framework/entities/__init__.py new file mode 100644 index 00000000..46de63e3 --- /dev/null +++ b/ghostos/framework/entities/__init__.py @@ -0,0 +1 @@ +from ghostos.framework.entities.basic import EntityFactoryProvider diff --git a/ghostos/framework/entities/basic.py b/ghostos/framework/entities/basic.py new file mode 100644 index 00000000..427b2374 --- /dev/null +++ b/ghostos/framework/entities/basic.py @@ -0,0 +1,14 @@ +from typing import Optional + +from ghostos.container import Provider, Container, INSTANCE +from ghostos.contracts.modules import Modules +from ghostos.entity import EntityFactory, EntityFactoryImpl + + +class EntityFactoryProvider(Provider[EntityFactory]): + def singleton(self) -> bool: + return True + + def factory(self, con: Container) -> Optional[EntityFactory]: + modules = con.force_fetch(Modules) + return EntityFactoryImpl(modules.import_module) From 2d3852159ed9051428aefab9b62e3e16fd11380e Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 10 Oct 2024 01:01:51 +0800 Subject: [PATCH 023/148] fix: fix container bootstrap provider even it is override by other registrar --- ghostos/container.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/ghostos/container.py b/ghostos/container.py index 6e1233a1..238d7770 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -3,6 +3,7 @@ from abc import ABCMeta, abstractmethod from typing import Type, Dict, TypeVar, Callable, Set, Optional, List, Generic, Any, Union, Iterable from typing import get_args, get_origin +import warnings __all__ = [ "Container", "IoCContainer", @@ -155,10 +156,13 @@ def bootstrap(self) -> None: return # 必须在这里初始化, 否则会循环调用. self._bootstrapped = True - if not self._bootstrapper: - return - for b in self._bootstrapper: - b.bootstrap(self) + if self._bootstrapper: + for b in self._bootstrapper: + b.bootstrap(self) + for provider in self._providers.values(): + # some bootstrapper provider may be override + if isinstance(provider, Bootstrapper): + provider.bootstrap(self) def set(self, abstract: Any, instance: INSTANCE) -> None: """ @@ -187,7 +191,9 @@ def get(self, abstract: Type[INSTANCE]) -> Optional[INSTANCE]: - params 感觉不需要. """ # 进行初始化. - self.bootstrap() + if not self._bootstrapped: + warnings.warn("container is not bootstrapped before using") + self.bootstrap() # get bound instance got = self._instances.get(abstract, None) @@ -250,9 +256,9 @@ def register(self, *providers: Provider) -> None: self._register(provider) def _register(self, provider: Provider) -> None: - if isinstance(provider, Bootstrapper): + if isinstance(provider, Bootstrapper) and self._bootstrapped: # 添加 bootstrapper. - self.add_bootstrapper(provider) + provider.bootstrap(self) contract = provider.contract() self._bind_contract(contract) @@ -271,6 +277,7 @@ def _register_provider(self, contract: ABSTRACT, provider: Provider) -> None: # remove singleton instance that already bound if contract in self._instances: del self._instances[contract] + # override the existing one self._providers[contract] = provider def add_bootstrapper(self, bootstrapper: Bootstrapper) -> None: @@ -279,7 +286,8 @@ def add_bootstrapper(self, bootstrapper: Bootstrapper) -> None: :param bootstrapper: 可以定义一些方法, 比如往容器里的某个类里注册一些工具. :return: """ - self._bootstrapper.append(bootstrapper) + if not self._bootstrapped: + self._bootstrapper.append(bootstrapper) def fetch(self, abstract: Type[INSTANCE], strict: bool = False) -> Optional[INSTANCE]: """ From 4e70bd7e661a775e063681418129ae850e2aa463 Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 10 Oct 2024 01:06:21 +0800 Subject: [PATCH 024/148] feat: huge modification about AIFunc 1. define ExecStep and ExecFrame for AIFunc 2. use ExecStep/Frame to pass stack status to each AIFuncManager, instead of container. 3. AiFuncManager nest container no more, let the generated MossCompiler to create sub manager (AiFuncCtx) 4. pass stream to AiFuncManager.execute, for client side messages. not test yet. --- ghostos/core/aifunc/__init__.py | 1 + ghostos/core/aifunc/driver.py | 90 ++++++++++---- ghostos/core/aifunc/interfaces.py | 189 ++++++++++++++++++++++++++---- ghostos/core/aifunc/manager.py | 171 +++++++++++++++------------ 4 files changed, 331 insertions(+), 120 deletions(-) diff --git a/ghostos/core/aifunc/__init__.py b/ghostos/core/aifunc/__init__.py index 3690d6ed..b7e50f9f 100644 --- a/ghostos/core/aifunc/__init__.py +++ b/ghostos/core/aifunc/__init__.py @@ -1,6 +1,7 @@ from ghostos.core.aifunc.driver import DefaultAIFuncDriverImpl from ghostos.core.aifunc.interfaces import ( AIFunc, AIFuncResult, AIFuncCtx, AIFuncDriver, AIFuncManager, + ExecFrame, ExecStep, ) from ghostos.core.aifunc.manager import DefaultAIFuncManagerImpl, DefaultAIFuncManagerProvider diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index 65dd23e5..7d73f39d 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -1,7 +1,7 @@ import traceback from typing import Tuple, List, Optional, Any -from ghostos.core.aifunc.interfaces import AIFuncDriver, AIFuncManager +from ghostos.core.aifunc.interfaces import AIFuncDriver, AIFuncManager, ExecStep, ExecFrame from ghostos.core.aifunc.func import ( AIFunc, get_aifunc_instruction, get_aifunc_result_type, get_aifunc_pycontext, get_aifunc_llmapi, @@ -9,7 +9,7 @@ from ghostos.core.llms import LLMs, Chat from ghostos.core.moss.abc import MossRuntime from ghostos.core.session import MsgThread, DefaultEventType, Threads, thread_to_chat -from ghostos.core.messages import Role, Message +from ghostos.core.messages import Role, Message, Stream from ghostos.contracts.logger import LoggerItf __all__ = [ @@ -132,9 +132,17 @@ def on_message(self, message: Message) -> None: def on_system_messages(self, messages: List[Message]) -> None: pass - def think(self, manager: AIFuncManager, thread: MsgThread) -> Tuple[MsgThread, Optional[Any], bool]: + def think( + self, + manager: AIFuncManager, + thread: MsgThread, + step: ExecStep, + upstream: Optional[Stream] + ) -> Tuple[MsgThread, Optional[Any], bool]: logger = manager.container().get(LoggerItf) - compiler = manager.compiler() + # get compiler by current exec step + # the MossCompiler.container().get(AIFuncCtx) will bind this step. + compiler = manager.compiler(step, upstream) compiler.join_context(thread.get_pycontext()) compiler.bind(self.aifunc.__class__, self.aifunc) runtime = compiler.compile(None) @@ -143,52 +151,81 @@ def think(self, manager: AIFuncManager, thread: MsgThread) -> Tuple[MsgThread, O systems.append(Role.SYSTEM.new( content=DEFAULT_NOTICES, )) + + # build chat self.on_system_messages(systems) chat = thread_to_chat(thread.id, systems, thread) - self.on_chat(chat) # Whether you want to send chat to llm, let it generate code for you or not - # todo: log - # 实例化 llm api + step.chat = chat + # on_chat hook + self.on_chat(chat) + + # instance the llms llms = manager.container().force_fetch(LLMs) llm_api = get_aifunc_llmapi(self.aifunc, llms) if llm_api is None: llm_api = manager.default_llm_api() - # 调用 llm api - # logger and logger.info(f"run aifunc with chat :{chat}") + + # call llm api ai_generation = llm_api.chat_completion(chat) - # 插入 ai 生成的消息. + + # append ai_generation thread.append(ai_generation) - self.on_message(ai_generation) # Whether you want to execute the ai-generated code or not + step.messages.append(ai_generation) + # on_message hook + self.on_message(ai_generation) + + # parse the ai_generation. code = self.parse_moss_code_in_message(ai_generation) - # code 相关校验: + error = None + # handle code: if not code: - thread.append(Role.SYSTEM.new(content="Error! You shall only write python code! DO NOT ACT LIKE IN A CHAT")) + error = Role.new_assistant_system( + content="Error! You shall only write python code! DO NOT ACT LIKE IN A CHAT" + ) + # log logger.error(f"ai_generation: {repr(ai_generation)}") - return thread, None, False - if "main(" not in code: - thread.append(Role.SYSTEM.new(content="Error! No main function found in your generation!")) + + elif "main(" not in code: + error = Role.new_assistant_system( + content="Error! No main function found in your generation!" + ) + + if error is not None: + thread.append(error) + step.error = error return thread, None, False result = None # 运行 moss. try: - executed = runtime.execute(code=code, target='main', local_args=['moss'], kwargs={"fn": self.aifunc}) + logger.info(f"executing ai_generation: {code}") + executed = runtime.execute( + code=code, + target='main', + local_args=['moss'], + kwargs={"fn": self.aifunc}, + ) + result, finish = executed.returns if not isinstance(finish, bool): raise RuntimeError(f"Result from main function {finish} is not boolean") - outputs = executed.std_output - if outputs: - output_message = Role.SYSTEM.new( - content=f"## Observation\n\nmoss executed main, std output is: \n{outputs}" + output = executed.std_output + step.std_output = output + if output: + output_message = Role.new_assistant_system( + content=f"## Observation\n\nmoss executed main, std output is: \n{output}" ) messages = [output_message] else: - output_message = Role.SYSTEM.new( + output_message = Role.new_assistant_system( content=f"## Observation\n\nhave not printed anything" ) messages = [output_message] pycontext = executed.pycontext + + # append the messages. thread.new_turn( event=DefaultEventType.OBSERVE.new( messages=messages, @@ -197,12 +234,17 @@ def think(self, manager: AIFuncManager, thread: MsgThread) -> Tuple[MsgThread, O ), pycontext=pycontext, ) + step.pycontext = pycontext + # I think this method is thread-safe + step.messages.extend(messages) + self.error_times = 0 except Exception as e: exe_info = "\n".join(traceback.format_exception(e)[-5:]) - output_message = Role.SYSTEM.new( + output_message = Role.new_assistant_system( content=f"moss executed main, exception occurs: \n{exe_info}" ) + thread.new_turn( event=DefaultEventType.OBSERVE.new( messages=[output_message], @@ -210,6 +252,8 @@ def think(self, manager: AIFuncManager, thread: MsgThread) -> Tuple[MsgThread, O from_task_id=thread.id, ), ) + step.error = output_message + self.error_times += 1 if self.error_times >= 3: raise RuntimeError(f"AIFunc `{self.name()}` failed {self.error_times} times, can not fix itself: \n{e}") diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index dd729da3..78b2f4e6 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -1,15 +1,21 @@ -from typing import Any, Optional, Tuple, Dict +from typing import Any, Optional, Tuple, Dict, Type, List from abc import ABC, abstractmethod from ghostos.core.aifunc.func import AIFunc, AIFuncResult from ghostos.core.moss.decorators import cls_source_code -from ghostos.core.moss.abc import MossCompiler -from ghostos.core.llms import LLMApi +from ghostos.core.moss import MossCompiler, PyContext +from ghostos.core.llms import LLMApi, Chat from ghostos.core.session import MsgThread +from ghostos.core.messages import Message, Stream +from ghostos.abc import Identifier +from ghostos.helpers import generate_import_path, uuid from ghostos.container import Container +from ghostos.entity import EntityMeta, model_to_entity_meta +from pydantic import BaseModel, Field __all__ = [ 'AIFunc', 'AIFuncResult', 'AIFuncManager', 'AIFuncCtx', 'AIFuncDriver', + 'ExecFrame', 'ExecStep', ] @@ -65,7 +71,70 @@ def values(self) -> Dict[str, Any]: pass +class ExecStep(BaseModel): + """ + AIFunc execute in multi-turn thinking. Each turn is a step. + """ + frame_id: str = Field(description="step id") + depth: int = Field(description="depth of the ExecFrame") + step_id: str = Field(default_factory=uuid, description="step id") + chat: Optional[Chat] = Field(default=None, description="llm chat") + messages: List[Message] = Field(default_factory=list, description="list of messages") + code: str = Field(default="", description="the generated code of the AIFunc") + std_output: str = Field(default="", description="the std output of the AIFunc step") + pycontext: Optional[PyContext] = Field(default=None, default_factory=PyContext) + error: Optional[Message] = Field(description="the error message") + frames: List = Field(default_factory=list, description="list of ExecFrame") + + def new_frame(self, fn: AIFunc) -> "ExecFrame": + frame = ExecFrame.from_func( + fn, + depth=self.depth + 1, + parent_step_id=self.step_id, + ) + # thread safe append + self.frames.append(frame) + return frame + + +class ExecFrame(BaseModel): + """ + stack frame of an AIFunc execution context + """ + frame_id: str = Field(default_factory=uuid, description="AIFunc execution id.") + parent_step: Optional[str] = Field(default=None, description="parent execution step id") + request: EntityMeta = Field(description="AIFunc request, model to entity") + response: Optional[EntityMeta] = Field(None, description="AIFunc response, model to entity") + depth: int = Field(default=0, description="the depth of the stack") + steps: List[ExecStep] = Field(default_factory=list, description="the execution steps") + + @classmethod + def from_func(cls, fn: AIFunc, depth: int = 0, parent_step_id: Optional[str] = None) -> "ExecFrame": + return cls( + request=model_to_entity_meta(fn), + parent_step=parent_step_id, + depth=depth, + ) + + def new_step(self) -> ExecStep: + step = ExecStep(frame_id=self.frame_id, depth=self.depth) + self.steps.append(step) + return step + + class AIFuncManager(ABC): + """ + AIFuncCtx is model-oriented. + AIFuncManager is developer (human or meta-agent) oriented + + In other words, an AIFuncCtx is the model-oriented interface of an AIFuncManager Adapter. + + the core method is `execute`, the method itself is stateless, + but receive a state object ExecFrame to record states. + + the `AIFuncCtx.run` is stateful when it is created from a specific ExecStep + it will create sub ExecFrame during each call, and update self ExecStep. + """ @abstractmethod def container(self) -> Container: @@ -84,35 +153,53 @@ def default_llm_api(self) -> LLMApi: pass @abstractmethod - def compiler(self) -> MossCompiler: + def compiler(self, step: ExecStep, upstream: Optional[Stream] = None) -> MossCompiler: """ - 返回与 AIFunc 相关的 MossCompiler - :return: + make a MossCompiler with step and upstream. + the MossCompiler.Container() can get sub AiFuncCtx with step and upstream. + :param step: get moss compiler with ExecStep + :param upstream: pass upstream to sub manager """ pass + @abstractmethod def context(self) -> AIFuncCtx: """ - :return: AIFuncCtx that provide AIFunc Runtime. + :return: AIFuncCtx that bind to this manager """ pass @abstractmethod - def execute(self, fn: AIFunc) -> AIFuncResult: - """ - 执行一个 AIFunc, 直到拿到它的返回结果. + def execute( + self, + fn: AIFunc, + frame: Optional[ExecFrame] = None, + upstream: Optional[Stream] = None, + ) -> AIFuncResult: + """ + execute an AIFunc in multi-turn thinking. + each step of the processing will record to the frame object. + + when AiFunc is running, it may generate code in which another AiFuncCtx is called. + The called AiFuncCtx is actually from a sub manager of this one. + + -- stack --> AIFuncManager execution --> LLM call AiFuncCtx --> Sub AIFuncManager execution + -- actually --> AIFuncManager execution -------------------------> Sub AIFuncManager execution """ pass @abstractmethod - def sub_manager(self) -> "AIFuncManager": + def sub_manager(self, step: ExecStep, upstream: Optional[Stream] = None) -> "AIFuncManager": """ instance an sub manager to provide AIFuncCtx for sub AIFunc """ pass @abstractmethod - def get_driver(self, fn: AIFunc) -> "AIFuncDriver": + def get_driver( + self, + fn: AIFunc, + ) -> "AIFuncDriver": """ 根据 AIFunc 实例获取 AIFuncDriver 的实例. """ @@ -126,6 +213,62 @@ def destroy(self) -> None: pass +class AIFuncRepository(ABC): + """ + Repository that register the AIFunc information, useful to recall AIFuncs + """ + + @abstractmethod + def register(self, fn: Type[AIFunc]) -> None: + """ + register an AIFunc class + :param fn: AIFunc class + """ + pass + + @classmethod + def identify(cls, fn: Type[AIFunc]) -> Identifier: + """ + how to identify an AIFunc + :param fn: class + :return: Identifier( + id=[import path of the AiFunc, formation is f"{fn.__module}:{func.__name__}"] + ) + """ + return Identifier( + id=generate_import_path(fn), + name=fn.__name__, + description=fn.__doc__, + ) + + @abstractmethod + def scan(self, module_name: str, recursive: bool) -> List[Identifier]: + """ + scan a module and find AiFunc + :param module_name: + :param recursive: + :return: list of AiFunc identifiers + """ + pass + + @abstractmethod + def search(self, query: str, limit: int = 10) -> List[Identifier]: + """ + search AiFuncs matching the query + :param query: nature language of the query + :param limit: numbers of results to return + :return: list of AiFunc identifiers + """ + pass + + @abstractmethod + def all(self) -> List[Identifier]: + """ + :return: all the registered AiFunc identifiers + """ + pass + + class AIFuncDriver(ABC): """ the driver that produce multi-turns thinking of an AIFunc. @@ -142,21 +285,21 @@ def initialize(self) -> MsgThread: pass @abstractmethod - def think(self, manager: AIFuncManager, thread: MsgThread) -> Tuple[MsgThread, Optional[Any], bool]: + def think( + self, + manager: AIFuncManager, + thread: MsgThread, + step: ExecStep, + upstream: Stream, + ) -> Tuple[MsgThread, Optional[Any], bool]: """ think another round based on msg thread. + each think round must pass a ExecStep to it. + :param manager: AIFuncManager that provide AIFunc Runtime. :param thread: thread that keep multi-turns thinking's history. + :param step: execution step. + :param upstream: upstream that can send runtime messages. :return: (updated thread, __result__, is finish) """ pass - - @abstractmethod - def on_save(self, manager: AIFuncManager, thread: MsgThread) -> None: - """ - 一切运行结束的时候, 保存 chat 数据. - :param manager: - :param thread: - :return: - """ - pass diff --git a/ghostos/core/aifunc/manager.py b/ghostos/core/aifunc/manager.py index f93034b9..1bd5eeaf 100644 --- a/ghostos/core/aifunc/manager.py +++ b/ghostos/core/aifunc/manager.py @@ -1,15 +1,15 @@ from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Dict, Any, Optional, List, Type +from typing import Dict, Any, Optional, Type, Callable, Iterable +from typing_extensions import Self -from ghostos.container import Container, Provider, INSTANCE +from ghostos.container import Container, Provider, ABSTRACT from ghostos.core.llms import LLMApi, LLMs from ghostos.core.moss import MossCompiler from ghostos.core.aifunc.func import AIFunc, AIFuncResult, get_aifunc_result_type -from ghostos.core.aifunc.interfaces import AIFuncManager, AIFuncCtx, AIFuncDriver +from ghostos.core.aifunc.interfaces import AIFuncManager, AIFuncCtx, AIFuncDriver, ExecFrame, ExecStep from ghostos.core.aifunc.driver import DefaultAIFuncDriverImpl -from ghostos.core.session import MsgThread -from ghostos.helpers import generate_import_path, uuid +from ghostos.core.messages import Stream __all__ = ['DefaultAIFuncManagerImpl', 'DefaultAIFuncManagerProvider'] @@ -19,56 +19,45 @@ class DefaultAIFuncManagerImpl(AIFuncManager, AIFuncCtx): def __init__( self, *, container: Container, + step: Optional[ExecStep] = None, + upstream: Optional[Stream] = None, default_driver: Optional[Type[AIFuncDriver]] = None, llm_api_name: str = "", - max_step: int = 10, - depth: int = 0, max_depth: int = 10, - parent_idx: str = "", - sibling_idx: int = 0, - aifunc_name: str = "", - exec_id: str = "", - parent_aifunc_name: str = "", + max_step: int = 10, ): - self._container = Container(parent=container) + # manager do not create submanager + # but the container of MossCompiler from this manager + # get an instance of AIFuncCtx, which is actually submanager of this one. + self._container = container + self._exec_step = step + self._upstream: Stream = upstream self._llm_api_name = llm_api_name self._values: Dict[str, Any] = {} - self._sub_managers: List[AIFuncManager] = [] - self._max_step = max_step - self._depth = depth self._max_depth = max_depth - if self._depth > self._max_depth: - raise RuntimeError(f"AiFunc depth {self._depth} > {self._max_depth}, stackoverflow") + self._max_step = max_step + if step and step.depth > self._max_depth: + raise RuntimeError(f"AiFunc depth {step.depth} > {self._max_depth}, stackoverflow") self._default_driver_type = default_driver if default_driver else DefaultAIFuncDriverImpl - self._exec_id = exec_id if exec_id else uuid() - self._parent_idx = parent_idx - self._sibling_idx = sibling_idx - if parent_idx: - self._identity_prefix = f"{self._parent_idx}_{self._sibling_idx}" - else: - self._identity_prefix = "s" - self._aifunc_name = aifunc_name - self._parent_aifunc_name = parent_aifunc_name - self._child_idx = 0 + self._destroyed = False - def sub_manager(self, *, aifunc_name: str = "") -> "AIFuncManager": - self._child_idx += 1 + def sub_manager(self, step: ExecStep, upstream: Optional[Stream] = None) -> "AIFuncManager": + # sub manager's upstream may be None + # parent manager do not pass upstream to submanager manager = DefaultAIFuncManagerImpl( container=self._container, + step=step, + upstream=upstream, default_driver=self._default_driver_type, llm_api_name=self._llm_api_name, - max_step=self._max_step, - depth=self._depth + 1, max_depth=self._max_depth, - parent_idx=self._identity_prefix, - sibling_idx=self._child_idx, - aifunc_name=aifunc_name, - exec_id=self._exec_id, - parent_aifunc_name=self._aifunc_name, ) - self._sub_managers.append(manager) + # register submanager, destroy them together return manager + def context(self) -> AIFuncCtx: + return self + def container(self) -> Container: return self._container @@ -76,37 +65,48 @@ def default_llm_api(self) -> LLMApi: llms = self._container.force_fetch(LLMs) return llms.get_api(self._llm_api_name) - def compiler(self) -> MossCompiler: + def compiler(self, step: ExecStep, upstream: Optional[Stream] = None) -> MossCompiler: compiler = self._container.force_fetch(MossCompiler) - compiler.container().set(AIFuncCtx, self) + + # rebind exec step to moss container, which is a sub container + # the exec step will not contaminate self._container + maker = self._sub_manager_fn(step, upstream) + + compiler.container().register_maker( + contract=AIFuncCtx, + maker=maker, + singleton=True, + ) + compiler.container().set(ExecStep, step) return compiler - def wrap_thread(self, thread: MsgThread, aifunc_driver: AIFuncDriver) -> MsgThread: - aifunc = aifunc_driver.aifunc - thread.extra["aifunc"] = generate_import_path(type(aifunc)) - thread.extra["aifunc_data"] = aifunc.model_dump(exclude_defaults=True) - thread.extra["parent_aifunc"] = self._parent_aifunc_name - thread.extra["aifunc_depth"] = self._depth - aifunc_name = type(aifunc).__name__ - thread.save_file = f"aifunc_{self._exec_id}/{self._identity_prefix}_{aifunc_name}.yml" - return thread - - def execute(self, fn: AIFunc) -> AIFuncResult: - self._aifunc_name = generate_import_path(type(fn)) + def _sub_manager_fn(self, step: ExecStep, upstream: Optional[Stream]) -> Callable[[], Self]: + def sub_manager() -> AIFuncManager: + return self.sub_manager(step, upstream) + + return sub_manager + + def execute( + self, + fn: AIFunc, + frame: Optional[ExecFrame] = None, + upstream: Optional[Stream] = None, + ) -> AIFuncResult: + if frame is None: + frame = ExecFrame.from_func(fn) driver = self.get_driver(fn) thread = driver.initialize() - thread = self.wrap_thread(thread, driver) step = 0 finished = False result = None while not finished: step += 1 + # each step generate a new exec step + exec_step = frame.new_step() if self._max_step != 0 and step > self._max_step: raise RuntimeError(f"exceeded max step {self._max_step}") - turn = thread.last_turn() - turn.extra["aifunc_step"] = step - thread, result, finished = driver.think(self, thread) - driver.on_save(manager=self, thread=thread) + thread, result, finished = driver.think(self, thread, exec_step, upstream=upstream) + if finished: break if result is not None and not isinstance(result, AIFuncResult): @@ -114,28 +114,40 @@ def execute(self, fn: AIFunc) -> AIFuncResult: raise RuntimeError(f"result is invalid AIFuncResult {type(result)}, expecting {result_type}") return result - def get_driver(self, fn: AIFunc) -> "AIFuncDriver": + def get_driver( + self, + fn: AIFunc, + ) -> "AIFuncDriver": cls = fn.__class__ if cls.__aifunc_driver__ is not None: - return cls.__aifunc_driver__(fn) - return self._default_driver_type(fn) + driver = cls.__aifunc_driver__ + else: + driver = self._default_driver_type + return driver(fn) def run(self, key: str, fn: AIFunc) -> AIFuncResult: - aifunc_name = generate_import_path(type(fn)) - sub_manager = self.sub_manager(aifunc_name=aifunc_name) - result = sub_manager.execute(fn) - self._values[key] = result - return result + if self._exec_step is not None: + frame = self._exec_step.new_frame(fn) + else: + frame = ExecFrame.from_func(fn) + sub_step = frame.new_step() + sub_manager = self.sub_manager(sub_step) + try: + result = sub_manager.execute(fn, frame=frame, upstream=self._upstream) + # thread safe? python dict is thread safe + self._values[key] = result + return result + finally: + # always destroy submanager. + # or memory leak as hell + sub_manager.destroy() def parallel_run(self, fn_dict: Dict[str, AIFunc]) -> Dict[str, AIFuncResult]: def execute_task(key: str, fn: AIFunc): - aifunc_name = generate_import_path(type(fn)) - sub_manager = self.sub_manager(aifunc_name=aifunc_name) - return key, sub_manager.execute(fn) + r = self.run(key, fn) + return key, r results = {} - # todo: get pool from container - # pool = self._container.force_fetch(Pool) with ThreadPoolExecutor(max_workers=len(fn_dict)) as executor: futures = [executor.submit(execute_task, key, fn) for key, fn in fn_dict.items()] for future in as_completed(futures): @@ -155,22 +167,33 @@ def values(self) -> Dict[str, Any]: return self._values def destroy(self) -> None: - for manager in self._sub_managers: - manager.destroy() - del self._sub_managers + if self._destroyed: + # destroy once. + # not every submanager is created at self.execute + # so they could be destroyed outside already + return self._container.destroy() del self._container del self._values + del self._exec_step + del self._upstream class DefaultAIFuncManagerProvider(Provider[AIFuncManager]): - def __init__(self, llm_api_name: str = ""): + def __init__( + self, + llm_api_name: str = "", + ): self._llm_api_name = llm_api_name def singleton(self) -> bool: + # !! AIFuncManager shall not be return False + def aliases(self) -> Iterable[ABSTRACT]: + yield AIFuncCtx + def factory(self, con: Container) -> Optional[AIFuncManager]: return DefaultAIFuncManagerImpl( container=con, From 0fd137570e5da00cc8ac6f735c9a94e589204326 Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 10 Oct 2024 15:32:52 +0800 Subject: [PATCH 025/148] dev: rename AIFuncManager to AIFuncExecutor --- ghostos/core/aifunc/__init__.py | 5 ++-- ghostos/core/aifunc/driver.py | 6 ++--- .../core/aifunc/{manager.py => executor.py} | 22 ++++++++-------- ghostos/core/aifunc/interfaces.py | 25 ++++++++++--------- ghostos/prototypes/aifunc/app.py | 6 ++--- ghostos/scripts/aifunc_test.py | 6 ++--- ghostos/scripts/swe_test.py | 6 ++--- tests/helpers/__init__.py | 0 8 files changed, 39 insertions(+), 37 deletions(-) rename ghostos/core/aifunc/{manager.py => executor.py} (90%) delete mode 100644 tests/helpers/__init__.py diff --git a/ghostos/core/aifunc/__init__.py b/ghostos/core/aifunc/__init__.py index b7e50f9f..5461bc8f 100644 --- a/ghostos/core/aifunc/__init__.py +++ b/ghostos/core/aifunc/__init__.py @@ -1,8 +1,9 @@ from ghostos.core.aifunc.driver import DefaultAIFuncDriverImpl from ghostos.core.aifunc.interfaces import ( - AIFunc, AIFuncResult, AIFuncCtx, AIFuncDriver, AIFuncManager, + AIFunc, AIFuncResult, AIFuncCtx, AIFuncDriver, AIFuncExecutor, + AIFuncRepository, ExecFrame, ExecStep, ) -from ghostos.core.aifunc.manager import DefaultAIFuncManagerImpl, DefaultAIFuncManagerProvider +from ghostos.core.aifunc.executor import DefaultAIFuncExecutorImpl, DefaultAIFuncManagerProvider from ghostos.core.aifunc.func import get_aifunc_result_type diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index 7d73f39d..1c53c1e6 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -1,7 +1,7 @@ import traceback from typing import Tuple, List, Optional, Any -from ghostos.core.aifunc.interfaces import AIFuncDriver, AIFuncManager, ExecStep, ExecFrame +from ghostos.core.aifunc.interfaces import AIFuncDriver, AIFuncExecutor, ExecStep, ExecFrame from ghostos.core.aifunc.func import ( AIFunc, get_aifunc_instruction, get_aifunc_result_type, get_aifunc_pycontext, get_aifunc_llmapi, @@ -134,7 +134,7 @@ def on_system_messages(self, messages: List[Message]) -> None: def think( self, - manager: AIFuncManager, + manager: AIFuncExecutor, thread: MsgThread, step: ExecStep, upstream: Optional[Stream] @@ -275,7 +275,7 @@ def parse_moss_code_in_message(self, message: Message) -> str: return content[code_start_index + len(CODE_MARK_LEFT): code_end_index].strip() - def on_save(self, manager: AIFuncManager, thread: MsgThread) -> None: + def on_save(self, manager: AIFuncExecutor, thread: MsgThread) -> None: # 如果 threads 抽象存在, 就保存一下. 还应该做一些日志的工作. container = manager.container() threads = container.get(Threads) diff --git a/ghostos/core/aifunc/manager.py b/ghostos/core/aifunc/executor.py similarity index 90% rename from ghostos/core/aifunc/manager.py rename to ghostos/core/aifunc/executor.py index 1bd5eeaf..bb438df5 100644 --- a/ghostos/core/aifunc/manager.py +++ b/ghostos/core/aifunc/executor.py @@ -7,14 +7,14 @@ from ghostos.core.llms import LLMApi, LLMs from ghostos.core.moss import MossCompiler from ghostos.core.aifunc.func import AIFunc, AIFuncResult, get_aifunc_result_type -from ghostos.core.aifunc.interfaces import AIFuncManager, AIFuncCtx, AIFuncDriver, ExecFrame, ExecStep +from ghostos.core.aifunc.interfaces import AIFuncExecutor, AIFuncCtx, AIFuncDriver, ExecFrame, ExecStep from ghostos.core.aifunc.driver import DefaultAIFuncDriverImpl from ghostos.core.messages import Stream -__all__ = ['DefaultAIFuncManagerImpl', 'DefaultAIFuncManagerProvider'] +__all__ = ['DefaultAIFuncExecutorImpl', 'DefaultAIFuncManagerProvider'] -class DefaultAIFuncManagerImpl(AIFuncManager, AIFuncCtx): +class DefaultAIFuncExecutorImpl(AIFuncExecutor, AIFuncCtx): def __init__( self, *, @@ -41,10 +41,10 @@ def __init__( self._default_driver_type = default_driver if default_driver else DefaultAIFuncDriverImpl self._destroyed = False - def sub_manager(self, step: ExecStep, upstream: Optional[Stream] = None) -> "AIFuncManager": + def sub_executor(self, step: ExecStep, upstream: Optional[Stream] = None) -> "AIFuncExecutor": # sub manager's upstream may be None # parent manager do not pass upstream to submanager - manager = DefaultAIFuncManagerImpl( + manager = DefaultAIFuncExecutorImpl( container=self._container, step=step, upstream=upstream, @@ -81,8 +81,8 @@ def compiler(self, step: ExecStep, upstream: Optional[Stream] = None) -> MossCom return compiler def _sub_manager_fn(self, step: ExecStep, upstream: Optional[Stream]) -> Callable[[], Self]: - def sub_manager() -> AIFuncManager: - return self.sub_manager(step, upstream) + def sub_manager() -> AIFuncExecutor: + return self.sub_executor(step, upstream) return sub_manager @@ -131,7 +131,7 @@ def run(self, key: str, fn: AIFunc) -> AIFuncResult: else: frame = ExecFrame.from_func(fn) sub_step = frame.new_step() - sub_manager = self.sub_manager(sub_step) + sub_manager = self.sub_executor(sub_step) try: result = sub_manager.execute(fn, frame=frame, upstream=self._upstream) # thread safe? python dict is thread safe @@ -179,7 +179,7 @@ def destroy(self) -> None: del self._upstream -class DefaultAIFuncManagerProvider(Provider[AIFuncManager]): +class DefaultAIFuncManagerProvider(Provider[AIFuncExecutor]): def __init__( self, @@ -194,8 +194,8 @@ def singleton(self) -> bool: def aliases(self) -> Iterable[ABSTRACT]: yield AIFuncCtx - def factory(self, con: Container) -> Optional[AIFuncManager]: - return DefaultAIFuncManagerImpl( + def factory(self, con: Container) -> Optional[AIFuncExecutor]: + return DefaultAIFuncExecutorImpl( container=con, llm_api_name=self._llm_api_name, ) diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index 78b2f4e6..cafba699 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -14,7 +14,8 @@ __all__ = [ 'AIFunc', 'AIFuncResult', - 'AIFuncManager', 'AIFuncCtx', 'AIFuncDriver', + 'AIFuncExecutor', 'AIFuncCtx', 'AIFuncDriver', + 'AIFuncRepository', 'ExecFrame', 'ExecStep', ] @@ -122,12 +123,12 @@ def new_step(self) -> ExecStep: return step -class AIFuncManager(ABC): +class AIFuncExecutor(ABC): """ AIFuncCtx is model-oriented. - AIFuncManager is developer (human or meta-agent) oriented + AIFuncExecutor is developer (human or meta-agent) oriented - In other words, an AIFuncCtx is the model-oriented interface of an AIFuncManager Adapter. + In other words, an AIFuncCtx is the model-oriented interface of an AIFuncExecutor Adapter. the core method is `execute`, the method itself is stateless, but receive a state object ExecFrame to record states. @@ -183,13 +184,13 @@ def execute( when AiFunc is running, it may generate code in which another AiFuncCtx is called. The called AiFuncCtx is actually from a sub manager of this one. - -- stack --> AIFuncManager execution --> LLM call AiFuncCtx --> Sub AIFuncManager execution - -- actually --> AIFuncManager execution -------------------------> Sub AIFuncManager execution + -- stack --> AIFuncExecutor execution --> LLM call AiFuncCtx --> Sub AIFuncExecutor execution + -- actually --> AIFuncExecutor execution -------------------------> Sub AIFuncExecutor execution """ pass @abstractmethod - def sub_manager(self, step: ExecStep, upstream: Optional[Stream] = None) -> "AIFuncManager": + def sub_executor(self, step: ExecStep, upstream: Optional[Stream] = None) -> "AIFuncExecutor": """ instance an sub manager to provide AIFuncCtx for sub AIFunc """ @@ -219,10 +220,10 @@ class AIFuncRepository(ABC): """ @abstractmethod - def register(self, fn: Type[AIFunc]) -> None: + def register(self, *fns: Type[AIFunc]) -> None: """ register an AIFunc class - :param fn: AIFunc class + :param fns: AIFunc class """ pass @@ -287,16 +288,16 @@ def initialize(self) -> MsgThread: @abstractmethod def think( self, - manager: AIFuncManager, + manager: AIFuncExecutor, thread: MsgThread, step: ExecStep, - upstream: Stream, + upstream: Optional[Stream], ) -> Tuple[MsgThread, Optional[Any], bool]: """ think another round based on msg thread. each think round must pass a ExecStep to it. - :param manager: AIFuncManager that provide AIFunc Runtime. + :param manager: AIFuncExecutor that provide AIFunc Runtime. :param thread: thread that keep multi-turns thinking's history. :param step: execution step. :param upstream: upstream that can send runtime messages. diff --git a/ghostos/prototypes/aifunc/app.py b/ghostos/prototypes/aifunc/app.py index 354440cb..96a2f2f5 100644 --- a/ghostos/prototypes/aifunc/app.py +++ b/ghostos/prototypes/aifunc/app.py @@ -10,7 +10,7 @@ from ghostos.core.messages import Message from ghostos.core.moss import test_container from ghostos.core.aifunc import ( - DefaultAIFuncManagerImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncManager, + DefaultAIFuncExecutorImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncExecutor, AIFuncResult, ) from ghostos.framework.logger import NamedLoggerProvider @@ -87,7 +87,7 @@ def on_chat(self, chat: Chat) -> None: def on_system_messages(self, messages: List[Message]) -> None: pass - def on_save(self, manager: AIFuncManager, thread: MsgThread) -> None: + def on_save(self, manager: AIFuncExecutor, thread: MsgThread) -> None: current = thread.current if current: for message in current.messages(): @@ -99,7 +99,7 @@ def on_save(self, manager: AIFuncManager, thread: MsgThread) -> None: ) super().on_save(manager, thread) - manager = DefaultAIFuncManagerImpl( + manager = DefaultAIFuncExecutorImpl( container=container, llm_api_name=llm_api_name, default_driver=TestDriverImpl, diff --git a/ghostos/scripts/aifunc_test.py b/ghostos/scripts/aifunc_test.py index 92402a0a..10c108d7 100644 --- a/ghostos/scripts/aifunc_test.py +++ b/ghostos/scripts/aifunc_test.py @@ -9,7 +9,7 @@ from ghostos.core.llms import Chat from ghostos.core.messages import Message from ghostos.core.moss import test_container -from ghostos.core.aifunc import DefaultAIFuncManagerImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncManager +from ghostos.core.aifunc import DefaultAIFuncExecutorImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncExecutor from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.storage import FileStorageProvider from ghostos.framework.llms import ConfigBasedLLMsProvider @@ -114,7 +114,7 @@ def on_chat(self, chat: Chat) -> None: def on_system_messages(self, messages: List[Message]) -> None: pass - def on_save(self, manager: AIFuncManager, thread: MsgThread) -> None: + def on_save(self, manager: AIFuncExecutor, thread: MsgThread) -> None: current = thread.current if current: for message in current.messages(): @@ -126,7 +126,7 @@ def on_save(self, manager: AIFuncManager, thread: MsgThread) -> None: ) super().on_save(manager, thread) - manager_ = DefaultAIFuncManagerImpl( + manager_ = DefaultAIFuncExecutorImpl( container=container, llm_api_name=llm_api, default_driver=TestDriverImpl, diff --git a/ghostos/scripts/swe_test.py b/ghostos/scripts/swe_test.py index 58865b16..b15bc2e0 100644 --- a/ghostos/scripts/swe_test.py +++ b/ghostos/scripts/swe_test.py @@ -9,7 +9,7 @@ from ghostos.core.llms import Chat from ghostos.core.messages import Message from ghostos.core.moss import test_container -from ghostos.core.aifunc import DefaultAIFuncManagerImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncManager +from ghostos.core.aifunc import DefaultAIFuncExecutorImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncExecutor from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.storage import FileStorageProvider from ghostos.framework.llms import ConfigBasedLLMsProvider @@ -102,7 +102,7 @@ def on_chat(self, chat: Chat) -> None: def on_system_messages(self, messages: List[Message]) -> None: pass - def on_save(self, manager: AIFuncManager, thread: MsgThread) -> None: + def on_save(self, manager: AIFuncExecutor, thread: MsgThread) -> None: current = thread.current if current: for message in current.messages(): @@ -113,7 +113,7 @@ def on_save(self, manager: AIFuncManager, thread: MsgThread) -> None: ) ) - manager_ = DefaultAIFuncManagerImpl( + manager_ = DefaultAIFuncExecutorImpl( container=container, llm_api_name=llm_api, default_driver=TestDriverImpl, diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py deleted file mode 100644 index e69de29b..00000000 From 0555b9c7a5f2a6f48bb6b05e8358970355ea4a60 Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 10 Oct 2024 15:34:43 +0800 Subject: [PATCH 026/148] dev: Modules add iter_modules method --- ghostos/contracts/modules.py | 25 +++++++++++++++++++++++-- tests/contracts/test_modules.py | 11 +++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 tests/contracts/test_modules.py diff --git a/ghostos/contracts/modules.py b/ghostos/contracts/modules.py index 5936d131..4939a9f3 100644 --- a/ghostos/contracts/modules.py +++ b/ghostos/contracts/modules.py @@ -1,7 +1,8 @@ -from abc import ABC, abstractmethod +from typing import Optional, Type, Union, List, Iterable from types import ModuleType +from abc import ABC, abstractmethod from importlib import import_module -from typing import Optional, Type +import pkgutil from ghostos.container import Provider, Container @@ -21,6 +22,14 @@ def import_module(self, modulename) -> ModuleType: """ pass + @abstractmethod + def iter_modules(self, module: Union[str, ModuleType]) -> Iterable[str]: + """ + like pkgutil.iter_modules. + :return: module names + """ + pass + class ImportWrapper: def __init__(self, modules: Modules): @@ -51,6 +60,18 @@ class DefaultModules(Modules): def import_module(self, modulename) -> ModuleType: return import_module(modulename) + def iter_modules(self, module: Union[str, ModuleType]) -> Iterable[str]: + if isinstance(module, str): + module_type = self.import_module(module) + elif isinstance(module, ModuleType): + module_type = module + else: + raise ValueError(f'Invalid module type: {type(module)}') + prefix = module_type.__name__ + "." + path = module_type.__path__ + for i, name, is_pkg in pkgutil.iter_modules(path, prefix): + yield name + class DefaultModulesProvider(Provider[Modules]): def singleton(self) -> bool: diff --git a/tests/contracts/test_modules.py b/tests/contracts/test_modules.py new file mode 100644 index 00000000..99042135 --- /dev/null +++ b/tests/contracts/test_modules.py @@ -0,0 +1,11 @@ +from ghostos.contracts.modules import DefaultModules + + +def test_default_modules_iter(): + m = DefaultModules() + from ghostos import contracts + result = list(m.iter_modules(contracts)) + + assert "ghostos.contracts" not in result + result2 = list(m.iter_modules("ghostos.contracts")) + assert result == result2 From 802155f6732607fea1504b662ac99231ad6f7fbf Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 10 Oct 2024 16:02:07 +0800 Subject: [PATCH 027/148] dev: configs add save method --- ghostos/contracts/configs.py | 28 ++++++++++++++++++++---- ghostos/framework/configs/__init__.py | 1 + ghostos/framework/configs/basic.py | 28 ++++++++++++++++++++++++ ghostos/framework/configs/memimpl.py | 17 ++++++++++++++ ghostos/framework/configs/storageimpl.py | 20 ++++++++--------- tests/contracts/test_configs.py | 27 +++++++++++++++++++++++ 6 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 ghostos/framework/configs/basic.py create mode 100644 ghostos/framework/configs/memimpl.py create mode 100644 tests/contracts/test_configs.py diff --git a/ghostos/contracts/configs.py b/ghostos/contracts/configs.py index aab3cae8..c63c5175 100644 --- a/ghostos/contracts/configs.py +++ b/ghostos/contracts/configs.py @@ -1,7 +1,9 @@ import yaml from abc import ABC, abstractmethod from typing import ClassVar, TypeVar, Type, Optional, AnyStr +from typing_extensions import Self from pydantic import BaseModel +import io __all__ = ['Config', 'Configs', 'YamlConfig', 'C'] @@ -24,10 +26,15 @@ def conf_path(cls) -> str: @classmethod @abstractmethod - def load(cls, content: AnyStr) -> "Config": + def unmarshal(cls, content: bytes) -> Self: """ unmarshal the Config instance from content. - todo: rename it to unmarshal, and add marshal method. + """ + pass + + def marshal(self) -> bytes: + """ + marshal self to a savable content """ pass @@ -53,6 +60,16 @@ def get(self, conf_type: Type[C], relative_path: Optional[str] = None) -> C: """ pass + @abstractmethod + def save(self, conf: Config, relative_path: Optional[str] = None) -> None: + """ + save a Config instance to it source. + notice some config shall be immutable + :param conf: the conf object + :param relative_path: if pass, override the conf_type default path. + """ + pass + TIP = """ With object class Config and repository class Configs, @@ -76,8 +93,11 @@ def conf_path(cls) -> str: return cls.relative_path @classmethod - def load(cls, content: str) -> "Config": + def unmarshal(cls, content: str) -> "Config": value = yaml.safe_load(content) return cls(**value) -# todo: toml config + def marshal(self) -> bytes: + value = self.model_dump(exclude_defaults=True) + result = yaml.safe_dump(value) + return result.encode() diff --git a/ghostos/framework/configs/__init__.py b/ghostos/framework/configs/__init__.py index 44bc2642..b1f7fdb8 100644 --- a/ghostos/framework/configs/__init__.py +++ b/ghostos/framework/configs/__init__.py @@ -1,2 +1,3 @@ from ghostos.contracts.configs import Configs +from ghostos.framework.configs.memimpl import MemoryConfigs from ghostos.framework.configs.storageimpl import ConfigsByStorageProvider, WorkspaceConfigsProvider diff --git a/ghostos/framework/configs/basic.py b/ghostos/framework/configs/basic.py new file mode 100644 index 00000000..cb243c8d --- /dev/null +++ b/ghostos/framework/configs/basic.py @@ -0,0 +1,28 @@ +from typing import Type, Optional +from ghostos.contracts.configs import Configs, Config, C +from abc import ABC, abstractmethod + + +class BasicConfigs(Configs, ABC): + """ + A Configs(repository) based on Storage, no matter what the Storage is. + """ + + def get(self, conf_type: Type[C], relative_path: Optional[str] = None) -> C: + path = conf_type.conf_path() + relative_path = relative_path if relative_path else path + content = self._get(relative_path) + return conf_type.unmarshal(content) + + @abstractmethod + def _get(self, relative_path: str) -> bytes: + pass + + @abstractmethod + def _put(self, relative_path: str, content: bytes) -> None: + pass + + def save(self, conf: Config, relative_path: Optional[str] = None) -> None: + marshaled = conf.marshal() + relative_path = relative_path if relative_path else conf.conf_path() + self._put(relative_path, marshaled) diff --git a/ghostos/framework/configs/memimpl.py b/ghostos/framework/configs/memimpl.py new file mode 100644 index 00000000..3e1bd509 --- /dev/null +++ b/ghostos/framework/configs/memimpl.py @@ -0,0 +1,17 @@ +from typing import Dict, Optional +from .basic import BasicConfigs + + +class MemoryConfigs(BasicConfigs): + + def __init__(self, defaults: Optional[Dict] = None): + defaults = defaults or {} + self._cache: Dict[str, bytes] = defaults + + def _get(self, relative_path: str) -> bytes: + if relative_path not in self._cache: + raise FileNotFoundError(f'{relative_path} is not in cache') + return self._cache.get(relative_path) + + def _put(self, relative_path: str, content: bytes) -> None: + self._cache[relative_path] = content diff --git a/ghostos/framework/configs/storageimpl.py b/ghostos/framework/configs/storageimpl.py index c54c12a4..7c5cbf5f 100644 --- a/ghostos/framework/configs/storageimpl.py +++ b/ghostos/framework/configs/storageimpl.py @@ -1,23 +1,21 @@ -from typing import Type, Optional -from ghostos.contracts.configs import Configs, C +from typing import Optional, Dict +from ghostos.contracts.configs import Configs from ghostos.contracts.storage import Storage from ghostos.container import Provider, Container from ghostos.core.ghosts import Workspace +from .basic import BasicConfigs -class StorageConfigs(Configs): - """ - A Configs(repository) based on Storage, no matter what the Storage is. - """ +class StorageConfigs(BasicConfigs): def __init__(self, storage: Storage, conf_dir: str): self._storage = storage.sub_storage(conf_dir) - def get(self, conf_type: Type[C], relative_path: Optional[str] = None) -> C: - path = conf_type.conf_path() - relative_path = relative_path if relative_path else path - content = self._storage.get(relative_path) - return conf_type.load(content) + def _get(self, relative_path: str) -> bytes: + return self._storage.get(relative_path) + + def _put(self, relative_path: str, content: bytes) -> None: + self._storage.put(relative_path, content) class ConfigsByStorageProvider(Provider[Configs]): diff --git a/tests/contracts/test_configs.py b/tests/contracts/test_configs.py new file mode 100644 index 00000000..cb8e86e3 --- /dev/null +++ b/tests/contracts/test_configs.py @@ -0,0 +1,27 @@ +from typing import List +from ghostos.contracts.configs import Config, YamlConfig +from ghostos.framework.configs import MemoryConfigs + + +class FooConf(YamlConfig): + relative_path = "hello.yml" + foo: str = "abc" + bar: float = 1.1 + + +def test_config_marshal(): + cases: List[Config] = [ + FooConf(), + ] + + configs = MemoryConfigs() + + for c in cases: + marshaled = c.marshal() + un_marshaled = c.unmarshal(marshaled) + marshaled2 = un_marshaled.marshal() + assert marshaled == marshaled2, c + + configs.save(c) + got = configs.get(type(c)) + assert got.marshal() == marshaled From 29275e4b314fa43f3bd38dae527fd7bc95d6b98b Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 10 Oct 2024 16:53:24 +0800 Subject: [PATCH 028/148] dev: fulfill aifunc repository develop but not test yet --- app/configs/registered_aifunc.yml | 1 + ghostos/abc.py | 14 ++- ghostos/bootstrap.py | 10 ++ ghostos/contracts/configs.py | 7 +- ghostos/core/aifunc/__init__.py | 4 +- ghostos/core/aifunc/executor.py | 4 +- ghostos/core/aifunc/interfaces.py | 25 ++-- ghostos/core/aifunc/repository.py | 119 +++++++++++++++++++ tests/core/aifuncs/test_aifunc_repository.py | 22 ++++ 9 files changed, 184 insertions(+), 22 deletions(-) create mode 100644 app/configs/registered_aifunc.yml create mode 100644 ghostos/core/aifunc/repository.py create mode 100644 tests/core/aifuncs/test_aifunc_repository.py diff --git a/app/configs/registered_aifunc.yml b/app/configs/registered_aifunc.yml new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/app/configs/registered_aifunc.yml @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/ghostos/abc.py b/ghostos/abc.py index ed2ac849..622b1b36 100644 --- a/ghostos/abc.py +++ b/ghostos/abc.py @@ -2,6 +2,12 @@ from pydantic import BaseModel, Field from ghostos.helpers import generate_import_path +__all__ = [ + 'Descriptive', + 'Identifier', 'Identifiable', 'IdentifiableClass', 'identify_class', 'identify_class_id', + 'PromptAble', 'PromptAbleClass' +] + class Descriptive(ABC): @@ -34,7 +40,7 @@ def class_identifier(cls) -> Identifier: pass -def describe_class(cls: type) -> Identifier: +def identify_class(cls: type) -> Identifier: """ 一个默认的用来描述类的方法. :param cls: 目标类. @@ -42,12 +48,16 @@ def describe_class(cls: type) -> Identifier: """ if issubclass(cls, IdentifiableClass): return cls.class_identifier() - id_ = generate_import_path(cls) + id_ = identify_class_id(cls) name = cls.__name__ desc = cls.__doc__ return Identifier(id=id_, name=name, description=desc) +def identify_class_id(cls: type) -> str: + return generate_import_path(cls) + + class PromptAble(ABC): """ 拥有 __prompt__ 方法的类. diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 6de5e2cd..5becbf22 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -102,6 +102,7 @@ def default_application_contracts() -> Contracts: from ghostos.framework.eventbuses import EventBus from ghostos.framework.llms import LLMs from ghostos.framework.logger import LoggerItf + from ghostos.core.aifunc import AIFuncExecutor, AIFuncRepository return Contracts([ # workspace contracts @@ -119,6 +120,10 @@ def default_application_contracts() -> Contracts: # moss MossCompiler, + # aifunc + AIFuncExecutor, + AIFuncRepository, + # session contracts Processes, # application processes repository Threads, # application threads repository @@ -154,6 +159,7 @@ def default_application_providers( from ghostos.framework.llms import ConfigBasedLLMsProvider from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.entities import EntityFactoryProvider + from ghostos.core.aifunc import DefaultAIFuncExecutorProvider, AIFuncRepoByConfigsProvider return [ BasicWorkspaceProvider( workspace_dir=root_dir, @@ -172,6 +178,8 @@ def default_application_providers( NamedLoggerProvider(logger_name), DefaultMOSSProvider(), EntityFactoryProvider(), + DefaultAIFuncExecutorProvider(), + AIFuncRepoByConfigsProvider(), ] @@ -202,6 +210,8 @@ def make_app_container( _container.register(*app_providers) # contracts validation app_contracts.validate(_container) + # bootstrap. + _container.bootstrap() return _container diff --git a/ghostos/contracts/configs.py b/ghostos/contracts/configs.py index c63c5175..5bae8dc3 100644 --- a/ghostos/contracts/configs.py +++ b/ghostos/contracts/configs.py @@ -1,9 +1,9 @@ import yaml from abc import ABC, abstractmethod -from typing import ClassVar, TypeVar, Type, Optional, AnyStr +from typing import ClassVar, TypeVar, Type, Optional from typing_extensions import Self from pydantic import BaseModel -import io +from ghostos.helpers import generate_import_path __all__ = ['Config', 'Configs', 'YamlConfig', 'C'] @@ -99,5 +99,6 @@ def unmarshal(cls, content: str) -> "Config": def marshal(self) -> bytes: value = self.model_dump(exclude_defaults=True) + comment = f"# from class: {generate_import_path(self.__class__)}" result = yaml.safe_dump(value) - return result.encode() + return "\n".join([comment, result]).encode() diff --git a/ghostos/core/aifunc/__init__.py b/ghostos/core/aifunc/__init__.py index 5461bc8f..abec1111 100644 --- a/ghostos/core/aifunc/__init__.py +++ b/ghostos/core/aifunc/__init__.py @@ -4,6 +4,6 @@ AIFuncRepository, ExecFrame, ExecStep, ) -from ghostos.core.aifunc.executor import DefaultAIFuncExecutorImpl, DefaultAIFuncManagerProvider - from ghostos.core.aifunc.func import get_aifunc_result_type +from ghostos.core.aifunc.executor import DefaultAIFuncExecutorImpl, DefaultAIFuncExecutorProvider +from ghostos.core.aifunc.repository import AIFuncRepoByConfigsProvider, AIFuncRepoByConfigs, AIFuncsConf diff --git a/ghostos/core/aifunc/executor.py b/ghostos/core/aifunc/executor.py index bb438df5..8af6240f 100644 --- a/ghostos/core/aifunc/executor.py +++ b/ghostos/core/aifunc/executor.py @@ -11,7 +11,7 @@ from ghostos.core.aifunc.driver import DefaultAIFuncDriverImpl from ghostos.core.messages import Stream -__all__ = ['DefaultAIFuncExecutorImpl', 'DefaultAIFuncManagerProvider'] +__all__ = ['DefaultAIFuncExecutorImpl', 'DefaultAIFuncExecutorProvider'] class DefaultAIFuncExecutorImpl(AIFuncExecutor, AIFuncCtx): @@ -179,7 +179,7 @@ def destroy(self) -> None: del self._upstream -class DefaultAIFuncManagerProvider(Provider[AIFuncExecutor]): +class DefaultAIFuncExecutorProvider(Provider[AIFuncExecutor]): def __init__( self, diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index cafba699..ac0eaabe 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, Tuple, Dict, Type, List +from typing import Any, Optional, Tuple, Dict, Type, List, Iterable from abc import ABC, abstractmethod from ghostos.core.aifunc.func import AIFunc, AIFuncResult from ghostos.core.moss.decorators import cls_source_code @@ -83,7 +83,7 @@ class ExecStep(BaseModel): messages: List[Message] = Field(default_factory=list, description="list of messages") code: str = Field(default="", description="the generated code of the AIFunc") std_output: str = Field(default="", description="the std output of the AIFunc step") - pycontext: Optional[PyContext] = Field(default=None, default_factory=PyContext) + pycontext: Optional[PyContext] = Field(default=None, description="pycontext of the step") error: Optional[Message] = Field(description="the error message") frames: List = Field(default_factory=list, description="list of ExecFrame") @@ -243,29 +243,28 @@ def identify(cls, fn: Type[AIFunc]) -> Identifier: ) @abstractmethod - def scan(self, module_name: str, recursive: bool) -> List[Identifier]: + def scan(self, module_name: str, *, recursive: bool, save: bool) -> List[Identifier]: """ scan a module and find AiFunc - :param module_name: - :param recursive: + :param module_name: the modulename where an AIFunc is located or start point of a recursive search + :param recursive: if recursive search + :param save: if auto save to the repository :return: list of AiFunc identifiers """ pass @abstractmethod - def search(self, query: str, limit: int = 10) -> List[Identifier]: + def list(self, offset: int = 0, limit: int = -1) -> Iterable[Identifier]: """ - search AiFuncs matching the query - :param query: nature language of the query - :param limit: numbers of results to return - :return: list of AiFunc identifiers + :param offset: offset of the first item in the list + :param limit: limit the list, if limit <= 0 means return all identifiers after offset. + :return: all the registered AiFunc identifiers """ pass - @abstractmethod - def all(self) -> List[Identifier]: + def validate(self) -> None: """ - :return: all the registered AiFunc identifiers + validate the registered AiFunc, remove invalid ones """ pass diff --git a/ghostos/core/aifunc/repository.py b/ghostos/core/aifunc/repository.py new file mode 100644 index 00000000..6c3b0f34 --- /dev/null +++ b/ghostos/core/aifunc/repository.py @@ -0,0 +1,119 @@ +import inspect +from typing import List, Type, Dict, Set, Iterable, Optional +from types import ModuleType + +from ghostos.abc import Identifier, identify_class +from ghostos.core.aifunc import AIFunc +from ghostos.core.aifunc.interfaces import AIFuncRepository +from ghostos.contracts.configs import YamlConfig, Configs +from ghostos.contracts.modules import Modules +from ghostos.helpers import generate_module_spec +from ghostos.container import Provider, Container, INSTANCE +from pydantic import Field +import time + + +class AIFuncsConf(YamlConfig): + relative_path = "registered_aifunc.yaml" + + identifiers: Dict[str, Identifier] = Field( + default_factory=dict, + description="registered AiFuncs identifier", + ) + validated_at: int = Field(0, description="Validation time in seconds") + overdue: int = Field(3600, description="Overdue time in seconds") + + def is_overdue(self) -> bool: + now = int(time.time()) + return now - self.validated_at > self.overdue + + +class AIFuncRepoByConfigs(AIFuncRepository): + + def __init__( + self, + conf: AIFuncsConf, + configs: Configs, + modules: Modules, + ): + self.conf = conf + self.configs = configs + self.modules = modules + if self.conf.is_overdue(): + self.validate() + + def register(self, *fns: Type[AIFunc]) -> None: + saving = [] + for fn in fns: + if not issubclass(fn, AIFunc): + raise TypeError(f"AiFunc must be subclass of AIFunc, not {fn}") + identifier = identify_class(fn) + saving.append(identifier) + self._save_aifunc_identifier(*saving) + + def _save_aifunc_identifier(self, *identifiers: Identifier) -> None: + for identifier in identifiers: + self.conf.identifiers[identifier.id] = identifier + self.configs.save(self.conf) + + def scan(self, module_name: str, *, recursive: bool, save: bool) -> List[Identifier]: + mod = self.modules.import_module(module_name) + result: Set[Type[AIFunc]] = set() + self._scan_aifuncs_in_module(mod, result) + if recursive: + for sub_module_name in self.modules.iter_modules(mod): + sub_module = self.modules.import_module(sub_module_name) + self._scan_aifuncs_in_module(sub_module, result) + returns = [] + for fn in result: + identifier = self.identify(fn) + returns.append(identifier) + if save: + self._save_aifunc_identifier(*returns) + return returns + + @staticmethod + def _scan_aifuncs_in_module(mod: ModuleType, scanned: Set[Type[AIFunc]]) -> None: + """ + scan a single module, not recursively + """ + for name in mod.__dict__: + if name.startswith("_"): + continue + value = mod.__dict__[name] + if value and inspect.isclass(value) and issubclass(value, AIFunc): + scanned.add(value) + + def list(self, offset: int = 0, limit: int = -1) -> Iterable[Identifier]: + limit = limit if limit > 0 else len(self.conf.identifiers) + return self.conf.identifiers.values()[offset:offset + limit] + + def validate(self) -> None: + identifiers = {} + for key, val in self.conf.identifiers.items(): + modulename, attr_name = generate_module_spec(val.id) + try: + mod = self.modules.import_module(modulename) + if key not in mod.__dict__: + continue + attr = mod.__dict__[attr_name] + if attr is not None and inspect.isclass(attr) and issubclass(attr, AIFunc): + identifiers[key] = val + except ModuleNotFoundError: + continue + self.conf.identifiers = identifiers + self.configs.save(self.conf) + + +class AIFuncRepoByConfigsProvider(Provider[AIFuncRepository]): + + def singleton(self) -> bool: + return True + + def factory(self, con: Container) -> Optional[AIFuncRepository]: + configs = con.force_fetch(Configs) + modules = con.force_fetch(Modules) + conf = configs.get(AIFuncsConf) + return AIFuncRepoByConfigs(conf, configs, modules) + + diff --git a/tests/core/aifuncs/test_aifunc_repository.py b/tests/core/aifuncs/test_aifunc_repository.py new file mode 100644 index 00000000..e7596631 --- /dev/null +++ b/tests/core/aifuncs/test_aifunc_repository.py @@ -0,0 +1,22 @@ +from ghostos.core.aifunc import AIFuncRepoByConfigsProvider, AIFuncRepository, AIFuncsConf +from ghostos.framework.configs import Configs, MemoryConfigs +from ghostos.contracts.modules import Modules, DefaultModules +from ghostos.container import Container +from ghostos.demo import aifuncs + + +def test_aifunc_repository(): + container = Container() + container.set(Modules, DefaultModules()) + container.set(Configs, MemoryConfigs({ + AIFuncsConf.conf_path(): "{}", + + })) + container.register(AIFuncRepoByConfigsProvider()) + container.bootstrap() + + repo = container.force_fetch(AIFuncRepository) + result = repo.scan(str(aifuncs.__name__), recursive=True, save=False) + assert len(result) > 1 + + From e5c45637d5207e26f769e9878ab8729a89c47e29 Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 10 Oct 2024 18:04:32 +0800 Subject: [PATCH 029/148] refact: rename Message.pack to Message.chunk --- ghostos/core/llms/llm.py | 4 +-- ghostos/core/messages/message.py | 30 +++++++++---------- ghostos/core/messages/openai.py | 8 ++--- ghostos/framework/llms/openai_driver.py | 4 +-- ghostos/framework/messages/buffers.py | 8 ++--- ghostos/framework/messengers/defaults.py | 2 +- ghostos/framework/streams/queuestream.py | 2 +- tests/core/messages/test_messages.py | 22 +++++++------- tests/framework/messages/test_buffer.py | 28 ++++++++--------- tests/framework/messenger/test_messenger.py | 4 +-- ...test_modules.py => test_modules_helper.py} | 0 .../{test_module.py => test_py_module.py} | 0 ..._tree_sitter.py => test_py_tree_sitter.py} | 0 13 files changed, 56 insertions(+), 56 deletions(-) rename tests/helpers/{test_modules.py => test_modules_helper.py} (100%) rename tests/python/{test_module.py => test_py_module.py} (100%) rename tests/python/{test_tree_sitter.py => test_py_tree_sitter.py} (100%) diff --git a/ghostos/core/llms/llm.py b/ghostos/core/llms/llm.py index 8894405d..7ec9ec84 100644 --- a/ghostos/core/llms/llm.py +++ b/ghostos/core/llms/llm.py @@ -62,7 +62,7 @@ def deliver_chat_completion(self, chat: Chat, deliver: Stream) -> None: """ if not deliver.is_streaming(): message = self.chat_completion(chat) - if message.is_tail(): + if message.is_done(): # add model conf as message payload self.get_model().set(message) deliver.deliver(message) @@ -70,7 +70,7 @@ def deliver_chat_completion(self, chat: Chat, deliver: Stream) -> None: items = self.chat_completion_chunks(chat) # todo: payload 要计算 tokens for item in items: - if item.is_tail(): + if item.is_done(): # add model conf as message payload self.get_model().set(item) deliver.deliver(item) diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 70375050..2fe84c76 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -43,7 +43,7 @@ def new( name: Optional[str] = None, type_: Optional[str] = None, ) -> "Message": - return Message.new_tail( + return Message.new_done( type_=type_ if type_ else DefaultMessageTypes.DEFAULT.value, role=self.value, name=name, @@ -54,9 +54,9 @@ def new( class DefaultMessageTypes(str, enum.Enum): DEFAULT = "" - CHAT_COMPLETION = "chat_completion" - ERROR = "error" - FINAL = "final" + CHAT_COMPLETION = "openai.chat_completion" + ERROR = "ghostos.messages.error" + FINAL = "ghostos.messages.final" def new( self, *, @@ -100,7 +100,7 @@ def is_final(cls, pack: "Message") -> bool: @classmethod def is_protocol_type(cls, message: "Message"): - return not message.pack and message.type in {cls.ERROR, cls.FINAL} + return not message.chunk and message.type in {cls.ERROR, cls.FINAL} class Caller(BaseModel): @@ -196,7 +196,7 @@ class Message(BaseModel): ref_id: Optional[str] = Field(default=None, description="消息的关联目标. 如果 role 是 tool, 则这个是 tool_call_id") type: str = Field(default="", description="消息类型是对 payload 的约定. 默认的 type就是 text.") created: float = Field(default=0.0, description="Message creation time") - pack: bool = Field(default=True, description="Message reset time") + chunk: bool = Field(default=True, description="Message reset time") role: str = Field(default=Role.ASSISTANT.value, description="Message role", enum=Role.all()) name: Optional[str] = Field(default=None, description="Message sender name") @@ -231,14 +231,14 @@ def new_head( if created <= 0: created = round(time.time(), 4) return cls( - role=role, name=name, content=content, memory=memory, pack=True, + role=role, name=name, content=content, memory=memory, chunk=True, type=typ_, ref_id=ref_id, msg_id=msg_id, created=created, ) @classmethod - def new_tail( + def new_done( cls, *, type_: str = "", role: str = Role.ASSISTANT.value, @@ -256,11 +256,11 @@ def new_tail( ref_id=ref_id, created=created, ) - msg.pack = False + msg.chunk = False return msg @classmethod - def new_pack( + def new_chunk( cls, *, typ_: str = "", role: str = Role.ASSISTANT.value, @@ -269,7 +269,7 @@ def new_pack( name: Optional[str] = None, ): return cls( - role=role, name=name, content=content, memory=memory, pack=True, + role=role, name=name, content=content, memory=memory, chunk=True, type=typ_, ) @@ -292,7 +292,7 @@ def patch(self, pack: "Message") -> Optional["Message"]: if pack.msg_id and self.msg_id and pack.msg_id != self.msg_id: return None # 如果目标包是一个尾包, 则直接返回这个尾包. - if not pack.pack: + if not pack.chunk: return pack # 否则更新当前消息. self.update(pack) @@ -352,8 +352,8 @@ def is_empty(self) -> bool: no_payloads = not self.payloads and not self.attachments and not self.callers return no_content and no_payloads - def is_tail(self) -> bool: - return not self.pack + def is_done(self) -> bool: + return not self.chunk def dump(self) -> Dict: """ @@ -401,7 +401,7 @@ def parse(self, messages: Iterable[MessageKind]) -> Iterable[Message]: if not item: # exclude empty message continue - msg = Message.new_tail(content=item, role=self.role) + msg = Message.new_done(content=item, role=self.role) yield self._with_ref(msg) else: # todo: 需要日志? diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index 1c07ff86..fcd5ac69 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -159,7 +159,7 @@ def _parse_assistant_chat_completion(message: Message) -> Iterable[ChatCompletio )] def from_chat_completion(self, message: ChatCompletionMessage) -> Message: - pack = Message.new_tail(type_=DefaultMessageTypes.CHAT_COMPLETION, role=message.role, content=message.content) + pack = Message.new_done(type_=DefaultMessageTypes.CHAT_COMPLETION, role=message.role, content=message.content) if message.function_call: caller = Caller( name=message.function_call.name, @@ -185,7 +185,7 @@ def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) - if len(item.choices) == 0: # 接受到了 openai 协议尾包. 但在这个协议里不作为尾包发送. usage = CompletionUsagePayload.from_chunk(item) - pack = Message.new_pack(role=Role.ASSISTANT.value, typ_=DefaultMessageTypes.CHAT_COMPLETION) + pack = Message.new_chunk(role=Role.ASSISTANT.value, typ_=DefaultMessageTypes.CHAT_COMPLETION) usage.set(pack) yield pack else: @@ -201,8 +201,8 @@ def _new_pack_from_delta(delta: ChoiceDelta, first: bool) -> Message: pack = Message.new_head(role=Role.ASSISTANT.value, content=delta.content, typ_=DefaultMessageTypes.CHAT_COMPLETION) else: - pack = Message.new_pack(role=Role.ASSISTANT.value, content=delta.content, - typ_=DefaultMessageTypes.CHAT_COMPLETION) + pack = Message.new_chunk(role=Role.ASSISTANT.value, content=delta.content, + typ_=DefaultMessageTypes.CHAT_COMPLETION) # function call if delta.function_call: function_call = Caller(**delta.function_call.model_dump()) diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index 2b56152c..80321a2e 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -154,8 +154,8 @@ def chat_completion(self, chat: Chat) -> Message: usage = CompletionUsagePayload.from_usage(message.usage) usage.set(pack) - if not pack.is_tail(): - pack.pack = False + if not pack.is_done(): + pack.chunk = False return pack def chat_completion_chunks(self, chat: Chat) -> Iterable[Message]: diff --git a/ghostos/framework/messages/buffers.py b/ghostos/framework/messages/buffers.py index 323db150..12da6845 100644 --- a/ghostos/framework/messages/buffers.py +++ b/ghostos/framework/messages/buffers.py @@ -93,7 +93,7 @@ def buff(self, pack: "Message") -> List[Message]: result = [] for item in items: # 如果是尾包, 对尾包进行必要的处理. - is_tail = item.is_tail() + is_tail = item.is_done() if is_tail: self._buff_tail_pack(item) result.append(item) @@ -108,7 +108,7 @@ def _buff(self, pack: "Message") -> Iterable[Message]: # final 包不进行 buffer. yield pack return - if pack.is_tail(): + if pack.is_done(): # 如果收到了一个尾包, 则走尾包逻辑. yield from self._receive_tail_pack(pack) return @@ -217,7 +217,7 @@ def _parse_content_by_functional_token(self, pack: "Message") -> "Message": # 输出的消息会缓存到一起. self._buffering_message_delivered_content += deliver_content # 结算环节, 变更 pack 可以输出的 content. - if pack.is_tail() and pack.content != self._buffering_message_delivered_content: + if pack.is_done() and pack.content != self._buffering_message_delivered_content: pack.memory = pack.content pack.content = deliver_content return pack @@ -300,7 +300,7 @@ def _clear_tail_pack(self) -> Optional[Message]: return None buffering = self._buffering_message - buffering.pack = False + buffering.chunk = False if self._functional_token_starts: if self._buffering_token: diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index 19667504..f9e4ad97 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -144,7 +144,7 @@ def _deliver(self, delivery: Iterable[Message]) -> bool: self._saving and self._thread is not None # thread exists. and not DefaultMessageTypes.is_protocol_type(item) # not a protocol type message. - and not item.pack + and not item.chunk ): # is tail package. # append tail message to thread. self._thread.append(item) diff --git a/ghostos/framework/streams/queuestream.py b/ghostos/framework/streams/queuestream.py index 5d147215..86850617 100644 --- a/ghostos/framework/streams/queuestream.py +++ b/ghostos/framework/streams/queuestream.py @@ -30,7 +30,7 @@ def deliver(self, pack: "Message") -> bool: self._queue.task_done() self._queue.put(pack, block=True) return True - elif self._streaming and not pack.is_tail(): + elif self._streaming and not pack.is_done(): # 不发送间包, 只发送尾包. return True else: diff --git a/tests/core/messages/test_messages.py b/tests/core/messages/test_messages.py index d8e30557..476c4165 100644 --- a/tests/core/messages/test_messages.py +++ b/tests/core/messages/test_messages.py @@ -5,7 +5,7 @@ def test_text_msg(): - msg = Message.new_tail(role=Role.SYSTEM, content="hello") + msg = Message.new_done(role=Role.SYSTEM, content="hello") assert msg.content == "hello" assert len(msg.msg_id) > 0 assert msg.created > 0 @@ -23,7 +23,7 @@ def test_message_basic_merge(): msg = Message.new_head(role="assistant") for c in string: - msg = msg.patch(msg.new_pack(content=c, role="assistant")) + msg = msg.patch(msg.new_chunk(content=c, role="assistant")) assert msg.content == "hello world" @@ -31,10 +31,10 @@ def test_message_with_full_type(): msg = Message.new_head() content = "hello world" for c in content: - msg = msg.patch(msg.new_pack(content=c)) + msg = msg.patch(msg.new_chunk(content=c)) last = msg.model_copy(update=dict(content="good")) - last.pack = False + last.chunk = False buffed = msg.patch(last) assert buffed is not None and buffed.content == "good" @@ -46,7 +46,7 @@ def test_head_is_not_empty(): def test_head_pack_patch(): msg = Message.new_head(content="a") - patch = msg.patch(Message.new_pack(content="b")) + patch = msg.patch(Message.new_chunk(content="b")) assert patch is not None assert patch.content == "ab" @@ -54,14 +54,14 @@ def test_head_pack_patch(): def test_tail_patch(): msg = Message.new_head(content="") for c in "hello": - pack = Message.new_pack(content=c) + pack = Message.new_chunk(content=c) patch = msg.patch(pack) assert patch is not None - tail = Message.new_tail(content=" world") + tail = Message.new_done(content=" world") patch = msg.patch(tail) assert patch is None - tail = Message.new_tail(content=" world", msg_id=msg.msg_id) + tail = Message.new_done(content=" world", msg_id=msg.msg_id) patch = msg.patch(tail) assert patch is not None assert patch.content == " world" @@ -69,12 +69,12 @@ def test_tail_patch(): def test_patch_default_type_message(): msg = Message.new_head(typ_="kind") - patch = msg.patch(Message.new_pack(content="c", typ_="")) + patch = msg.patch(Message.new_chunk(content="c", typ_="")) assert patch is not None - patch = msg.patch(Message.new_pack(content="c", typ_="kind")) + patch = msg.patch(Message.new_chunk(content="c", typ_="kind")) assert patch is not None - pack = Message.new_pack(content="c", typ_="foo") + pack = Message.new_chunk(content="c", typ_="foo") assert pack.type == "foo" patch = msg.patch(pack) assert patch is None diff --git a/tests/framework/messages/test_buffer.py b/tests/framework/messages/test_buffer.py index 722beee0..e60565ab 100644 --- a/tests/framework/messages/test_buffer.py +++ b/tests/framework/messages/test_buffer.py @@ -22,7 +22,7 @@ def test_default_buffer_baseline(): assert i == 1 for c in content1: - pack = Message.new_pack(content=c) + pack = Message.new_chunk(content=c) sent = buffer.buff(pack) for item in sent: buffer2.buff(item) @@ -36,7 +36,7 @@ def test_default_buffer_baseline(): buffer2.buff(new_head) for c in content2: - pack = Message.new_pack(content=c) + pack = Message.new_chunk(content=c) buffer2.buff(pack) buffed = buffer2.flush() @@ -58,7 +58,7 @@ def test_functional_token_baseline(): """ for c in content: - msg = Message.new_pack(content=c) + msg = Message.new_chunk(content=c) buffer.buff(msg) flushed = buffer.flush() @@ -76,7 +76,7 @@ def test_buffer_sent(): count_has_message_id = 0 for c in content: - msg = Message.new_pack(content=c) + msg = Message.new_chunk(content=c) sent = buffer.buff(msg) for i in sent: assert not i.is_empty() @@ -93,14 +93,14 @@ def test_buffer_sent_one_tail(): content = "hello world" tails = 0 for c in content: - msg = Message.new_pack(content=c) + msg = Message.new_chunk(content=c) sent = buffer.buff(msg) for i in sent: - if not i.pack: + if not i.chunk: tails += 1 buffed = buffer.flush() for i in buffed.unsent: - if not i.pack: + if not i.chunk: tails += 1 assert tails == 1 @@ -123,7 +123,7 @@ def test_buffer_with_moss_token(): content = "好的,我会帮你播放这首歌。\n\n>moss:\ndef main(os: MOSS) -> Operator:\n # Search for the song \"七里香\" by 周杰伦\n song_list = os.player.search(\"\", \"周杰伦\", \"七里香\")\n \n # Check if the song is found\n if \"七里香\" in song_list:\n # Play the song\n playing = os.player.play(\"七里香\")\n \n # Check if the song is playing\n if playing:\n return\n os.mindflow.finish(\"正在播放周杰伦的《七里香》。\")\n else:\n return os.mindflow.fail(\"无法播放周杰伦的《七里香》。\")\n else:\n return os.mindflow.fail(\"未找到周杰伦的《七里香》。\")" for c in content: - p = Message.new_pack(content=c) + p = Message.new_chunk(content=c) buffer.buff(p) buffed = buffer.flush() assert len(buffed.messages) == 1 @@ -143,7 +143,7 @@ def test_buffer_with_sep_content(): contents = ["he", "llo >mo", "ss: w", "orld"] content = "".join(contents) for c in contents: - msg = Message.new_pack(content=c) + msg = Message.new_chunk(content=c) buffer.buff(msg) flushed = buffer.flush() assert len(flushed.messages) == 1 @@ -168,9 +168,9 @@ def test_buffer_with_tail_item(): buffer.buff(header) content = "hello" for c in content: - msg = Message.new_pack(content=c) + msg = Message.new_chunk(content=c) buffer.buff(msg) - tail = Message.new_tail(content="hello world", msg_id=header.msg_id) + tail = Message.new_done(content="hello world", msg_id=header.msg_id) buffer.buff(tail) flushed = buffer.flush() assert len(flushed.messages) == 1 @@ -183,9 +183,9 @@ def test_buffer_header_with_payload(): header.payloads["foo"] = {} buffer.buff(header) content = "hello" - buffer.buff(Message.new_pack(content="")) + buffer.buff(Message.new_chunk(content="")) for c in content: - msg = Message.new_pack(content=c) + msg = Message.new_chunk(content=c) buffer.buff(msg) flushed = buffer.flush() assert len(flushed.messages) == 1 @@ -205,7 +205,7 @@ def test_buffer_with_xml_functional_token(): contents = ["he", "llo w", "orld'] content = "".join(contents) for c in contents: - msg = Message.new_pack(content=c) + msg = Message.new_chunk(content=c) buffer.buff(msg) flushed = buffer.flush() assert len(flushed.messages) == 1 diff --git a/tests/framework/messenger/test_messenger.py b/tests/framework/messenger/test_messenger.py index c7ed7263..12c2f4a4 100644 --- a/tests/framework/messenger/test_messenger.py +++ b/tests/framework/messenger/test_messenger.py @@ -9,7 +9,7 @@ def test_default_messenger_baseline(): messenger = DefaultMessenger(thread=thread) content = "hello world" for c in content: - msg = Message.new_pack(content=c) + msg = Message.new_chunk(content=c) success = messenger.deliver(msg) assert success messenger.flush() @@ -31,7 +31,7 @@ def test_messenger_with_moss_xml_token(): contents = ["he", "llo >mo", "ss: w", "orld"] content = "".join(contents) for c in contents: - msg = Message.new_pack(content=c) + msg = Message.new_chunk(content=c) messenger.deliver(msg) flushed = messenger.flush() assert len(list(flushed.callers)) > 0 diff --git a/tests/helpers/test_modules.py b/tests/helpers/test_modules_helper.py similarity index 100% rename from tests/helpers/test_modules.py rename to tests/helpers/test_modules_helper.py diff --git a/tests/python/test_module.py b/tests/python/test_py_module.py similarity index 100% rename from tests/python/test_module.py rename to tests/python/test_py_module.py diff --git a/tests/python/test_tree_sitter.py b/tests/python/test_py_tree_sitter.py similarity index 100% rename from tests/python/test_tree_sitter.py rename to tests/python/test_py_tree_sitter.py From 104525a58179ef13d934b4e74ffa88ff1949fa45 Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 11 Oct 2024 00:48:33 +0800 Subject: [PATCH 030/148] dev: messenger comments and modifications --- ghostos/core/aifunc/executor.py | 6 +- ghostos/core/aifunc/interfaces.py | 13 +- ghostos/core/aifunc/repository.py | 1 + ghostos/core/llms/llm.py | 6 +- ghostos/core/messages/buffers.py | 31 ++- ghostos/core/messages/helpers.py | 2 + ghostos/core/messages/message.py | 207 +++++++++++++----- ghostos/core/messages/openai.py | 20 +- ghostos/core/messages/stream.py | 24 ++- ghostos/core/session/messenger.py | 48 ++--- ghostos/framework/llms/openai_driver.py | 2 +- ghostos/framework/messages/buffers.py | 19 +- ghostos/framework/messengers/__init__.py | 1 + ghostos/framework/messengers/defaults.py | 223 +++++++++++++------- ghostos/framework/session/basic.py | 4 +- ghostos/framework/streams/__init__.py | 2 +- ghostos/framework/streams/empty.py | 11 +- ghostos/framework/streams/queuestream.py | 20 +- tests/core/messages/test_messages.py | 8 +- tests/framework/messages/test_buffer.py | 2 +- tests/framework/messenger/test_messenger.py | 49 ++++- 21 files changed, 466 insertions(+), 233 deletions(-) diff --git a/ghostos/core/aifunc/executor.py b/ghostos/core/aifunc/executor.py index 8af6240f..105750a6 100644 --- a/ghostos/core/aifunc/executor.py +++ b/ghostos/core/aifunc/executor.py @@ -9,7 +9,7 @@ from ghostos.core.aifunc.func import AIFunc, AIFuncResult, get_aifunc_result_type from ghostos.core.aifunc.interfaces import AIFuncExecutor, AIFuncCtx, AIFuncDriver, ExecFrame, ExecStep from ghostos.core.aifunc.driver import DefaultAIFuncDriverImpl -from ghostos.core.messages import Stream +from ghostos.core.messages import Stream, DefaultMessageTypes __all__ = ['DefaultAIFuncExecutorImpl', 'DefaultAIFuncExecutorProvider'] @@ -112,6 +112,10 @@ def execute( if result is not None and not isinstance(result, AIFuncResult): result_type = get_aifunc_result_type(type(fn)) raise RuntimeError(f"result is invalid AIFuncResult {type(result)}, expecting {result_type}") + + # if frame is the root, send final message as protocol + if upstream and frame.depth == 0: + upstream.send(DefaultMessageTypes.final()) return result def get_driver( diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index ac0eaabe..4d8da838 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, Tuple, Dict, Type, List, Iterable +from typing import Any, Optional, Tuple, Dict, Type, List, Iterable, Callable from abc import ABC, abstractmethod from ghostos.core.aifunc.func import AIFunc, AIFuncResult from ghostos.core.moss.decorators import cls_source_code @@ -189,6 +189,17 @@ def execute( """ pass + def new_exec_frame(self, fn: AIFunc, upstream: Optional[Stream]) -> Tuple[ExecFrame, Callable[[], AIFuncResult]]: + """ + syntax sugar + """ + frame = ExecFrame.from_func(fn) + + def execution() -> AIFuncResult: + return self.execute(fn, frame, upstream) + + return frame, execution + @abstractmethod def sub_executor(self, step: ExecStep, upstream: Optional[Stream] = None) -> "AIFuncExecutor": """ diff --git a/ghostos/core/aifunc/repository.py b/ghostos/core/aifunc/repository.py index 6c3b0f34..d9a5ebcd 100644 --- a/ghostos/core/aifunc/repository.py +++ b/ghostos/core/aifunc/repository.py @@ -114,6 +114,7 @@ def factory(self, con: Container) -> Optional[AIFuncRepository]: configs = con.force_fetch(Configs) modules = con.force_fetch(Modules) conf = configs.get(AIFuncsConf) + conf.validated_at = int(time.time()) return AIFuncRepoByConfigs(conf, configs, modules) diff --git a/ghostos/core/llms/llm.py b/ghostos/core/llms/llm.py index 7ec9ec84..b73ac4b0 100644 --- a/ghostos/core/llms/llm.py +++ b/ghostos/core/llms/llm.py @@ -60,9 +60,9 @@ def deliver_chat_completion(self, chat: Chat, deliver: Stream) -> None: """ 逐个发送消息的包. """ - if not deliver.is_streaming(): + if not deliver.accept_chunks(): message = self.chat_completion(chat) - if message.is_done(): + if message.is_complete(): # add model conf as message payload self.get_model().set(message) deliver.deliver(message) @@ -70,7 +70,7 @@ def deliver_chat_completion(self, chat: Chat, deliver: Stream) -> None: items = self.chat_completion_chunks(chat) # todo: payload 要计算 tokens for item in items: - if item.is_done(): + if item.is_complete(): # add model conf as message payload self.get_model().set(item) deliver.deliver(item) diff --git a/ghostos/core/messages/buffers.py b/ghostos/core/messages/buffers.py index ff123128..8337a672 100644 --- a/ghostos/core/messages/buffers.py +++ b/ghostos/core/messages/buffers.py @@ -10,39 +10,36 @@ class Flushed(NamedTuple): unsent: Iterable[Message] - """ buffer 尚未发送, 需要继续发送出去的包""" + """ the unsent messages or chunks, which were buffed""" messages: List[Message] - """经过 buff, 生成的包""" + """all the patched complete messages""" callers: List[Caller] - """消息体产生的回调方法.""" + """all the callers that delivered""" class Buffer(ABC): """ - 在流式传输中拦截 message 的拦截器. 同时要能完成粘包, 返回粘包后的结果. + a container to buff streaming Message chunks, + and patched all the chunks, + return complete patched messages after flushed. + 在流式传输中拦截 message 的拦截器. 同时要能完成粘包, 最终返回粘包后的结果. """ - @abstractmethod - def match(self, message: Message) -> bool: - """ - 匹配一个消息体. - """ - pass - @abstractmethod def buff(self, pack: "Message") -> List[Message]: """ - buff 一个消息体, 然后决定是否对外发送. - 不能用 Iterable 返回, 如果上层不处理, 就会导致没有 buff. + try to buff a message pack + :return: the sending messages after the buffing. may be: + 1. the input pack, which need not be buffered + 2. the unsent packs, which are replaced by new buffing pack. """ pass - @abstractmethod - def new(self) -> "Buffer": - pass - @abstractmethod def flush(self) -> Flushed: + """ + flush the buffered messages, and reset itself. + """ pass diff --git a/ghostos/core/messages/helpers.py b/ghostos/core/messages/helpers.py index d8a4a737..79eecb39 100644 --- a/ghostos/core/messages/helpers.py +++ b/ghostos/core/messages/helpers.py @@ -11,3 +11,5 @@ def copy_messages(messages: Iterable[Message]) -> List[Message]: for message in messages: result.append(message.model_copy(deep=True)) return result + +# seems at last not so many helper function are made.... diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 2fe84c76..eb001062 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -1,6 +1,7 @@ import enum import time from typing import Optional, Dict, Set, Iterable, Union, List, ClassVar +from typing_extensions import Self from abc import ABC, abstractmethod from pydantic import BaseModel, Field from ghostos.helpers import uuid @@ -43,7 +44,7 @@ def new( name: Optional[str] = None, type_: Optional[str] = None, ) -> "Message": - return Message.new_done( + return Message.new_tail( type_=type_ if type_ else DefaultMessageTypes.DEFAULT.value, role=self.value, name=name, @@ -180,39 +181,89 @@ def add(self, message: "Message") -> None: message.attachments[self.key] = values -# 消息体的容器. 通用的抽象设计, 设计思路: -# 1. message 可以是一个完整的消息, 也可以是一个包, 用 pack 字段做区分. 支持 dict 传输, dict 传输时不包含默认值. -# 2. 完整的 message 需要有 msg_id, 但包可以没有. -# 3. content 是对客户端展示用的消息体, 而 memory 是对大模型展示的消息体. 两者可能不一样. -# 4. message 可以有强类型字段, 比如 images, 但通过 attachments (累加) 和 payload (替代) 来定义. Message 容器里放弱类型的 dict. -# 5. type 字段用来提示 message 拥有的信息. 比如 images 消息, 会包含 images payload, 但同时也会指定 type. 这样方便解析时预判. -# 6. 所有的 message 都需要能转换成模型的协议, 默认要对齐 openai 的协议. -# 7. openai 协议中的 tool, function_call 统一成 caller 抽象, 通过 caller.id 来做区分. -# 8. 流式传输中, 可以有首包和尾包. 首包期待包含全部的 payloads 和 attachments. 间包则可选. 尾包是完整的消息体. +# the Message class is a container for every kind of message and it's chunks. +# I need this container because: +# 1. I hate weak-type container of message, countless type checking and adapting +# 2. I have not found a community-accepted message protocol for Ai Model messages. +# So I developed this wheel, may be a bad move, but happy to replace it with a mature library someday. +# +# 这个消息类是各种消息类型的一个通用容器. +# 我需要一个这样的容器是因为: +# 1. 讨厌弱类型消息, 需要做无数的校验和适配, 缺乏规则. 比如 OpenAI 的那个极其复杂的 dict. +# 2. 我没找到一个社区广泛使用的标准消息协议. +# 所以重复造了这个轮子, 如果未来发现了成熟的库, 要果断取代掉它. 为此全链路对 Message 的依赖要控制好. +# 把 Message 用于创建消息的地方, 很难修改. 但它作为传输时的 item, 是可以替代的. +# +# the basic logic of this container: +# 1. Message instance could be a complete message, or a chunk. +# 2. I can parse Message to dict/json/serialized data, and unpack a Message from them. +# the complete Message instance must have msg_id for tracking, but the chunk does not. +# 3. I need a message has a default protocol to show it to User/Agent differently. +# so this container has two field, content(to user) and memory (to llm). +# 4. the basic information of message are strong typed, but dynamic payloads or attachments have a certain way to parse. +# 5. both client side and server side can define it own parser with message type. +# 6. each type of message can either be parsed to LLM Message (like OpenAI Message), or ignore. +# 7. define a common action caller for LLM, compatible for JSONSchema Tool, function call or FunctionalTokens. +# 8. the streaming chunks always have a head package (introduce following chunks), +# and a tail package (the complete message). +# +# 基本设计逻辑: +# 1. Message 既可以是一个完整的消息, 也可以是一个间包. 它们通常有相同的结构. +# 2. 可以用 dict/json/别的序列化协议 传输它, 也可以从这些协议反解. 因此用了 pydantic. +# 完整的消息体必须有 msg_id, 但中间包不需要它. +# 3. 消息对客户端和 AI 模型的展示方式可以不一样. 所以有 content 和 memory 字段的区分. +# 4. 消息的基础信息是强类型的, 那些动态类型的信息可以通过确定的方式反解. +# 5. 客户端和服务端可以根据需要, 定义自己的消息转义协议. +# 6. 所有的完整消息要么能被解析成模型的消息, 要么就应该忽略它. 避免展示加工不了的. +# 7. 用一个 caller 兼容各种模型的 action caller. +# 8. 流式传输的消息包, 应该有 首包 / 间包 / 尾包. 尾包是一个粘包后的完整包. class Message(BaseModel): - """标准的消息体.""" + """ message protocol """ - msg_id: str = Field(default="", description="消息的全局唯一 id. ") - ref_id: Optional[str] = Field(default=None, description="消息的关联目标. 如果 role 是 tool, 则这个是 tool_call_id") - type: str = Field(default="", description="消息类型是对 payload 的约定. 默认的 type就是 text.") - created: float = Field(default=0.0, description="Message creation time") - chunk: bool = Field(default=True, description="Message reset time") + msg_id: str = Field(default="", description="unique message id. ") + ref_id: Optional[str] = Field(default=None, description="the referenced message id.") + type: str = Field(default="", description="default message type, if empty, means text") + created: float = Field( + default=0, + description="Message creation time, only available in head chunk or complete one", + ) + chunk: bool = Field(default=True, description="if the message is a chunk or a complete one") role: str = Field(default=Role.ASSISTANT.value, description="Message role", enum=Role.all()) name: Optional[str] = Field(default=None, description="Message sender name") - content: Optional[str] = Field(default=None, description="Message content") - memory: Optional[str] = Field(default=None, description="Message memory") + content: Optional[str] = Field( + default=None, + description="Message content that for client side. empty means it shall not be showed", + ) + memory: Optional[str] = Field( + default=None, + description="Message memory that for llm, if none, means content is memory", + ) # --- attachments --- # - payloads: Dict[str, Dict] = Field(default_factory=dict, description="k/v 结构的强类型参数.") - attachments: Dict[str, List[Dict]] = Field(default_factory=dict, description="k/list[v] 类型的强类型参数.") + payloads: Dict[str, Dict] = Field( + default_factory=dict, + description="payload type key to payload item. payload shall be a strong-typed dict" + ) + attachments: Dict[str, List[Dict]] = Field( + default_factory=dict, + description="attachment type key to attachment items. attachment shall be a strong-typed dict", + ) - callers: List[Caller] = Field(default_factory=list, description="将 callers 作为一种单独的类型. ") + callers: List[Caller] = Field( + default_factory=list, + description="the callers parsed in a complete message." + ) - pack_count: int = Field(default=0, description="pack count") - time_cast: float = Field(default=0.0, description="from first pack to last pack") + chunk_count: int = Field(default=0, description="how many chunks of this complete message") + time_cast: float = Field(default=0.0, description="from first chunk to tail message.") + + streaming_id: Optional[str] = Field( + default=None, + description="may be multiple streaming exists, use streaming id to separate them into a order", + ) @classmethod def new_head( @@ -226,6 +277,18 @@ def new_head( ref_id: Optional[str] = None, created: int = 0, ): + """ + create a head chunk message + :param role: + :param typ_: + :param content: + :param memory: + :param name: + :param msg_id: + :param ref_id: + :param created: + :return: + """ if msg_id is None: msg_id = uuid() if created <= 0: @@ -238,7 +301,7 @@ def new_head( ) @classmethod - def new_done( + def new_tail( cls, *, type_: str = "", role: str = Role.ASSISTANT.value, @@ -249,6 +312,18 @@ def new_done( ref_id: Optional[str] = None, created: int = 0, ): + """ + create a tail message, is the complete message of chunks. + :param type_: + :param role: + :param content: + :param memory: + :param name: + :param msg_id: + :param ref_id: + :param created: + :return: + """ msg = cls.new_head( role=role, name=name, content=content, memory=memory, typ_=type_, @@ -268,42 +343,60 @@ def new_chunk( memory: Optional[str] = None, name: Optional[str] = None, ): + """ + create a chunk message. + :param typ_: + :param role: + :param content: + :param memory: + :param name: + :return: + """ return cls( role=role, name=name, content=content, memory=memory, chunk=True, type=typ_, ) def get_content(self) -> str: + """ + get content of this message that is showed to model + if result is empty, means do not show it to model. + """ if self.memory is None: return self.content if self.content else "" return self.memory - def patch(self, pack: "Message") -> Optional["Message"]: + def patch(self, chunk: "Message") -> Optional["Message"]: """ - 预期目标消息是当前消息的一个后续包, 执行粘包逻辑. - :param pack: - :return: 如果粘包成功, 返回粘包后的消息. 粘包失败, 则返回 None. + patch a chunk to the current message until get a tail message or other message's chunk + :param chunk: the chunk to patch. + :return: if patch succeeds, return the patched message. None means it is other message's chunk """ - # type 不相同的话, 则认为是不同消息. - pack_type = pack.get_type() + # if the type is not same, it can't be patched + pack_type = chunk.get_type() if pack_type and pack_type != self.get_type(): return None - # 如果两个消息的 msg id 都存在, 又不相同, 则认为是不同的消息. - if pack.msg_id and self.msg_id and pack.msg_id != self.msg_id: + # the chunk message shall have the same message id or empty one + if chunk.msg_id and self.msg_id and chunk.msg_id != self.msg_id: return None - # 如果目标包是一个尾包, 则直接返回这个尾包. - if not pack.chunk: - return pack - # 否则更新当前消息. - self.update(pack) + # if not a chunk, just return the tail message. + # tail message may be changed by outside method such as moderation. + if not chunk.chunk: + return chunk + # otherwise, update current one. + self.update(chunk) return self def get_copy(self) -> "Message": + """ + :return: deep copy + """ return self.model_copy(deep=True) def update(self, pack: "Message") -> None: """ - 使用目标消息更新当前消息. + update the fields. + do not call this method outside patch unless you know what you are doing """ if not self.msg_id: # 当前消息的 msg id 不会变更. @@ -333,38 +426,42 @@ def update(self, pack: "Message") -> None: self.attachments[key] = saved if pack.callers: self.callers.extend(pack.callers) - self.pack_count += 1 + self.chunk_count += 1 if self.created: now = round(time.time(), 4) self.time_cast = round(now - self.created, 4) def get_type(self) -> str: """ - 返回消息的类型. + return a message type """ return self.type or DefaultMessageTypes.DEFAULT def is_empty(self) -> bool: """ - 根据协议判断是不是空消息. + a message is empty means it has no content, payloads, callers, or attachments """ no_content = not self.content and not self.memory no_payloads = not self.payloads and not self.attachments and not self.callers return no_content and no_payloads - def is_done(self) -> bool: + def is_complete(self) -> bool: + """ + complete message is not a chunk one + """ return not self.chunk def dump(self) -> Dict: """ - 将消息以 dict 形式输出, 过滤掉默认值. + dump a message dict without default value. """ return self.model_dump(exclude_defaults=True) class MessageClass(ABC): """ - 一种特殊的 Message, 本体是强类型数据结构, 映射到 Message 类型中解决 payloads 等参数问题. + A message class with every field that is strong-typed + the payloads and attachments shall parse to dict when generate to a Message. """ @abstractmethod @@ -373,17 +470,22 @@ def to_message(self) -> Message: @classmethod @abstractmethod - def from_message(cls) -> Optional[Message]: + def from_message(cls, container: Message) -> Optional[Self]: + """ + from a message container generate a strong-typed one. + :param container: + :return: None means type not match. + """ pass MessageKind = Union[Message, MessageClass, str] -"""将三种类型的数据统一视作 message 类型. """ +"""sometimes we need three forms of the message to define an argument or property.""" class MessageKindParser: """ - 处理 MessageType + middleware that parse weak MessageKind into Message chunks """ def __init__(self, role: str = Role.ASSISTANT.value, ref_id: Optional[str] = None) -> None: @@ -395,13 +497,13 @@ def parse(self, messages: Iterable[MessageKind]) -> Iterable[Message]: if isinstance(item, Message): yield self._with_ref(item) if isinstance(item, MessageClass): - msg= item.to_message() + msg = item.to_message() yield self._with_ref(msg) if isinstance(item, str): if not item: # exclude empty message continue - msg = Message.new_done(content=item, role=self.role) + msg = Message.new_tail(content=item, role=self.role) yield self._with_ref(msg) else: # todo: 需要日志? @@ -411,10 +513,3 @@ def _with_ref(self, item: Message) -> Message: if self.ref_id is not None: item.ref_id = self.ref_id return item - - def unknown(self, item) -> None: - """ - unknown 消息类型的处理逻辑. - 默认忽视, 可以重写这个方法. - """ - return diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index fcd5ac69..27223767 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -23,19 +23,21 @@ class OpenAIMessageParser(ABC): """ - 用来对齐 openai 的协议. + a parser for OpenAI messages alignment. """ @abstractmethod def parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: """ - 将 message 转换为 openai 的请求入参. + parse a Message to OpenAI chat completion message form. + OpenAI's input message (ChatCompletionXXXParam) are different to ChatCompletion types, + which is exhausting """ pass def parse_message_list(self, messages: Iterable[Message]) -> Iterable[ChatCompletionMessageParam]: """ - 将多条消息转换成 openai 的多条入参. + syntax suger """ for message in messages: items = self.parse_message(message) @@ -45,21 +47,23 @@ def parse_message_list(self, messages: Iterable[Message]) -> Iterable[ChatComple @abstractmethod def from_chat_completion(self, message: ChatCompletionMessage) -> Message: """ - 将 openai chat completion 转换. + parse a ChatCompletion message to Message. + Request -> Message -> ChatCompletionXXXXParam --LLM generation--> ChatCompletionXXX --> Message """ pass @abstractmethod def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) -> Iterable[Message]: """ - 将 openai 的 delta 转换过来. + patch the openai Chat Completion Chunks. + the Realtime API need a new parser. """ pass class CompletionUsagePayload(CompletionUsage, PayloadItem): """ - 将每个包的开销记录下来. + the strong-typed payload of OpenAI chat completion usage. """ key: ClassVar[str] = "completion_usage" @@ -81,7 +85,7 @@ def join(self, payload: "CompletionUsagePayload") -> "CompletionUsagePayload": class DefaultOpenAIMessageParser(OpenAIMessageParser): """ - 默认的 parser, 只做了极简的实现. + default implementation of OpenAIMessageParser """ def parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: @@ -159,7 +163,7 @@ def _parse_assistant_chat_completion(message: Message) -> Iterable[ChatCompletio )] def from_chat_completion(self, message: ChatCompletionMessage) -> Message: - pack = Message.new_done(type_=DefaultMessageTypes.CHAT_COMPLETION, role=message.role, content=message.content) + pack = Message.new_tail(type_=DefaultMessageTypes.CHAT_COMPLETION, role=message.role, content=message.content) if message.function_call: caller = Caller( name=message.function_call.name, diff --git a/ghostos/core/messages/stream.py b/ghostos/core/messages/stream.py index f216743f..fe1fa9bf 100644 --- a/ghostos/core/messages/stream.py +++ b/ghostos/core/messages/stream.py @@ -9,33 +9,41 @@ class Stream(ABC): """ - messenger 的原型. Stream 的有状态版本. + streaming output messages. """ @abstractmethod def deliver(self, pack: "Message") -> bool: """ - 发送一个包. + deliver a pack of message, may be a chunk + if an error message or a final message is delivering, the stream usually stop immediately. + but nesting stream can accept multiple final messages, only stop when it's done method is called. + :return: if the message was delivered. if the stream is stopped, return False. """ pass @abstractmethod - def is_streaming(self) -> bool: + def accept_chunks(self) -> bool: """ - if not streaming, only receive tail message + weather the stream is sending chunks. + if False, the stream will send joined chunks as a single message only. """ pass - @abstractmethod def send(self, messages: Iterable[Message]) -> bool: """ - 发送消息. + syntax sugar for delivering """ - pass + for item in messages: + ok = self.deliver(item) + if not ok: + # break sending + return False + return True @abstractmethod def stopped(self) -> bool: """ - 是否已经停止接受 + if the stream is stopped. """ pass diff --git a/ghostos/core/session/messenger.py b/ghostos/core/session/messenger.py index 2d5454b8..e314a9e3 100644 --- a/ghostos/core/session/messenger.py +++ b/ghostos/core/session/messenger.py @@ -10,44 +10,25 @@ class Buffed(NamedTuple): - messages: List["Message"] - """已经向上游发送的消息""" + messages: List[Message] + """ the sent messages, all chunks are joined""" - callers: List["Caller"] - """过滤出来的 caller. """ + callers: List[Caller] + """ the parsed callers from sent message""" class Messenger(Stream, ABC): """ - Messenger 是流式传输消息的桥梁. - 通过 messenger 发送完消息后, 需要执行 done 方法. - 它可以通过 downstream 方法生成下级 messenger + Messenger is a bridge of message streams + Messenger finish when the flush method is called. + Each messenger can nest sub messengers, when sub messenger is finished, + the parent messenger is not finished until the flush is called. + + why this is an abstract base class? + there may be more abilities during streaming are needed, + this project can only provide a basic one. """ - @abstractmethod - def new( - self, *, - sending: bool = True, - thread: Optional[MsgThread] = None, - name: Optional[str] = None, - buffer: Optional[Buffer] = None, - payloads: Optional[Iterable[Payload]] = None, - attachments: Optional[Iterable[Attachment]] = None, - functional_tokens: Optional[Iterable[FunctionalToken]] = None - ) -> "Messenger": - """ - 生成一个新的 Messenger 供发送消息使用. 发送完应该调用 flush 方法. - :param sending: 消息是否向上游发送. 为 false 的话不会真正对上游发送. - :param thread: 如果传入了 thread, 在 flush 时会自动将消息保存到 thread 内. - :param name: 所有的消息体默认都添加 name. - :param buffer: 自定义 buffer, 也可以用于过滤消息. - :param payloads: 消息默认添加的 payloads. - :param attachments: 消息默认添加的 attachments. - :param functional_tokens: 是否添加 functional tokens. - :return: 返回一个新的 messenger. - """ - pass - def say(self, content: str): """ syntactic sugar @@ -58,7 +39,8 @@ def say(self, content: str): @abstractmethod def flush(self) -> Tuple[List[Message], List[Caller]]: """ - 将过程中发送的消息进行粘包, 并返回粘包后的结果. - 运行完 done, 会中断后续的输出. + flush the buffed messages, finish the streaming of this messenger. + the message buffer shall join all the chunks to message item. + after the messenger is flushed, it can not send any new message. """ pass diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index 80321a2e..d8a631b3 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -154,7 +154,7 @@ def chat_completion(self, chat: Chat) -> Message: usage = CompletionUsagePayload.from_usage(message.usage) usage.set(pack) - if not pack.is_done(): + if not pack.is_complete(): pack.chunk = False return pack diff --git a/ghostos/framework/messages/buffers.py b/ghostos/framework/messages/buffers.py index 12da6845..15c4c045 100644 --- a/ghostos/framework/messages/buffers.py +++ b/ghostos/framework/messages/buffers.py @@ -48,6 +48,8 @@ def __init__( self._functional_token_chars: Dict[int, Set[str]] = {} """ functional token 的字符组.. """ + self._destroyed = False + if functional_tokens: for ft in functional_tokens: start = ft.token @@ -93,7 +95,7 @@ def buff(self, pack: "Message") -> List[Message]: result = [] for item in items: # 如果是尾包, 对尾包进行必要的处理. - is_tail = item.is_done() + is_tail = item.is_complete() if is_tail: self._buff_tail_pack(item) result.append(item) @@ -108,7 +110,7 @@ def _buff(self, pack: "Message") -> Iterable[Message]: # final 包不进行 buffer. yield pack return - if pack.is_done(): + if pack.is_complete(): # 如果收到了一个尾包, 则走尾包逻辑. yield from self._receive_tail_pack(pack) return @@ -217,7 +219,7 @@ def _parse_content_by_functional_token(self, pack: "Message") -> "Message": # 输出的消息会缓存到一起. self._buffering_message_delivered_content += deliver_content # 结算环节, 变更 pack 可以输出的 content. - if pack.is_done() and pack.content != self._buffering_message_delivered_content: + if pack.is_complete() and pack.content != self._buffering_message_delivered_content: pack.memory = pack.content pack.content = deliver_content return pack @@ -357,3 +359,14 @@ def flush(self) -> Flushed: self._buffed_messages = [] self._buffed_callers = [] return flushed + + def destroy(self) -> None: + if self._destroyed: + return + self._destroyed = True + del self._buffering_message + del self._buffering_message_delivered_content + del self._buffering_token + del self._functional_token_starts + del self._origin_functional_tokens + del self._functional_token_ends diff --git a/ghostos/framework/messengers/__init__.py b/ghostos/framework/messengers/__init__.py index 13f547a9..5ad06f9e 100644 --- a/ghostos/framework/messengers/__init__.py +++ b/ghostos/framework/messengers/__init__.py @@ -1 +1,2 @@ +from ghostos.core.session import Messenger from ghostos.framework.messengers.defaults import DefaultMessenger, TestMessengerProvider diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index f9e4ad97..738cea4b 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -1,4 +1,4 @@ -from typing import Optional, Iterable, TYPE_CHECKING, Type +from typing import Optional, Iterable, TYPE_CHECKING, Type, Dict, List from ghostos.container import Container, Provider from ghostos.core.session.messenger import Messenger, Buffed from ghostos.core.messages import ( @@ -8,6 +8,8 @@ from ghostos.core.session.threads import MsgThread from ghostos.core.llms import FunctionalToken from ghostos.framework.messages.buffers import DefaultBuffer +from ghostos.helpers import uuid +from threading import Lock if TYPE_CHECKING: from ghostos.contracts.logger import LoggerItf @@ -24,6 +26,7 @@ class DefaultMessenger(Messenger, Stream): def __init__( self, *, + depth: int = 0, upstream: Optional[Stream] = None, thread: Optional["MsgThread"] = None, name: Optional[str] = None, @@ -47,7 +50,9 @@ def __init__( :param functional_tokens: 是否有需要处理的 functional tokens :param logger: """ + self._depth = depth self._thread: Optional[MsgThread] = thread + # self._streaming_id: str = uuid() self._name = name self._logger = logger self._role = role if role else Role.ASSISTANT.value @@ -68,78 +73,121 @@ def __init__( functional_tokens=self._functional_tokens, ) self._buffer: Buffer = buffer + self._accept_chunks = upstream.accept_chunks() if upstream else False + # self._sending_stream_id: Optional[str] = None + # self._sending_stream_buffer: Dict[str, List[Message]] = {} + self._destroyed: bool = False + self._locker = Lock() - def new( - self, *, - sending: bool = True, - thread: Optional[MsgThread] = None, - name: Optional[str] = None, - buffer: Optional[Buffer] = None, - payloads: Optional[Iterable[Payload]] = None, - attachments: Optional[Iterable[Attachment]] = None, - functional_tokens: Optional[Iterable[FunctionalToken]] = None, - ) -> "Messenger": - # payloads 完成初始化. - _payloads = None - if self._payloads is not None or payloads is not None: - payloads_map = {} - if self._payloads: - for payload in self._payloads: - payloads_map[payload.key] = payload - if payloads: - for payload in payloads: - payloads_map[payload.key] = payload - _payloads = payloads_map.values() - - # attachments 初始化. - _attachments = None - if self._attachments is not None or attachments is not None: - _attachments = [] - if self._attachments: - _attachments.extend(self._attachments) - if attachments: - _attachments.extend(attachments) - - # 如果能传输数据, 则传递上游的 upstream. - upstream = self._upstream if sending else None - thread = self._thread - functional_tokens = functional_tokens if functional_tokens else self._functional_tokens - messenger = DefaultMessenger( - upstream=upstream, - thread=thread, - name=self._name, - role=self._role, - buffer=buffer, - payloads=_payloads, - attachments=_attachments, - functional_tokens=functional_tokens, - ) - return messenger - - def is_streaming(self) -> bool: - if self._upstream is None: - return False - return self._upstream.is_streaming() + # def new( + # self, *, + # sending: bool = True, + # thread: Optional[MsgThread] = None, + # name: Optional[str] = None, + # buffer: Optional[Buffer] = None, + # payloads: Optional[Iterable[Payload]] = None, + # attachments: Optional[Iterable[Attachment]] = None, + # functional_tokens: Optional[Iterable[FunctionalToken]] = None, + # ) -> "Messenger": + # # payloads 完成初始化. + # # copy + # messenger = DefaultMessenger( + # depth=self._depth + 1, + # upstream=self._upstream, + # thread=thread, + # name=self._name, + # role=self._role, + # # buffer is None to sub manager + # buffer=buffer, + # payloads=payloads, + # attachments=attachments, + # functional_tokens=functional_tokens, + # ) + # return messenger + + def accept_chunks(self) -> bool: + return self._accept_chunks def deliver(self, pack: "Message") -> bool: if self.stopped(): return False - if not pack: - return False - # 下游返回 error, 会导致全链路的 messenger 因为 error 而停止. - # 所以 error 类型的消息, 链路里只能有一个. - if DefaultMessageTypes.ERROR.match(pack): - self._stop(pack) - return True - if DefaultMessageTypes.is_final(pack): + elif DefaultMessageTypes.is_final(pack): # 下游发送的 final 包, 上游会装作已经发送成功. return True + + with self._locker: + # 下游返回 error, 会导致全链路的 messenger 因为 error 而停止. + # 所以 error 类型的消息, 链路里只能有一个. + if DefaultMessageTypes.ERROR.match(pack): + # receive error pack will stop the current streaming. + self._stop(pack) + return True + # return self._map_or_deliver_by_streaming_id(pack) + return self._buff_then_deliver(pack) + + # def _map_or_deliver_by_streaming_id(self, pack: "Message") -> bool: + # """ + # use streaming id to buff or reduce messages. + # """ + # if self._depth > 0: + # return self._buff_then_deliver(pack) + # if self._sending_stream_id is None: + # self._sending_stream_id = pack.streaming_id + # + # if pack.streaming_id not in self._sending_stream_buffer: + # self._sending_stream_buffer[pack.streaming_id] = [] + # buffer = self._sending_stream_buffer[pack.streaming_id] + # buffer.append(pack) + # if self._sending_stream_id != pack.streaming_id: + # return True + # else: + # # reduce deliver + # return self._reduce_streaming_items() + + # def _reduce_streaming_items(self) -> bool: + # if self._sending_stream_id is not None: + # items = self._sending_stream_buffer[self._sending_stream_id] + # self._sending_stream_buffer[self._sending_stream_id] = [] + # last = None + # for item in items: + # success = self._buff_then_deliver(item) + # if not success: + # return False + # last = item + # if last and (last.is_complete() or DefaultMessageTypes.is_protocol_type(last)): + # print("\n+++`" + last.content + "`+++\n") + # del self._sending_stream_buffer[self._sending_stream_id] + # self._sending_stream_id = None + # # keep going + # return self._reduce_streaming_items() + # else: + # # still buffering + # return True + # elif len(self._sending_stream_buffer) == 0: + # # all items are sent + # self._sending_stream_id = None + # self._sending_stream_buffer = {} + # return True + # else: + # for key in self._sending_stream_buffer: + # self._sending_stream_id = key + # break + # return self._reduce_streaming_items() + + def _buff_then_deliver(self, pack: "Message") -> bool: delivery = self._buffer.buff(pack) - return self._deliver(delivery) + return self._deliver_to_upstream(delivery) - def _deliver(self, delivery: Iterable[Message]) -> bool: + def _deliver_to_upstream(self, delivery: Iterable[Message]) -> bool: + if self._stopped: + return False for item in delivery: + if not DefaultMessageTypes.is_protocol_type(item) and item.chunk and not self._accept_chunks: + continue + # 如果发送不成功, 直接中断. + # if self._depth == 0: + # item.streaming_id = None if ( self._saving and self._thread is not None # thread exists. @@ -148,27 +196,22 @@ def _deliver(self, delivery: Iterable[Message]) -> bool: ): # is tail package. # append tail message to thread. self._thread.append(item) - if self._upstream: - # 如果发送不成功, 直接中断. + + if self._upstream is not None: success = self._upstream.deliver(item) if not success: + # in case check upstream is stopped over and over again. + self._stopped = self._upstream.stopped() return False return True - def send(self, messages: Iterable[Message]) -> bool: - for item in messages: - success = self.deliver(item) - if not success: - return False - return True - - def flush(self) -> "Buffed": + def flush(self) -> Buffed: if self._stopped: return Buffed(messages=[], callers=[]) - buffed = self._buffer.flush() if buffed.unsent: - self._deliver(buffed.unsent) + self._deliver_to_upstream(buffed.unsent) + self._stop(None) return Buffed(messages=buffed.messages, callers=buffed.callers) def _stop(self, final: Optional[Message]) -> None: @@ -176,13 +219,38 @@ def _stop(self, final: Optional[Message]) -> None: 停止并且发送指定的 final 包. 如果没有指定, 则发送 DefaultTypes.final() """ self._stopped = True + if self._destroyed: + return if final is None or not DefaultMessageTypes.is_protocol_type(final): final = DefaultMessageTypes.final() - if self._upstream and not self._upstream.stopped(): - self._upstream.deliver(final) + self._deliver_to_upstream([final]) + self.destroy() def stopped(self) -> bool: - return self._stopped or (self._upstream is not None and self._upstream.stopped()) + if self._stopped: + return True + if self._upstream is None: + return False + if self._upstream.stopped(): + self._stopped = True + return self._stopped + + def destroy(self) -> None: + """ + I kind of don't trust python gc, let me help some + :return: + """ + if self._destroyed: + return + self._destroyed = True + del self._upstream + if self._buffer: + self._buffer.flush() + del self._buffer + del self._payloads + del self._attachments + del self._thread + del self._functional_tokens class TestMessengerProvider(Provider[Messenger]): @@ -198,3 +266,4 @@ def contract(self) -> Type[Messenger]: def factory(self, con: Container) -> Messenger: return DefaultMessenger() + diff --git a/ghostos/framework/session/basic.py b/ghostos/framework/session/basic.py index 871292a3..ab2ac4e8 100644 --- a/ghostos/framework/session/basic.py +++ b/ghostos/framework/session/basic.py @@ -211,8 +211,8 @@ def _do_fire_events(self) -> None: bus = self._eventbus main_task_id = process.main_task_id for e in self._firing_events: - # 异步进程需要通知. - notify = not self._upstream.is_streaming() or e.task_id != main_task_id + # all the sub-tasks need notification + notify = e.task_id != main_task_id self._logger.info(f"fire event {e.type}: eid {e.id}; task_id {e.task_id}") bus.send_event(e, notify) self._firing_events = [] diff --git a/ghostos/framework/streams/__init__.py b/ghostos/framework/streams/__init__.py index 86186b94..7765ab46 100644 --- a/ghostos/framework/streams/__init__.py +++ b/ghostos/framework/streams/__init__.py @@ -1,2 +1,2 @@ from ghostos.framework.streams.queuestream import QueueStream -from ghostos.framework.streams.empty import EmptyStream \ No newline at end of file +from ghostos.framework.streams.empty import EmptyStream diff --git a/ghostos/framework/streams/empty.py b/ghostos/framework/streams/empty.py index eccd7659..ce316617 100644 --- a/ghostos/framework/streams/empty.py +++ b/ghostos/framework/streams/empty.py @@ -4,6 +4,9 @@ class EmptyStream(Stream): + """ + for mock or test + """ def __init__(self, max_final: int = 0): self._max_final = max_final @@ -16,14 +19,8 @@ def deliver(self, pack: "Message") -> bool: self._final_count += 1 return True - def is_streaming(self) -> bool: + def accept_chunks(self) -> bool: return False - def send(self, messages: Iterable[Message]) -> bool: - for item in messages: - if not self.deliver(item): - return False - return True - def stopped(self) -> bool: return self._final_count > self._max_final diff --git a/ghostos/framework/streams/queuestream.py b/ghostos/framework/streams/queuestream.py index 86850617..052dae8f 100644 --- a/ghostos/framework/streams/queuestream.py +++ b/ghostos/framework/streams/queuestream.py @@ -7,6 +7,10 @@ class QueueStream(Stream): + """ + expect to develop a thread-safe stream by python queue. + but I'm not familiar to python thread safe queue... + """ def __init__(self, queue: Queue, streaming: bool = True, max_final: int = 1): self._queue = queue @@ -30,22 +34,18 @@ def deliver(self, pack: "Message") -> bool: self._queue.task_done() self._queue.put(pack, block=True) return True - elif self._streaming and not pack.is_done(): + elif self._streaming and not pack.is_complete(): # 不发送间包, 只发送尾包. return True else: self._queue.put(pack, block=True) return True - def is_streaming(self) -> bool: - return self._streaming - - def send(self, messages: Iterable[Message]) -> bool: - for item in messages: - ok = self.deliver(item) - if not ok: - return False - return True + def accept_chunks(self) -> bool: + return not self._streaming def stopped(self) -> bool: return self._stopped + + def close(self): + self._stopped = True diff --git a/tests/core/messages/test_messages.py b/tests/core/messages/test_messages.py index 476c4165..403162ef 100644 --- a/tests/core/messages/test_messages.py +++ b/tests/core/messages/test_messages.py @@ -5,7 +5,7 @@ def test_text_msg(): - msg = Message.new_done(role=Role.SYSTEM, content="hello") + msg = Message.new_tail(role=Role.SYSTEM, content="hello") assert msg.content == "hello" assert len(msg.msg_id) > 0 assert msg.created > 0 @@ -57,11 +57,11 @@ def test_tail_patch(): pack = Message.new_chunk(content=c) patch = msg.patch(pack) assert patch is not None - tail = Message.new_done(content=" world") + tail = Message.new_tail(content=" world") patch = msg.patch(tail) assert patch is None - tail = Message.new_done(content=" world", msg_id=msg.msg_id) + tail = Message.new_tail(content=" world", msg_id=msg.msg_id) patch = msg.patch(tail) assert patch is not None assert patch.content == " world" @@ -82,3 +82,5 @@ def test_patch_default_type_message(): + + diff --git a/tests/framework/messages/test_buffer.py b/tests/framework/messages/test_buffer.py index e60565ab..0d423c01 100644 --- a/tests/framework/messages/test_buffer.py +++ b/tests/framework/messages/test_buffer.py @@ -170,7 +170,7 @@ def test_buffer_with_tail_item(): for c in content: msg = Message.new_chunk(content=c) buffer.buff(msg) - tail = Message.new_done(content="hello world", msg_id=header.msg_id) + tail = Message.new_tail(content="hello world", msg_id=header.msg_id) buffer.buff(tail) flushed = buffer.flush() assert len(flushed.messages) == 1 diff --git a/tests/framework/messenger/test_messenger.py b/tests/framework/messenger/test_messenger.py index 12c2f4a4..7375c09a 100644 --- a/tests/framework/messenger/test_messenger.py +++ b/tests/framework/messenger/test_messenger.py @@ -1,7 +1,8 @@ -from ghostos.framework.messengers import DefaultMessenger +from ghostos.framework.messengers import Messenger, DefaultMessenger from ghostos.core.session.threads import MsgThread from ghostos.core.messages import Message from ghostos.core.llms import FunctionalToken +import time def test_default_messenger_baseline(): @@ -64,3 +65,49 @@ def test_messenger_with_single_message(): assert flushed.messages[0].content == "" assert flushed.messages[0].memory == content assert len(flushed.callers) == 1 + +# def test_async_sub_messengers(): +# from threading import Thread +# functional_tokens = [FunctionalToken( +# token="", +# end_token="", +# name="moss", +# description="desc", +# deliver=False, +# )] +# +# def make(m: Messenger, idx: int): +# def fn(): +# content = f"{idx}def main():\n pass" +# mod = idx % 3 + 1 +# contents = [] +# c = 0 +# while c < len(content): +# contents.append(content[c:c + mod]) +# c = c + mod +# for line in contents: +# m.deliver(Message.new_chunk(content=line)) +# time.sleep(0.1) +# messages, callers = m.flush() +# print(f"\ncontent {idx}: {len(messages)} + `{messages[0].content}`") +# +# +# return fn +# +# thread = MsgThread() +# messenger = DefaultMessenger(thread=thread, functional_tokens=functional_tokens) +# running = [] +# for i in range(10): +# sub = messenger.new() +# f = make(sub, i) +# t = Thread(target=f) +# running.append(t) +# t.start() +# for t in running: +# t.join() +# flushed = messenger.flush() +# for msg in flushed.messages: +# # print(msg.streaming_id) +# print("\n++\n" + msg.get_content()) +# for caller in flushed.callers: +# print(caller.arguments) From 3d73b5d654c2d6e0bcdec71afa133c5daf984840 Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 11 Oct 2024 13:28:17 +0800 Subject: [PATCH 031/148] refact: rename processes and tasks for less confusing --- ghostos/bootstrap.py | 16 ++++----- ghostos/core/aifunc/driver.py | 4 +-- ghostos/core/ghostos.py | 20 +++++------ ghostos/core/ghosts/utils.py | 4 +-- ghostos/core/session/__init__.py | 6 ++-- ghostos/core/session/processes.py | 18 +++++----- ghostos/core/session/session.py | 16 ++++----- ghostos/core/session/tasks.py | 4 +-- ghostos/core/session/threads.py | 4 +-- ghostos/framework/ghostos/basic.py | 8 ++--- ghostos/framework/ghostos/demo_os.py | 6 ++-- ghostos/framework/ghosts/basic.py | 26 +++++++------- ghostos/framework/ghosts/demo.py | 4 +-- ghostos/framework/processes/__init__.py | 2 +- .../framework/processes/storage_processes.py | 36 +++++++++---------- ghostos/framework/session/basic.py | 32 ++++++++--------- ghostos/framework/tasks/__init__.py | 2 +- ghostos/framework/tasks/storage_tasks.py | 26 +++++++------- ghostos/framework/threads/__init__.py | 4 +-- ghostos/framework/threads/storage_threads.py | 26 +++++++------- ghostos/prototypes/aifunc/app.py | 4 +-- ghostos/scripts/aifunc_test.py | 4 +-- ghostos/scripts/swe_test.py | 4 +-- tests/framework/tasks/test_storage_impl.py | 4 +-- 24 files changed, 140 insertions(+), 140 deletions(-) diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 5becbf22..34d4f51d 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -96,9 +96,9 @@ def default_application_contracts() -> Contracts: from ghostos.entity import EntityFactory from ghostos.framework.workspaces import Workspace from ghostos.framework.configs import Configs - from ghostos.framework.processes import Processes - from ghostos.framework.threads import Threads - from ghostos.framework.tasks import Tasks + from ghostos.framework.processes import GhostProcessRepo + from ghostos.framework.threads import MsgThreadRepo + from ghostos.framework.tasks import TaskRepo from ghostos.framework.eventbuses import EventBus from ghostos.framework.llms import LLMs from ghostos.framework.logger import LoggerItf @@ -125,9 +125,9 @@ def default_application_contracts() -> Contracts: AIFuncRepository, # session contracts - Processes, # application processes repository - Threads, # application threads repository - Tasks, # application tasks repository + GhostProcessRepo, # application processes repository + MsgThreadRepo, # application threads repository + TaskRepo, # application tasks repository EventBus, # application session eventbus ]) @@ -153,7 +153,7 @@ def default_application_providers( from ghostos.framework.workspaces import BasicWorkspaceProvider from ghostos.framework.configs import WorkspaceConfigsProvider from ghostos.framework.processes import WorkspaceProcessesProvider - from ghostos.framework.threads import WorkspaceThreadsProvider + from ghostos.framework.threads import MsgThreadsRepoByWorkSpaceProvider from ghostos.framework.tasks import WorkspaceTasksProvider from ghostos.framework.eventbuses import MemEventBusImplProvider from ghostos.framework.llms import ConfigBasedLLMsProvider @@ -169,7 +169,7 @@ def default_application_providers( WorkspaceConfigsProvider(), WorkspaceProcessesProvider(runtime_processes_dir), WorkspaceTasksProvider(runtime_tasks_dir), - WorkspaceThreadsProvider(runtime_threads_dir), + MsgThreadsRepoByWorkSpaceProvider(runtime_threads_dir), DefaultPoolProvider(100), ConfigBasedLLMsProvider(llms_conf_path), DefaultModulesProvider(), diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index 1c53c1e6..b10bf10b 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -8,7 +8,7 @@ ) from ghostos.core.llms import LLMs, Chat from ghostos.core.moss.abc import MossRuntime -from ghostos.core.session import MsgThread, DefaultEventType, Threads, thread_to_chat +from ghostos.core.session import MsgThread, DefaultEventType, MsgThreadRepo, thread_to_chat from ghostos.core.messages import Role, Message, Stream from ghostos.contracts.logger import LoggerItf @@ -278,6 +278,6 @@ def parse_moss_code_in_message(self, message: Message) -> str: def on_save(self, manager: AIFuncExecutor, thread: MsgThread) -> None: # 如果 threads 抽象存在, 就保存一下. 还应该做一些日志的工作. container = manager.container() - threads = container.get(Threads) + threads = container.get(MsgThreadRepo) if threads is not None: threads.save_thread(thread) diff --git a/ghostos/core/ghostos.py b/ghostos/core/ghostos.py index 601ee945..7f5b1443 100644 --- a/ghostos/core/ghostos.py +++ b/ghostos/core/ghostos.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from ghostos.entity import EntityMeta from ghostos.core.messages import Stream -from ghostos.core.session import EventBus, Event, Tasks, Task, Process, Processes +from ghostos.core.session import EventBus, Event, TaskRepo, Task, GhostProcess, GhostProcessRepo from ghostos.core.ghosts import Ghost, GhostConf, Inputs from ghostos.contracts.logger import LoggerItf from ghostos.contracts.shutdown import Shutdown @@ -43,7 +43,7 @@ def get_or_create_process( session_id: str, process_id: Optional[str] = None, task_id: Optional[str] = None, - ) -> Optional[Process]: + ) -> Optional[GhostProcess]: """ get a process from session_id, if not exists, create one. :param ghost_meta: to create ghost instance. @@ -58,7 +58,7 @@ def get_or_create_process( def make_ghost( self, *, upstream: Stream, - process: Process, + process: GhostProcess, task: Optional[Task] = None, task_id: Optional[str] = None, ) -> Ghost: @@ -145,17 +145,17 @@ def _eventbus(self) -> EventBus: """ return self.container().force_fetch(EventBus) - def _processes(self) -> Processes: - return self.container().force_fetch(Processes) + def _processes(self) -> GhostProcessRepo: + return self.container().force_fetch(GhostProcessRepo) - def _tasks(self) -> Tasks: - return self.container().force_fetch(Tasks) + def _tasks(self) -> TaskRepo: + return self.container().force_fetch(TaskRepo) @abstractmethod def make_ghost( self, *, upstream: Stream, - process: Process, + process: GhostProcess, task: Optional[Task] = None, task_id: Optional[str] = None, ) -> Ghost: @@ -170,11 +170,11 @@ def get_or_create_process( session_id: str, process_id: Optional[str] = None, task_id: Optional[str] = None, - ) -> Optional[Process]: + ) -> Optional[GhostProcess]: processes = self._processes() proc = processes.get_session_process(session_id) if proc is None or (process_id and process_id != proc.pid): - proc = Process.new( + proc = GhostProcess.new( session_id=session_id, ghost_meta=ghost_meta, process_id=process_id, diff --git a/ghostos/core/ghosts/utils.py b/ghostos/core/ghosts/utils.py index 3194f404..09918268 100644 --- a/ghostos/core/ghosts/utils.py +++ b/ghostos/core/ghosts/utils.py @@ -4,7 +4,7 @@ from ghostos.core.ghosts.thoughts import Thought, ThoughtDriver from ghostos.core.session import ( Event, DefaultEventType, - Task, TaskState, Tasks, + Task, TaskState, TaskRepo, ) from ghostos.core.messages import ( MessageKind, @@ -169,7 +169,7 @@ def cancel_children_tasks( if not children_ids: return - tasks = self.ghost.container().force_fetch(Tasks) + tasks = self.ghost.container().force_fetch(TaskRepo) children = list(tasks.get_task_briefs(children_ids)) if not children: # 没有 children. diff --git a/ghostos/core/session/__init__.py b/ghostos/core/session/__init__.py index 12ed27a3..e7a61025 100644 --- a/ghostos/core/session/__init__.py +++ b/ghostos/core/session/__init__.py @@ -1,10 +1,10 @@ from ghostos.core.session.session import Session from ghostos.core.session.tasks import ( Task, TaskPayload, TaskBrief, - Tasks, TaskState, WaitGroup, + TaskRepo, TaskState, WaitGroup, ) -from ghostos.core.session.threads import Threads, MsgThread, thread_to_chat, Turn -from ghostos.core.session.processes import Process, Processes +from ghostos.core.session.threads import MsgThreadRepo, MsgThread, thread_to_chat, Turn +from ghostos.core.session.processes import GhostProcess, GhostProcessRepo from ghostos.core.session.messenger import Messenger, Buffed from ghostos.core.session.events import Event, EventBus, DefaultEventType from ghostos.core.session.simple_thread import SimpleMsgThread diff --git a/ghostos/core/session/processes.py b/ghostos/core/session/processes.py index b1f103c8..fa54d978 100644 --- a/ghostos/core/session/processes.py +++ b/ghostos/core/session/processes.py @@ -6,12 +6,12 @@ from ghostos.helpers import uuid __all__ = [ - 'Process', - 'Processes', + 'GhostProcess', + 'GhostProcessRepo', ] -class Process(BaseModel): +class GhostProcess(BaseModel): process_id: str = Field( description=""" Unique process id for the agent session. Session shall only have one process a time. @@ -48,10 +48,10 @@ def new( ghost_meta: EntityMeta, process_id: Optional[str] = None, main_task_id: Optional[str] = None, - ) -> "Process": + ) -> "GhostProcess": process_id = process_id if process_id else uuid() main_task_id = process_id if main_task_id is None else main_task_id - return Process( + return GhostProcess( session_id=session_id, process_id=process_id, main_task_id=main_task_id, @@ -59,13 +59,13 @@ def new( ) -class Processes(ABC): +class GhostProcessRepo(ABC): """ repository to save or load process """ @abstractmethod - def get_process(self, process_id: str) -> Optional[Process]: + def get_process(self, process_id: str) -> Optional[GhostProcess]: """ get process by id :param process_id: process id @@ -73,14 +73,14 @@ def get_process(self, process_id: str) -> Optional[Process]: pass @abstractmethod - def get_session_process(self, session_id: str) -> Optional[Process]: + def get_session_process(self, session_id: str) -> Optional[GhostProcess]: """ get session process by session id """ pass @abstractmethod - def save_process(self, process: Process) -> None: + def save_process(self, process: GhostProcess) -> None: """ save process :param process: diff --git a/ghostos/core/session/session.py b/ghostos/core/session/session.py index 9e9e2049..37772056 100644 --- a/ghostos/core/session/session.py +++ b/ghostos/core/session/session.py @@ -3,9 +3,9 @@ from ghostos.core.session.events import Event, EventBus from ghostos.core.session.messenger import Messenger -from ghostos.core.session.processes import Processes, Process -from ghostos.core.session.tasks import Tasks, Task, TaskBrief -from ghostos.core.session.threads import Threads, MsgThread +from ghostos.core.session.processes import GhostProcessRepo, GhostProcess +from ghostos.core.session.tasks import TaskRepo, Task, TaskBrief +from ghostos.core.session.threads import MsgThreadRepo, MsgThread from ghostos.core.messages import MessageKind, Role, Buffer, Payload, Attachment, Message from ghostos.core.llms import FunctionalToken @@ -56,7 +56,7 @@ def refresh_lock(self) -> bool: # pass @abstractmethod - def process(self) -> "Process": + def process(self) -> "GhostProcess": """ 当前会话所处的进程数据. 不允许直接修改. 只有指定的 API 会修改结果并保存. @@ -119,7 +119,7 @@ def update_task(self, task: "Task", thread: Optional["MsgThread"], update_histor pass @abstractmethod - def update_process(self, process: "Process") -> None: + def update_process(self, process: "GhostProcess") -> None: """ 改动 process 并保存. 通常只在初始化里才需要. """ @@ -172,15 +172,15 @@ def get_task_briefs(self, *task_ids, children: bool = False) -> List[TaskBrief]: pass @abstractmethod - def tasks(self) -> Tasks: + def tasks(self) -> TaskRepo: pass @abstractmethod - def processes(self) -> Processes: + def processes(self) -> GhostProcessRepo: pass @abstractmethod - def threads(self) -> Threads: + def threads(self) -> MsgThreadRepo: pass @abstractmethod diff --git a/ghostos/core/session/tasks.py b/ghostos/core/session/tasks.py index 76d5db1f..c4d246a0 100644 --- a/ghostos/core/session/tasks.py +++ b/ghostos/core/session/tasks.py @@ -11,7 +11,7 @@ __all__ = [ 'Task', 'TaskPayload', 'TaskBrief', 'TaskState', - 'Tasks', + 'TaskRepo', 'WaitGroup', ] @@ -342,7 +342,7 @@ def from_task(cls, task: Task) -> "TaskPayload": ) -class Tasks(ABC): +class TaskRepo(ABC): """ 管理 task 存储的模块. 通常集成到 Session 里. """ diff --git a/ghostos/core/session/threads.py b/ghostos/core/session/threads.py index 32255a37..20984152 100644 --- a/ghostos/core/session/threads.py +++ b/ghostos/core/session/threads.py @@ -10,7 +10,7 @@ from contextlib import contextmanager __all__ = [ - 'Threads', 'MsgThread', 'Turn', + 'MsgThreadRepo', 'MsgThread', 'Turn', 'thread_to_chat', ] @@ -296,7 +296,7 @@ def thread_to_chat(chat_id: str, system: List[Message], thread: MsgThread) -> Ch return chat -class Threads(ABC): +class MsgThreadRepo(ABC): """ the repository to save and load threads """ diff --git a/ghostos/framework/ghostos/basic.py b/ghostos/framework/ghostos/basic.py index 2fa9d938..0e1654f0 100644 --- a/ghostos/framework/ghostos/basic.py +++ b/ghostos/framework/ghostos/basic.py @@ -7,14 +7,14 @@ from ghostos.core.ghostos import AbsGhostOS from ghostos.core.ghosts import Ghost from ghostos.core.messages import Stream -from ghostos.core.session import Process, Task +from ghostos.core.session import GhostProcess, Task from ghostos.contracts.shutdown import ShutdownProvider from ghostos.contracts.modules import Modules, DefaultModulesProvider from ghostos.framework.storage import FileStorageProvider from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.workspaces import BasicWorkspaceProvider from ghostos.framework.configs import WorkspaceConfigsProvider -from ghostos.framework.threads import WorkspaceThreadsProvider +from ghostos.framework.threads import MsgThreadsRepoByWorkSpaceProvider from ghostos.framework.processes import WorkspaceProcessesProvider from ghostos.framework.tasks import WorkspaceTasksProvider from ghostos.framework.llms import ConfigBasedLLMsProvider @@ -80,7 +80,7 @@ def _on_initialized(self): def make_ghost( self, *, upstream: Stream, - process: Process, + process: GhostProcess, task: Optional[Task] = None, task_id: Optional[str] = None, ) -> Ghost: @@ -102,7 +102,7 @@ def _default_providers(self) -> List[Provider]: WorkspaceConfigsProvider(), WorkspaceProcessesProvider(self._processes_path), WorkspaceTasksProvider(self._tasks_path), - WorkspaceThreadsProvider(self._threads_path), + MsgThreadsRepoByWorkSpaceProvider(self._threads_path), DefaultPoolProvider(100), ConfigBasedLLMsProvider(self._llm_config_path), MemEventBusImplProvider(), diff --git a/ghostos/framework/ghostos/demo_os.py b/ghostos/framework/ghostos/demo_os.py index 88b80122..03252857 100644 --- a/ghostos/framework/ghostos/demo_os.py +++ b/ghostos/framework/ghostos/demo_os.py @@ -2,7 +2,7 @@ from ghostos.core.ghosts import Ghost, GhostConf, Workspace, Shell from ghostos.core.messages import Stream -from ghostos.core.session import Process, Task +from ghostos.core.session import GhostProcess, Task from ghostos.contracts.logger import LoggerItf from ghostos.contracts.configs import Configs, YamlConfig @@ -28,7 +28,7 @@ def _on_initialized(self): def make_ghost( self, *, upstream: Stream, - process: Process, + process: GhostProcess, task: Optional[Task] = None, task_id: Optional[str] = None, ) -> Ghost: @@ -43,7 +43,7 @@ def _make_ghost_instance( self, conf: GhostConf, upstream: Stream, - process: Process, + process: GhostProcess, task: Optional[Task] = None, task_id: Optional[str] = None, ) -> Ghost: diff --git a/ghostos/framework/ghosts/basic.py b/ghostos/framework/ghosts/basic.py index bcb17b90..930d92b8 100644 --- a/ghostos/framework/ghosts/basic.py +++ b/ghostos/framework/ghosts/basic.py @@ -16,8 +16,8 @@ from ghostos.core.messages import Caller from ghostos.core.session import ( Session, Event, DefaultEventType, - EventBus, Tasks, Processes, Threads, Messenger, - Process, Task, MsgThread, + EventBus, TaskRepo, GhostProcessRepo, MsgThreadRepo, Messenger, + GhostProcess, Task, MsgThread, ) from ghostos.framework.operators import OnEventOperator from ghostos.framework.multitasks import MultiTaskBasicImpl @@ -61,9 +61,9 @@ class BasicGhost(Ghost, ABC): Storage, Configs, EventBus, - Processes, - Tasks, - Threads, + GhostProcessRepo, + TaskRepo, + MsgThreadRepo, Pool, LLMs, Shutdown, @@ -94,7 +94,7 @@ def __init__( workspace: Workspace, entity_factory: EntityFactory, upstream: Stream, - process: Process, + process: GhostProcess, max_operator_runs: int, task: Optional[Task] = None, task_id: Optional[str] = None, @@ -167,10 +167,10 @@ def _bootstrap_ghost_container(self): # register session drivers: session_function_providers = { - Tasks: self._session.tasks, - Processes: self._session.processes, + TaskRepo: self._session.tasks, + GhostProcessRepo: self._session.processes, Messenger: self._session.messenger, - Threads: self._session.threads, + MsgThreadRepo: self._session.threads, EventBus: self._session.eventbus, } for contract, maker in session_function_providers.items(): @@ -196,16 +196,16 @@ def make_session( self, logger: LoggerItf, upstream: Stream, - process: Process, + process: GhostProcess, root_thought: Thought, task: Optional[Task] = None, task_id: Optional[str] = None, ) -> Session: container = self.container() identifier = self.conf().identifier() - processes = container.force_fetch(Processes) - tasks = container.force_fetch(Tasks) - threads = container.force_fetch(Threads) + processes = container.force_fetch(GhostProcessRepo) + tasks = container.force_fetch(TaskRepo) + threads = container.force_fetch(MsgThreadRepo) pool = container.force_fetch(Pool) eventbus = container.force_fetch(EventBus) # task and thread init. diff --git a/ghostos/framework/ghosts/demo.py b/ghostos/framework/ghosts/demo.py index 678fca46..55c29360 100644 --- a/ghostos/framework/ghosts/demo.py +++ b/ghostos/framework/ghosts/demo.py @@ -1,7 +1,7 @@ from typing import Optional, List from ghostos.abc import Identifier from ghostos.core.ghosts import GhostConf, Shell, Workspace -from ghostos.core.session import Process, Task +from ghostos.core.session import GhostProcess, Task from ghostos.contracts.modules import Modules from ghostos.core.messages import Stream from ghostos.framework.ghosts.basic import BasicGhost, InputsPipe @@ -59,7 +59,7 @@ def __init__( container: Container, entity_factory: EntityFactory, workspace: Workspace, - process: Process, + process: GhostProcess, upstream: Optional[Stream] = None, shell: Optional[Shell] = None, task: Optional[Task] = None, diff --git a/ghostos/framework/processes/__init__.py b/ghostos/framework/processes/__init__.py index 91a4721b..b128b65d 100644 --- a/ghostos/framework/processes/__init__.py +++ b/ghostos/framework/processes/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.session import Processes +from ghostos.core.session import GhostProcessRepo from ghostos.framework.processes.storage_processes import StorageProcessImplProvider, WorkspaceProcessesProvider diff --git a/ghostos/framework/processes/storage_processes.py b/ghostos/framework/processes/storage_processes.py index bb128e0b..540108e9 100644 --- a/ghostos/framework/processes/storage_processes.py +++ b/ghostos/framework/processes/storage_processes.py @@ -1,17 +1,17 @@ from typing import Optional, Dict, Type import yaml -from ghostos.core.session import Process -from ghostos.core.session.processes import Processes +from ghostos.core.session import GhostProcess +from ghostos.core.session.processes import GhostProcessRepo from ghostos.contracts.storage import Storage from ghostos.contracts.logger import LoggerItf from ghostos.core.ghosts.workspace import Workspace from threading import Lock from ghostos.container import Provider, Container -__all__ = ['StorageProcessesImpl', 'StorageProcessImplProvider', 'WorkspaceProcessesProvider'] +__all__ = ['StorageGhostProcessRepoImpl', 'StorageProcessImplProvider', 'WorkspaceProcessesProvider'] -class StorageProcessesImpl(Processes): +class StorageGhostProcessRepoImpl(GhostProcessRepo): session_map_name = "sessions.yml" def __init__(self, storage: Storage, logger: LoggerItf): @@ -31,12 +31,12 @@ def _get_session_process_map(self) -> Dict[str, str]: def _get_process_filename(process_id: str) -> str: return f"{process_id}.process.yml" - def get_process(self, process_id: str) -> Optional[Process]: + def get_process(self, process_id: str) -> Optional[GhostProcess]: filename = self._get_process_filename(process_id) if self._storage.exists(filename): content = self._storage.get(filename) data = yaml.safe_load(content) - process = Process(**data) + process = GhostProcess(**data) return process return None @@ -45,14 +45,14 @@ def _save_session_process_map(self, session_map: Dict[str, str]) -> None: filename = self.session_map_name self._storage.put(filename, content.encode("utf-8")) - def get_session_process(self, session_id: str) -> Optional[Process]: + def get_session_process(self, session_id: str) -> Optional[GhostProcess]: m = self._get_session_process_map() process_id = m.get(session_id, None) if process_id is None: return None return self.get_process(process_id) - def save_process(self, process: Process) -> None: + def save_process(self, process: GhostProcess) -> None: session_id = process.session_id process_id = process.process_id with self._lock: @@ -64,35 +64,35 @@ def save_process(self, process: Process) -> None: self._storage.put(filename, content.encode("utf-8")) -class StorageProcessImplProvider(Provider[Processes]): +class StorageProcessImplProvider(Provider[GhostProcessRepo]): def __init__(self, process_dir: str = "runtime/processes"): self.process_dir = process_dir def singleton(self) -> bool: return True - def contract(self) -> Type[Processes]: - return Processes + def contract(self) -> Type[GhostProcessRepo]: + return GhostProcessRepo - def factory(self, con: Container) -> Optional[Processes]: + def factory(self, con: Container) -> Optional[GhostProcessRepo]: storage = con.force_fetch(Storage) logger = con.force_fetch(LoggerItf) processes_storage = storage.sub_storage(self.process_dir) - return StorageProcessesImpl(processes_storage, logger) + return StorageGhostProcessRepoImpl(processes_storage, logger) -class WorkspaceProcessesProvider(Provider[Processes]): +class WorkspaceProcessesProvider(Provider[GhostProcessRepo]): def __init__(self, process_dir: str = "processes"): self.process_dir = process_dir def singleton(self) -> bool: return True - def contract(self) -> Type[Processes]: - return Processes + def contract(self) -> Type[GhostProcessRepo]: + return GhostProcessRepo - def factory(self, con: Container) -> Optional[Processes]: + def factory(self, con: Container) -> Optional[GhostProcessRepo]: workspace = con.force_fetch(Workspace) logger = con.force_fetch(LoggerItf) processes_storage = workspace.runtime().sub_storage(self.process_dir) - return StorageProcessesImpl(processes_storage, logger) + return StorageGhostProcessRepoImpl(processes_storage, logger) diff --git a/ghostos/framework/session/basic.py b/ghostos/framework/session/basic.py index ab2ac4e8..b535f67b 100644 --- a/ghostos/framework/session/basic.py +++ b/ghostos/framework/session/basic.py @@ -5,9 +5,9 @@ ) from ghostos.core.session import ( Session, - Process, Processes, - MsgThread, Threads, - Task, Tasks, TaskPayload, TaskState, + GhostProcess, GhostProcessRepo, + MsgThread, MsgThreadRepo, + Task, TaskRepo, TaskPayload, TaskState, Messenger, Event, EventBus, DefaultEventType, TaskBrief, @@ -43,27 +43,27 @@ def __init__( upstream: Stream, eventbus: EventBus, pool: Pool, - processes: Processes, - tasks: Tasks, - threads: Threads, + processes: GhostProcessRepo, + tasks: TaskRepo, + threads: MsgThreadRepo, logger: LoggerItf, # 当前任务信息. - process: Process, + process: GhostProcess, task: Task, thread: MsgThread, ): self._pool = pool self._upstream = upstream self._logger = logger - self._tasks: Tasks = tasks - self._processes: Processes = processes + self._tasks: TaskRepo = tasks + self._processes: GhostProcessRepo = processes self._ghost_name: str = ghost_name self._message_role: str = ghost_role - self._threads: Threads = threads + self._threads: MsgThreadRepo = threads self._eventbus: EventBus = eventbus # 需要管理的状态. self._task: Task = task - self._process: Process = process + self._process: GhostProcess = process self._creating: List[Task] = [] self._thread: MsgThread = thread self._firing_events: List[Event] = [] @@ -86,7 +86,7 @@ def refresh_lock(self) -> bool: return True return False - def process(self) -> "Process": + def process(self) -> "GhostProcess": return self._process def task(self) -> "Task": @@ -259,19 +259,19 @@ def get_task_briefs(self, *task_ids, children: bool = False) -> "List[TaskBrief] self._fetched_task_briefs[task_brief.task_id] = task_brief return result - def tasks(self) -> Tasks: + def tasks(self) -> TaskRepo: return self._tasks - def processes(self) -> Processes: + def processes(self) -> GhostProcessRepo: return self._processes - def threads(self) -> Threads: + def threads(self) -> MsgThreadRepo: return self._threads def eventbus(self) -> EventBus: return self._eventbus - def update_process(self, process: "Process") -> None: + def update_process(self, process: "GhostProcess") -> None: self._process = process def quit(self) -> None: diff --git a/ghostos/framework/tasks/__init__.py b/ghostos/framework/tasks/__init__.py index 3f856f18..9ddd286f 100644 --- a/ghostos/framework/tasks/__init__.py +++ b/ghostos/framework/tasks/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.session import Tasks +from ghostos.core.session import TaskRepo from ghostos.framework.tasks.storage_tasks import StorageTasksImplProvider, WorkspaceTasksProvider diff --git a/ghostos/framework/tasks/storage_tasks.py b/ghostos/framework/tasks/storage_tasks.py index 83a1c38b..a9af98d7 100644 --- a/ghostos/framework/tasks/storage_tasks.py +++ b/ghostos/framework/tasks/storage_tasks.py @@ -1,16 +1,16 @@ from typing import Optional, List, Iterable, Dict, Type import yaml -from ghostos.core.session import TaskState, TaskBrief, Task, Tasks +from ghostos.core.session import TaskState, TaskBrief, Task, TaskRepo from ghostos.core.ghosts import Workspace from ghostos.contracts.logger import LoggerItf from ghostos.contracts.storage import Storage from ghostos.container import Provider, Container from ghostos.helpers import uuid -__all__ = ['StorageTasksImpl', 'StorageTasksImplProvider', 'WorkspaceTasksProvider'] +__all__ = ['StorageTaskRepoImpl', 'StorageTasksImplProvider', 'WorkspaceTasksProvider'] -class StorageTasksImpl(Tasks): +class StorageTaskRepoImpl(TaskRepo): def __init__(self, storage: Storage, logger: LoggerItf): self._storage = storage @@ -87,7 +87,7 @@ def refresh_task_lock(self, task_id: str, lock: str) -> Optional[str]: return None -class StorageTasksImplProvider(Provider[Tasks]): +class StorageTasksImplProvider(Provider[TaskRepo]): """ provide storage based Tasks """ @@ -98,17 +98,17 @@ def __init__(self, tasks_dir: str = "runtime/tasks"): def singleton(self) -> bool: return True - def contract(self) -> Type[Tasks]: - return Tasks + def contract(self) -> Type[TaskRepo]: + return TaskRepo - def factory(self, con: Container) -> Optional[Tasks]: + def factory(self, con: Container) -> Optional[TaskRepo]: logger = con.force_fetch(LoggerItf) storage = con.force_fetch(Storage) tasks_storage = storage.sub_storage(self.tasks_dir) - return StorageTasksImpl(tasks_storage, logger) + return StorageTaskRepoImpl(tasks_storage, logger) -class WorkspaceTasksProvider(Provider[Tasks]): +class WorkspaceTasksProvider(Provider[TaskRepo]): def __init__(self, namespace: str = "tasks"): self.namespace = namespace @@ -116,12 +116,12 @@ def __init__(self, namespace: str = "tasks"): def singleton(self) -> bool: return True - def contract(self) -> Type[Tasks]: - return Tasks + def contract(self) -> Type[TaskRepo]: + return TaskRepo - def factory(self, con: Container) -> Optional[Tasks]: + def factory(self, con: Container) -> Optional[TaskRepo]: workspace = con.force_fetch(Workspace) runtime_storage = workspace.runtime() tasks_storage = runtime_storage.sub_storage(self.namespace) logger = con.force_fetch(LoggerItf) - return StorageTasksImpl(tasks_storage, logger) + return StorageTaskRepoImpl(tasks_storage, logger) diff --git a/ghostos/framework/threads/__init__.py b/ghostos/framework/threads/__init__.py index 30eb0a38..9b0c6a58 100644 --- a/ghostos/framework/threads/__init__.py +++ b/ghostos/framework/threads/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.session import Threads -from ghostos.framework.threads.storage_threads import StorageThreadsProvider, WorkspaceThreadsProvider +from ghostos.core.session import MsgThreadRepo +from ghostos.framework.threads.storage_threads import MsgThreadRepoByStorageProvider, MsgThreadsRepoByWorkSpaceProvider diff --git a/ghostos/framework/threads/storage_threads.py b/ghostos/framework/threads/storage_threads.py index 0b72dce0..759a90ad 100644 --- a/ghostos/framework/threads/storage_threads.py +++ b/ghostos/framework/threads/storage_threads.py @@ -1,5 +1,5 @@ from typing import Optional, Type -from ghostos.core.session import MsgThread, Threads, SimpleMsgThread +from ghostos.core.session import MsgThread, MsgThreadRepo, SimpleMsgThread from ghostos.core.ghosts import Workspace from ghostos.contracts.storage import Storage from ghostos.contracts.logger import LoggerItf @@ -8,10 +8,10 @@ import yaml import os -__all__ = ['StorageThreads', 'StorageThreadsProvider', 'WorkspaceThreadsProvider'] +__all__ = ['MsgThreadRepoByStorage', 'MsgThreadRepoByStorageProvider', 'MsgThreadsRepoByWorkSpaceProvider'] -class StorageThreads(Threads): +class MsgThreadRepoByStorage(MsgThreadRepo): def __init__( self, *, @@ -62,7 +62,7 @@ def fork_thread(self, thread: MsgThread) -> MsgThread: return thread.fork() -class StorageThreadsProvider(Provider[Threads]): +class MsgThreadRepoByStorageProvider(Provider[MsgThreadRepo]): def __init__(self, threads_dir: str = "runtime/threads"): self._threads_dir = threads_dir @@ -70,17 +70,17 @@ def __init__(self, threads_dir: str = "runtime/threads"): def singleton(self) -> bool: return True - def contract(self) -> Type[Threads]: - return Threads + def contract(self) -> Type[MsgThreadRepo]: + return MsgThreadRepo - def factory(self, con: Container) -> Optional[Threads]: + def factory(self, con: Container) -> Optional[MsgThreadRepo]: storage = con.force_fetch(Storage) threads_storage = storage.sub_storage(self._threads_dir) logger = con.force_fetch(LoggerItf) - return StorageThreads(storage=threads_storage, logger=logger) + return MsgThreadRepoByStorage(storage=threads_storage, logger=logger) -class WorkspaceThreadsProvider(Provider[Threads]): +class MsgThreadsRepoByWorkSpaceProvider(Provider[MsgThreadRepo]): def __init__(self, namespace: str = "threads"): self._namespace = namespace @@ -88,11 +88,11 @@ def __init__(self, namespace: str = "threads"): def singleton(self) -> bool: return True - def contract(self) -> Type[Threads]: - return Threads + def contract(self) -> Type[MsgThreadRepo]: + return MsgThreadRepo - def factory(self, con: Container) -> Optional[Threads]: + def factory(self, con: Container) -> Optional[MsgThreadRepo]: workspace = con.force_fetch(Workspace) logger = con.force_fetch(LoggerItf) threads_storage = workspace.runtime().sub_storage(self._namespace) - return StorageThreads(storage=threads_storage, logger=logger) + return MsgThreadRepoByStorage(storage=threads_storage, logger=logger) diff --git a/ghostos/prototypes/aifunc/app.py b/ghostos/prototypes/aifunc/app.py index 96a2f2f5..51251539 100644 --- a/ghostos/prototypes/aifunc/app.py +++ b/ghostos/prototypes/aifunc/app.py @@ -16,7 +16,7 @@ from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.storage import FileStorageProvider from ghostos.framework.llms import ConfigBasedLLMsProvider -from ghostos.framework.threads import StorageThreadsProvider +from ghostos.framework.threads import MsgThreadRepoByStorageProvider from ghostos.framework.configs import ConfigsByStorageProvider from rich.console import Console from rich.panel import Panel @@ -54,7 +54,7 @@ def run_aifunc( container = test_container() container.register(FileStorageProvider(root_dir)) container.register(NamedLoggerProvider(logger_name=logger_name)) - container.register(StorageThreadsProvider(threads_dir=threads_path)) + container.register(MsgThreadRepoByStorageProvider(threads_dir=threads_path)) container.register(ConfigsByStorageProvider(configs_path)) container.register(ConfigBasedLLMsProvider(llm_conf_path)) diff --git a/ghostos/scripts/aifunc_test.py b/ghostos/scripts/aifunc_test.py index 10c108d7..89fd6b8c 100644 --- a/ghostos/scripts/aifunc_test.py +++ b/ghostos/scripts/aifunc_test.py @@ -13,7 +13,7 @@ from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.storage import FileStorageProvider from ghostos.framework.llms import ConfigBasedLLMsProvider -from ghostos.framework.threads import StorageThreadsProvider +from ghostos.framework.threads import MsgThreadRepoByStorageProvider from ghostos.container import Container from ghostos.contracts.modules import Modules from ghostos.contracts.storage import Storage @@ -33,7 +33,7 @@ def prepare_container(root_dir: str) -> Container: container = test_container() container.register(FileStorageProvider(root_dir)) container.register(NamedLoggerProvider(logger_name="debug")) - container.register(StorageThreadsProvider(threads_dir='runtime/threads')) + container.register(MsgThreadRepoByStorageProvider(threads_dir='runtime/threads')) container.register(ConfigsByStorageProvider("configs")) container.register(ConfigBasedLLMsProvider("llms_conf.yml")) return container diff --git a/ghostos/scripts/swe_test.py b/ghostos/scripts/swe_test.py index b15bc2e0..6a1d3303 100644 --- a/ghostos/scripts/swe_test.py +++ b/ghostos/scripts/swe_test.py @@ -13,7 +13,7 @@ from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.storage import FileStorageProvider from ghostos.framework.llms import ConfigBasedLLMsProvider -from ghostos.framework.threads import StorageThreadsProvider +from ghostos.framework.threads import MsgThreadRepoByStorageProvider from ghostos.container import Container from ghostos.contracts.modules import Modules from ghostos.framework.configs import ConfigsByStorageProvider @@ -33,7 +33,7 @@ def prepare_container(root_dir: str) -> Container: container = test_container() container.register(FileStorageProvider(root_dir)) container.register(NamedLoggerProvider(logger_name="debug")) - container.register(StorageThreadsProvider(threads_dir='runtime/threads')) + container.register(MsgThreadRepoByStorageProvider(threads_dir='runtime/threads')) container.register(ConfigsByStorageProvider("ghostos/configs")) container.register(ConfigBasedLLMsProvider("llms/llms_conf.yaml")) return container diff --git a/tests/framework/tasks/test_storage_impl.py b/tests/framework/tasks/test_storage_impl.py index e8bd7715..1dde32c0 100644 --- a/tests/framework/tasks/test_storage_impl.py +++ b/tests/framework/tasks/test_storage_impl.py @@ -1,5 +1,5 @@ from ghostos.framework.storage import MemStorage -from ghostos.framework.tasks.storage_tasks import StorageTasksImpl +from ghostos.framework.tasks.storage_tasks import StorageTaskRepoImpl from ghostos.framework.logger import FakeLogger from ghostos.core.session import Task from ghostos.entity import EntityMeta @@ -7,7 +7,7 @@ def test_storage_tasks_impl(): storage = MemStorage() - tasks = StorageTasksImpl(storage, FakeLogger()) + tasks = StorageTaskRepoImpl(storage, FakeLogger()) task = Task.new( task_id="task_id", session_id="session_id", From 484ce2810ab484e57c4fac455013868f480d61e7 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 12 Oct 2024 02:48:30 +0800 Subject: [PATCH 032/148] dev: try to implement duplex stream connection --- ghostos/core/messages/__init__.py | 2 +- ghostos/core/messages/stream.py | 35 ++++- ghostos/framework/streams/array.py | 201 +++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 ghostos/framework/streams/array.py diff --git a/ghostos/core/messages/__init__.py b/ghostos/core/messages/__init__.py index e52fd98e..0d22b22b 100644 --- a/ghostos/core/messages/__init__.py +++ b/ghostos/core/messages/__init__.py @@ -9,4 +9,4 @@ ) from ghostos.core.messages.buffers import Buffer, Flushed from ghostos.core.messages.helpers import copy_messages -from ghostos.core.messages.stream import Stream +from ghostos.core.messages.stream import Stream, Receiver, Received, Connection diff --git a/ghostos/core/messages/stream.py b/ghostos/core/messages/stream.py index fe1fa9bf..235c4aab 100644 --- a/ghostos/core/messages/stream.py +++ b/ghostos/core/messages/stream.py @@ -1,9 +1,11 @@ from abc import ABC, abstractmethod -from typing import Iterable +from typing import Iterable, Tuple from ghostos.core.messages.message import Message __all__ = [ "Stream", + "Receiver", "Received", + "Connection", ] @@ -47,3 +49,34 @@ def stopped(self) -> bool: if the stream is stopped. """ pass + + +class Received(ABC): + + @abstractmethod + def added(self) -> Message: + pass + + @abstractmethod + def chunks(self) -> Iterable[Message]: + pass + + @abstractmethod + def done(self) -> Message: + pass + + +class Receiver(ABC): + @abstractmethod + def received(self) -> Iterable[Received]: + pass + + +class Connection(ABC): + @abstractmethod + def __enter__(self) -> Tuple[Stream, Receiver]: + pass + + @abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + pass diff --git a/ghostos/framework/streams/array.py b/ghostos/framework/streams/array.py new file mode 100644 index 00000000..01ec317e --- /dev/null +++ b/ghostos/framework/streams/array.py @@ -0,0 +1,201 @@ +from typing import Tuple, Optional, Dict, List, Union, Iterable + +from ghostos.core.messages import ( + Message, Stream, Connection, Receiver, Received, + DefaultMessageTypes, +) +from threading import Lock +import time + + +class ArrayStreamConnection(Connection): + """ + 考虑到 Python 的 array 和 map 的操作是线程安全的, 试试用这个来做. + """ + + def __init__(self, accept_chunks: bool = True, idle: float = 0.2): + self._stopped = True + self._accept_chunks = accept_chunks + self._final: Optional[Message] = None + self._current_msg_id: str = "" + self._msg_ids = [] + self._message_heads: Dict[str, Union[Message, None]] = {} + self._message_chunks: Dict[str, List[Message]] = {} + self._message_tails: Dict[str, Union[Message, None]] = {} + self._locker = Lock() + self._receiver: Optional[ArrayReceiver] = None + self._stream: Optional[ArrayStream] = None + self._idle = idle + + def add_item(self, item: Message) -> bool: + if self._stopped: + return False + # item 还是加锁吧. + with self._locker: + if DefaultMessageTypes.is_protocol_type(item): + self._stopped = False + self._final = item + return True + if not self._accept_chunks and item.chunk: + return True + + msg_id = item.msg_id + if msg_id and msg_id != self._current_msg_id and msg_id not in self._msg_ids: + self._msg_ids.append(msg_id) + self._current_msg_id = msg_id + + # if the item is the tail of the chunks + if item.is_complete(): + self._message_tails[msg_id] = item + # then the item is a chunk + elif msg_id: + self._message_heads[msg_id] = item + else: + msg_id = self._current_msg_id + items = self._message_chunks.get(msg_id, []) + items.append(item) + self._message_chunks[msg_id] = items + + def stopped(self) -> bool: + return self._stopped + + def get_msg_head(self, msg_id: str) -> Optional[Message]: + return self._message_heads.get(msg_id, None) + + def get_msg_tail(self, msg_id: str) -> Optional[Message]: + return self._message_tails.get(msg_id, None) + + def get_msg_chunks(self, msg_id: str) -> List[Message]: + return self._message_chunks.get(msg_id, []) + + def get_msg_id(self, idx: int) -> Optional[str]: + if len(self._msg_ids) > idx: + return self._msg_ids[idx] + return None + + def __enter__(self) -> Tuple[Stream, Receiver]: + self._stream = ArrayStream(self, self._accept_chunks) + self._receiver = ArrayReceiver(self, self._idle) + return self._stream, self._receiver + + def __exit__(self, exc_type, exc_val, exc_tb): + if self._stream: + self._stream.destroy() + del self._stream + if self._receiver: + self._receiver.destroy() + del self._receiver + del self._final + del self._message_chunks + del self._message_tails + del self._msg_ids + del self._locker + + +class ArrayStream(Stream): + + def __init__(self, connection: ArrayStreamConnection, accept_chunks: bool): + self._connection: ArrayStreamConnection = connection + self._accept_chunks = accept_chunks + self._stopped = False + + def deliver(self, pack: "Message") -> bool: + if self._stopped: + return False + success = self._connection.add_item(pack) + if success: + return True + if self._connection.stopped(): + self.destroy() + return False + + def accept_chunks(self) -> bool: + return self._accept_chunks + + def stopped(self) -> bool: + if self._stopped: + return self._stopped + self._stopped = self._connection.stopped() + return self._stopped + + def destroy(self): + self._stopped = True + del self._connection + + +class ArrayReceiver(Receiver): + def __init__(self, connection: ArrayStreamConnection, idle: float): + self._connection: ArrayStreamConnection = connection + self._idle = idle + self._stopped = False + self._received: List[ArrayReceived] = [] + + def received(self) -> Iterable[Received]: + if self._stopped: + return [] + idx = 0 + while not self._connection.stopped(): + msg_id = self._connection.get_msg_id(idx) + if msg_id is not None: + yield ArrayReceived(msg_id, self._connection, self._idle) + idx += 1 + else: + time.sleep(self._idle) + while msg_id := self._connection.get_msg_id(idx): + yield ArrayReceived(msg_id, self._connection, self._idle) + self.destroy() + + def destroy(self): + if self._stopped: + return + for item in self._received: + item.destroy() + del self._connection + del self._received + + +class ArrayReceived(Received): + + def __init__(self, msg_id: str, connection: ArrayStreamConnection, idle: float) -> None: + self._msg_id = msg_id + self._connection = connection + self._stopped = False + self._head = self._connection.get_msg_head(self._msg_id) + self._tail: Optional[Message] = None + self._idle = idle + + def added(self) -> Message: + if self._head is None: + raise ValueError("No head received") + return self._head + + def destroy(self) -> None: + if self._stopped: + return + self._stopped = True + del self._head + del self._connection + del self._tail + + def chunks(self) -> Iterable[Message]: + idx = 0 + stopped = False + while True: + stopped = stopped or self._connection.stopped() + tail = self._connection.get_msg_tail(self._msg_id) + if tail is not None: + self._tail = tail + return + chunks = self._connection.get_msg_chunks(msg_id=self._msg_id) + if idx < len(chunks): + yield chunks[idx] + idx += 1 + elif not stopped: + time.sleep(self._idle) + else: + return + + def done(self) -> Message: + if self._tail is None: + raise ValueError("No tail received or read before") + return self._tail From bb86cd5c17d0d31a56739bdac3ef8c3f85803e0b Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 12 Oct 2024 02:49:03 +0800 Subject: [PATCH 033/148] dev: add userinfo class but not decide to use it yet --- ghostos/core/ghosts/user.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 ghostos/core/ghosts/user.py diff --git a/ghostos/core/ghosts/user.py b/ghostos/core/ghosts/user.py new file mode 100644 index 00000000..17dd3321 --- /dev/null +++ b/ghostos/core/ghosts/user.py @@ -0,0 +1,15 @@ +from typing import List +from abc import ABC, abstractmethod +from ghostos.abc import Identifiable +from ghostos.core.llms import ChatPreparer + + +class User(Identifiable, ChatPreparer, ABC): + + @abstractmethod + def allow(self, action: str, *args, **kwargs) -> bool: + pass + + @abstractmethod + def authorized(self, resource: str, *args, **kwargs) -> List[str]: + pass From 891856d72fd415a42e3a38a3f2b09ca2a4d3b418 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 12 Oct 2024 02:49:56 +0800 Subject: [PATCH 034/148] dev: pool add new function and submit return future --- ghostos/contracts/pool.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/ghostos/contracts/pool.py b/ghostos/contracts/pool.py index 69a3d687..fe4516a8 100644 --- a/ghostos/contracts/pool.py +++ b/ghostos/contracts/pool.py @@ -1,6 +1,7 @@ from typing import Callable, Optional, Type +from typing_extensions import Self from abc import ABC, abstractmethod -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor, Future from ghostos.container import Provider, Container @@ -10,7 +11,15 @@ class Pool(ABC): """ @abstractmethod - def submit(self, caller: Callable, *args, **kwargs) -> Callable: + def submit(self, caller: Callable, *args, **kwargs) -> Future: + pass + + @abstractmethod + def new(self, size: int) -> Self: + """ + use the same class to create a new pool, + or split a quota to create a sub pool. + """ pass @abstractmethod @@ -23,8 +32,11 @@ def __init__(self, size: int): self.size = size self.pool = ThreadPoolExecutor(max_workers=size) - def submit(self, caller: Callable, *args, **kwargs) -> None: - self.pool.submit(caller, *args, **kwargs) + def submit(self, caller: Callable, *args, **kwargs) -> Future: + return self.pool.submit(caller, *args, **kwargs) + + def new(self, size: int) -> Self: + return DefaultPool(size) def shutdown(self, wait=True, *, cancel_futures=False): self.pool.shutdown(wait=wait, cancel_futures=cancel_futures) From f608ef4fd43c5895847f3a574b01e6dfeebc5950 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 13 Oct 2024 00:24:04 +0800 Subject: [PATCH 035/148] feat: develop stream receiver and test them, fix a ton of issues --- ghostos/core/messages/__init__.py | 2 +- ghostos/core/messages/message.py | 32 +- ghostos/core/messages/stream.py | 63 +++- ghostos/core/session/messenger.py | 7 +- ghostos/framework/messages/buffers.py | 24 +- ghostos/framework/messengers/defaults.py | 39 ++- ghostos/framework/streams/__init__.py | 2 + ghostos/framework/streams/array.py | 314 ++++++++++-------- ghostos/framework/streams/empty.py | 17 +- ghostos/framework/streams/queuestream.py | 38 +-- ghostos/helpers/time.py | 13 +- ghostos/prototypes/console/app.py | 2 +- tests/framework/messages/test_buffer.py | 29 +- tests/framework/messenger/test_messenger.py | 39 ++- .../framework/streams/test_arr_connection.py | 192 +++++++++++ tests/python/test_class.py | 13 + 16 files changed, 589 insertions(+), 237 deletions(-) create mode 100644 tests/framework/streams/test_arr_connection.py diff --git a/ghostos/core/messages/__init__.py b/ghostos/core/messages/__init__.py index 0d22b22b..1fe549c3 100644 --- a/ghostos/core/messages/__init__.py +++ b/ghostos/core/messages/__init__.py @@ -9,4 +9,4 @@ ) from ghostos.core.messages.buffers import Buffer, Flushed from ghostos.core.messages.helpers import copy_messages -from ghostos.core.messages.stream import Stream, Receiver, Received, Connection +from ghostos.core.messages.stream import Stream, Receiver, Received diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index eb001062..c8158877 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -63,7 +63,8 @@ def new( self, *, content: str, role: str = Role.ASSISTANT.value, memory: Optional[str] = None, name: Optional[str] = None, ) -> "Message": - return Message(content=content, memory=memory, name=name, type=self.value, role=role) + chunk = not self.is_protocol_type(self.value) + return Message(content=content, memory=memory, name=name, type=self.value, role=role, chunk=chunk) def new_assistant( self, *, @@ -93,15 +94,19 @@ def match(self, message: "Message") -> bool: @classmethod def final(cls): - return Message(type=cls.FINAL.value, role=Role.ASSISTANT.value) + return Message(type=cls.FINAL.value, role=Role.ASSISTANT.value, chunk=False) @classmethod def is_final(cls, pack: "Message") -> bool: return pack.type == cls.FINAL.value @classmethod - def is_protocol_type(cls, message: "Message"): - return not message.chunk and message.type in {cls.ERROR, cls.FINAL} + def is_protocol_message(cls, message: "Message") -> bool: + return cls.is_protocol_type(message.type) + + @classmethod + def is_protocol_type(cls, value: str) -> bool: + return value in {cls.ERROR, cls.FINAL} class Caller(BaseModel): @@ -224,7 +229,7 @@ class Message(BaseModel): ref_id: Optional[str] = Field(default=None, description="the referenced message id.") type: str = Field(default="", description="default message type, if empty, means text") created: float = Field( - default=0, + default=0.0, description="Message creation time, only available in head chunk or complete one", ) chunk: bool = Field(default=True, description="if the message is a chunk or a complete one") @@ -382,11 +387,26 @@ def patch(self, chunk: "Message") -> Optional["Message"]: # if not a chunk, just return the tail message. # tail message may be changed by outside method such as moderation. if not chunk.chunk: - return chunk + return chunk.model_copy() # otherwise, update current one. self.update(chunk) + # add msg_id to each chunk + chunk.msg_id = self.msg_id return self + def as_head(self) -> Self: + item = self.model_copy(deep=True) + if not item.msg_id: + item.msg_id = uuid() + if not self.created: + item.created = time.time() + return item + + def as_tail(self) -> Self: + item = self.as_head() + item.chunk = False + return item + def get_copy(self) -> "Message": """ :return: deep copy diff --git a/ghostos/core/messages/stream.py b/ghostos/core/messages/stream.py index 235c4aab..86d22725 100644 --- a/ghostos/core/messages/stream.py +++ b/ghostos/core/messages/stream.py @@ -1,25 +1,44 @@ from abc import ABC, abstractmethod -from typing import Iterable, Tuple +from typing import Iterable, Tuple, Optional, Callable from ghostos.core.messages.message import Message __all__ = [ "Stream", "Receiver", "Received", - "Connection", ] class Stream(ABC): """ streaming output messages. + with stream: + stream.send(message_item) + ... + # when the stream exits, it will send the protocol final item. + + 1. when stream context is exited, the stream send final message to the receiver. + 2. when a protocol item send to stream, it will stop. """ @abstractmethod def deliver(self, pack: "Message") -> bool: """ - deliver a pack of message, may be a chunk + deliver a message. + a message shall be a head, chunk or a tail. + - head: first chunk message with msg id + - chunk: part of the complete message, if msg id exists, should be the same as head. + - tail: complete message that join all the chunks, has msg_id + + when msg type is Protocol type, means the stream shall stop. + + stream can deliver multiple batch of message chunks. like: + [tail], [head, chunk, chunk, tail], [head, tail], [tail, tail, tail] + - tail only: one complete message at a time. + - head => chunks => tail: normal sequences of chunks. + - head => tail: no chunks needed + - tail => tail: the new tail shall replace the current tail. + if an error message or a final message is delivering, the stream usually stop immediately. - but nesting stream can accept multiple final messages, only stop when it's done method is called. :return: if the message was delivered. if the stream is stopped, return False. """ pass @@ -28,7 +47,7 @@ def deliver(self, pack: "Message") -> bool: def accept_chunks(self) -> bool: """ weather the stream is sending chunks. - if False, the stream will send joined chunks as a single message only. + if False, the stream will ignore all the chunks """ pass @@ -50,33 +69,51 @@ def stopped(self) -> bool: """ pass + def __enter__(self): + return self + + @abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb): + pass + class Received(ABC): + """ + api for a batch of message chunks + """ @abstractmethod - def added(self) -> Message: + def head(self) -> Message: + """ + :return: head chunk of the message chunks. + may be the head chunk is the tail. + """ pass @abstractmethod def chunks(self) -> Iterable[Message]: + """ + iterate over the message chunks. + from head (if head is not the tail) to the last chunk + """ pass @abstractmethod def done(self) -> Message: + """ + retail the complete message of the chunks. + """ pass class Receiver(ABC): @abstractmethod - def received(self) -> Iterable[Received]: + def __enter__(self) -> Iterable[Received]: pass - -class Connection(ABC): @abstractmethod - def __enter__(self) -> Tuple[Stream, Receiver]: + def __exit__(self, exc_type, exc_val, exc_tb): pass - @abstractmethod - def __exit__(self, exc_type, exc_val, exc_tb) -> bool: - pass + +new_connection = Callable[[], Tuple[Stream, Receiver]] diff --git a/ghostos/core/session/messenger.py b/ghostos/core/session/messenger.py index e314a9e3..c95f07c9 100644 --- a/ghostos/core/session/messenger.py +++ b/ghostos/core/session/messenger.py @@ -1,10 +1,7 @@ -from typing import Optional, Iterable, NamedTuple, List, Tuple +from typing import NamedTuple, List, Tuple from abc import ABC, abstractmethod -from ghostos.core.messages.message import Message, Payload, Attachment, Caller, Role -from ghostos.core.messages.buffers import Buffer +from ghostos.core.messages.message import Message, Caller, Role from ghostos.core.messages.stream import Stream -from ghostos.core.session.threads import MsgThread -from ghostos.core.llms import FunctionalToken __all__ = ['Messenger', 'Buffed'] diff --git a/ghostos/framework/messages/buffers.py b/ghostos/framework/messages/buffers.py index 15c4c045..fd7162b4 100644 --- a/ghostos/framework/messages/buffers.py +++ b/ghostos/framework/messages/buffers.py @@ -35,8 +35,6 @@ def __init__( """正在 buff 的消息体. """ self._buffed_messages: List[Message] = [] - """发送出去的完整消息体. """ - self._buffed_callers: List[Caller] = [] """过程中 buff 的 caller. """ self._origin_functional_tokens = functional_tokens @@ -106,7 +104,7 @@ def _buff(self, pack: "Message") -> Iterable[Message]: return [] # 不深拷贝的话, 加工逻辑就会交叉污染? # pack = origin.model_copy(deep=True) - if DefaultMessageTypes.is_protocol_type(pack): + if DefaultMessageTypes.is_protocol_message(pack): # final 包不进行 buffer. yield pack return @@ -255,11 +253,6 @@ def _buff_tail_pack(self, tail: Message) -> None: # 剥离所有的 callers. self._buffed_messages.append(tail) - # 从标准的 payload 和 attachments 里读取 caller. - if tail.callers: - for caller in tail.callers: - self._buffed_callers.append(caller) - def _wrap_first_pack(self, pack: Message) -> Message: # 首包强拷贝, 用来做一个 buffer. pack = pack.model_copy(deep=True) @@ -336,7 +329,9 @@ def _generate_current_caller(self) -> Optional[Caller]: if not self._current_functional_token: return None functional_token = self._functional_token_starts[self._current_functional_token] - return functional_token.new_caller(self._current_functional_token_content) + caller = functional_token.new_caller(self._current_functional_token_content) + self._current_functional_token = "" + return caller def new(self) -> "DefaultBuffer": return DefaultBuffer( @@ -354,10 +349,17 @@ def flush(self) -> Flushed: self._buff_tail_pack(unsent) deliver.append(unsent) - flushed = Flushed(unsent=deliver, messages=self._buffed_messages, callers=self._buffed_callers) + callers = [] + messages = self._buffed_messages + for item in messages: + callers.extend(item.callers) + flushed = Flushed( + unsent=deliver, + messages=messages, + callers=callers, + ) self._reset_buffering() self._buffed_messages = [] - self._buffed_callers = [] return flushed def destroy(self) -> None: diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index 738cea4b..16e38496 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -183,19 +183,19 @@ def _deliver_to_upstream(self, delivery: Iterable[Message]) -> bool: if self._stopped: return False for item in delivery: - if not DefaultMessageTypes.is_protocol_type(item) and item.chunk and not self._accept_chunks: + if not DefaultMessageTypes.is_protocol_message(item) and item.chunk and not self._accept_chunks: continue # 如果发送不成功, 直接中断. # if self._depth == 0: # item.streaming_id = None - if ( - self._saving - and self._thread is not None # thread exists. - and not DefaultMessageTypes.is_protocol_type(item) # not a protocol type message. - and not item.chunk - ): # is tail package. - # append tail message to thread. - self._thread.append(item) + # if ( + # self._saving + # and self._thread is not None # thread exists. + # and not DefaultMessageTypes.is_protocol_type(item) # not a protocol type message. + # and not item.chunk + # ): # is tail package. + # # append tail message to thread. + # self._thread.append(item) if self._upstream is not None: success = self._upstream.deliver(item) @@ -206,11 +206,13 @@ def _deliver_to_upstream(self, delivery: Iterable[Message]) -> bool: return True def flush(self) -> Buffed: - if self._stopped: + if self._stopped or self._destroyed: return Buffed(messages=[], callers=[]) buffed = self._buffer.flush() if buffed.unsent: self._deliver_to_upstream(buffed.unsent) + if self._thread: + self._thread.append(*buffed.messages) self._stop(None) return Buffed(messages=buffed.messages, callers=buffed.callers) @@ -221,7 +223,7 @@ def _stop(self, final: Optional[Message]) -> None: self._stopped = True if self._destroyed: return - if final is None or not DefaultMessageTypes.is_protocol_type(final): + if final is None or not DefaultMessageTypes.is_protocol_message(final): final = DefaultMessageTypes.final() self._deliver_to_upstream([final]) self.destroy() @@ -235,6 +237,14 @@ def stopped(self) -> bool: self._stopped = True return self._stopped + def __exit__(self, exc_type, exc_val, exc_tb): + if self._stopped: + return + self.flush() + if exc_val: + self._stop(DefaultMessageTypes.ERROR.new(content=str(exc_val))) + self.destroy() + def destroy(self) -> None: """ I kind of don't trust python gc, let me help some @@ -244,12 +254,10 @@ def destroy(self) -> None: return self._destroyed = True del self._upstream - if self._buffer: - self._buffer.flush() - del self._buffer + self._buffer = None del self._payloads del self._attachments - del self._thread + self._thread = None del self._functional_tokens @@ -266,4 +274,3 @@ def contract(self) -> Type[Messenger]: def factory(self, con: Container) -> Messenger: return DefaultMessenger() - diff --git a/ghostos/framework/streams/__init__.py b/ghostos/framework/streams/__init__.py index 7765ab46..0597d665 100644 --- a/ghostos/framework/streams/__init__.py +++ b/ghostos/framework/streams/__init__.py @@ -1,2 +1,4 @@ +from ghostos.core.messages import Stream, Receiver, Received +from ghostos.framework.streams.array import new_connection from ghostos.framework.streams.queuestream import QueueStream from ghostos.framework.streams.empty import EmptyStream diff --git a/ghostos/framework/streams/array.py b/ghostos/framework/streams/array.py index 01ec317e..b790b959 100644 --- a/ghostos/framework/streams/array.py +++ b/ghostos/framework/streams/array.py @@ -1,201 +1,233 @@ -from typing import Tuple, Optional, Dict, List, Union, Iterable - +from typing import Tuple, Optional, Dict, List, Iterable, Callable from ghostos.core.messages import ( - Message, Stream, Connection, Receiver, Received, + Message, Stream, Receiver, Received, DefaultMessageTypes, ) -from threading import Lock import time +__all__ = ['new_connection'] + +from ghostos.helpers import Timeleft + -class ArrayStreamConnection(Connection): +def new_connection(timeout: float, accept_chunks: bool, idle: float = 0.2) -> Tuple[Stream, Receiver]: """ - 考虑到 Python 的 array 和 map 的操作是线程安全的, 试试用这个来做. + create a stream and a receiver, which are run at different threads. + when receiver is stopped, stream stop immediately. + :param timeout: + :param accept_chunks: + :param idle: + :return: """ + receiver = _ArrayReceiver(idle=idle) + stream = _ArrayStream(receiver, timeout=timeout, accept_chunks=accept_chunks) + return stream, receiver - def __init__(self, accept_chunks: bool = True, idle: float = 0.2): - self._stopped = True - self._accept_chunks = accept_chunks - self._final: Optional[Message] = None - self._current_msg_id: str = "" - self._msg_ids = [] - self._message_heads: Dict[str, Union[Message, None]] = {} - self._message_chunks: Dict[str, List[Message]] = {} - self._message_tails: Dict[str, Union[Message, None]] = {} - self._locker = Lock() - self._receiver: Optional[ArrayReceiver] = None - self._stream: Optional[ArrayStream] = None + +class _ArrayReceiver(Receiver): + def __init__(self, idle: float): self._idle = idle + self._stopped = False + self._received: Dict[str, _ArrayReceived] = {} + self._msg_ids: List[str] = [] + self._final: Optional[Message] = None + self._buffering: Optional[Message] = None + self._destroyed: bool = False + self._iterating: bool = False def add_item(self, item: Message) -> bool: if self._stopped: return False - # item 还是加锁吧. - with self._locker: - if DefaultMessageTypes.is_protocol_type(item): - self._stopped = False - self._final = item - return True - if not self._accept_chunks and item.chunk: - return True - - msg_id = item.msg_id - if msg_id and msg_id != self._current_msg_id and msg_id not in self._msg_ids: - self._msg_ids.append(msg_id) - self._current_msg_id = msg_id - - # if the item is the tail of the chunks - if item.is_complete(): - self._message_tails[msg_id] = item - # then the item is a chunk - elif msg_id: - self._message_heads[msg_id] = item - else: - msg_id = self._current_msg_id - items = self._message_chunks.get(msg_id, []) - items.append(item) - self._message_chunks[msg_id] = items + if DefaultMessageTypes.is_protocol_message(item): + self.stop(item) + return True - def stopped(self) -> bool: - return self._stopped + if self._buffering is None: + self._new_received(item) + return True - def get_msg_head(self, msg_id: str) -> Optional[Message]: - return self._message_heads.get(msg_id, None) + patched = self._buffering.patch(item) + if patched: + self._append_item(item) + return True + else: + tail = self._buffering.as_tail() + self._append_item(tail) + self._new_received(item) + return True - def get_msg_tail(self, msg_id: str) -> Optional[Message]: - return self._message_tails.get(msg_id, None) + def _new_received(self, item: Message) -> None: + msg_id = item.msg_id + if not item.is_complete(): + self._buffering = item.as_head() + msg_id = self._buffering.msg_id + received = _ArrayReceived(item, idle=self.idle) + self._received[msg_id] = received + self._msg_ids.append(msg_id) - def get_msg_chunks(self, msg_id: str) -> List[Message]: - return self._message_chunks.get(msg_id, []) + def _append_item(self, item: Message) -> None: + msg_id = self._buffering.msg_id + received = self._received[msg_id] + received.add_item(item) - def get_msg_id(self, idx: int) -> Optional[str]: - if len(self._msg_ids) > idx: - return self._msg_ids[idx] - return None + def stopped(self) -> bool: + return self._stopped - def __enter__(self) -> Tuple[Stream, Receiver]: - self._stream = ArrayStream(self, self._accept_chunks) - self._receiver = ArrayReceiver(self, self._idle) - return self._stream, self._receiver + def idle(self) -> bool: + time.sleep(self._idle) + return not self._stopped def __exit__(self, exc_type, exc_val, exc_tb): - if self._stream: - self._stream.destroy() - del self._stream - if self._receiver: - self._receiver.destroy() - del self._receiver + if self._stopped: + return + self.destroy() + + def __enter__(self) -> Iterable[Received]: + if self._iterating: + raise RuntimeError("Cannot iterating Retriever at the same time") + self._iterating = True + idx = 0 + while not self._stopped: + if idx < len(self._msg_ids): + msg_id = self._msg_ids[idx] + idx += 1 + yield self._received[msg_id] + else: + time.sleep(self._idle) + while idx < len(self._msg_ids): + yield self._received[self._msg_ids[idx]] + idx += 1 + if self._final and DefaultMessageTypes.ERROR.match(self._final): + yield _ArrayReceived(self._final, idle=self.idle) + self._iterating = False + + def stop(self, item: Optional[Message]) -> None: + if self._stopped: + return + self._stopped = True + if self._buffering: + tail = self._buffering.as_tail() + self._append_item(tail) + self._buffering = None + self._final = item + + def destroy(self): + if self._destroyed: + return + self._destroyed = True + self._stopped = True + for item in self._received.values(): + item.destroy() + del self._buffering del self._final - del self._message_chunks - del self._message_tails del self._msg_ids - del self._locker + del self._received -class ArrayStream(Stream): +class _ArrayStream(Stream): - def __init__(self, connection: ArrayStreamConnection, accept_chunks: bool): - self._connection: ArrayStreamConnection = connection + def __init__(self, receiver: _ArrayReceiver, timeout: float, accept_chunks: bool = True): + self._receiver = receiver + self._stopped = receiver.stopped() self._accept_chunks = accept_chunks - self._stopped = False + self._timeleft = Timeleft(timeout) def deliver(self, pack: "Message") -> bool: if self._stopped: return False - success = self._connection.add_item(pack) + if not self._timeleft.alive(): + e = TimeoutError(f"Timeout after {self._timeleft.passed()}") + self._receiver.stop(DefaultMessageTypes.ERROR.new(content=str(e))) + raise e + if pack.chunk and not self._accept_chunks: + return True + success = self._receiver.add_item(pack) if success: return True - if self._connection.stopped(): - self.destroy() + if self._receiver.stopped(): + self.stop() return False + def __exit__(self, exc_type, exc_val, exc_tb): + item = None + if exc_val: + item = DefaultMessageTypes.ERROR.new(content=str(exc_val)) + if not self._stopped: + self._receiver.stop(item) + self.stop() + def accept_chunks(self) -> bool: return self._accept_chunks def stopped(self) -> bool: if self._stopped: return self._stopped - self._stopped = self._connection.stopped() + self._stopped = self._receiver.stopped() return self._stopped - def destroy(self): - self._stopped = True - del self._connection - - -class ArrayReceiver(Receiver): - def __init__(self, connection: ArrayStreamConnection, idle: float): - self._connection: ArrayStreamConnection = connection - self._idle = idle - self._stopped = False - self._received: List[ArrayReceived] = [] - - def received(self) -> Iterable[Received]: - if self._stopped: - return [] - idx = 0 - while not self._connection.stopped(): - msg_id = self._connection.get_msg_id(idx) - if msg_id is not None: - yield ArrayReceived(msg_id, self._connection, self._idle) - idx += 1 - else: - time.sleep(self._idle) - while msg_id := self._connection.get_msg_id(idx): - yield ArrayReceived(msg_id, self._connection, self._idle) - self.destroy() - - def destroy(self): + def stop(self): if self._stopped: return - for item in self._received: - item.destroy() - del self._connection - del self._received + self._stopped = True + del self._receiver -class ArrayReceived(Received): +class _ArrayReceived(Received): - def __init__(self, msg_id: str, connection: ArrayStreamConnection, idle: float) -> None: - self._msg_id = msg_id - self._connection = connection + def __init__(self, head: Message, idle: Callable) -> None: + self._idle = idle + self._items: List[Dict] = [head.model_dump(exclude_defaults=True)] self._stopped = False - self._head = self._connection.get_msg_head(self._msg_id) self._tail: Optional[Message] = None - self._idle = idle + if head.is_complete() or DefaultMessageTypes.is_protocol_message(head): + self._tail = head + self._destroyed = False - def added(self) -> Message: - if self._head is None: - raise ValueError("No head received") - return self._head + def add_item(self, item: Message) -> None: + if item.is_complete() or DefaultMessageTypes.is_protocol_message(item): + self._tail = item + else: + self._items.append(item.model_dump(exclude_defaults=True)) - def destroy(self) -> None: - if self._stopped: - return - self._stopped = True - del self._head - del self._connection - del self._tail + def head(self) -> Message: + return Message(**self._items[0]) def chunks(self) -> Iterable[Message]: + if self._tail: + for item in self._items: + yield Message(**item) + return idx = 0 - stopped = False - while True: - stopped = stopped or self._connection.stopped() - tail = self._connection.get_msg_tail(self._msg_id) - if tail is not None: - self._tail = tail - return - chunks = self._connection.get_msg_chunks(msg_id=self._msg_id) - if idx < len(chunks): - yield chunks[idx] + while self._tail is None and not self._stopped: + if idx < len(self._items): + yield Message(**self._items[idx]) idx += 1 - elif not stopped: - time.sleep(self._idle) else: - return + self._stopped = self._idle() + while idx < len(self._items): + yield Message(**self._items[idx]) + idx += 1 + + def destroy(self) -> None: + if self._destroyed: + return + self._destroyed = True + self._stopped = True + del self._items + del self._tail + del self._idle def done(self) -> Message: - if self._tail is None: - raise ValueError("No tail received or read before") - return self._tail + if self._tail: + return self._tail + failed = 0 + while not self._stopped: + if failed > 3: + break + if self._tail: + return self._tail + if not self._idle(): + failed += 1 + if self._tail: + return self._tail + raise RuntimeError(f"empty tail message") diff --git a/ghostos/framework/streams/empty.py b/ghostos/framework/streams/empty.py index ce316617..7ac179e6 100644 --- a/ghostos/framework/streams/empty.py +++ b/ghostos/framework/streams/empty.py @@ -1,6 +1,4 @@ -from typing import Iterable - -from ghostos.core.messages import Stream, Message, DefaultMessageTypes +from ghostos.core.messages import Stream, Message class EmptyStream(Stream): @@ -8,19 +6,14 @@ class EmptyStream(Stream): for mock or test """ - def __init__(self, max_final: int = 0): - self._max_final = max_final - self._final_count = 0 - def deliver(self, pack: "Message") -> bool: - if self.stopped(): - return False - if DefaultMessageTypes.is_final(pack): - self._final_count += 1 return True def accept_chunks(self) -> bool: return False def stopped(self) -> bool: - return self._final_count > self._max_final + return False + + def __exit__(self, exc_type, exc_val, exc_tb): + pass diff --git a/ghostos/framework/streams/queuestream.py b/ghostos/framework/streams/queuestream.py index 052dae8f..00a288a8 100644 --- a/ghostos/framework/streams/queuestream.py +++ b/ghostos/framework/streams/queuestream.py @@ -1,4 +1,4 @@ -from typing import Iterable +from typing import Iterable, Optional from ghostos.core.messages import Stream, Message, DefaultMessageTypes from queue import Queue @@ -12,29 +12,17 @@ class QueueStream(Stream): but I'm not familiar to python thread safe queue... """ - def __init__(self, queue: Queue, streaming: bool = True, max_final: int = 1): + def __init__(self, queue: Queue, accept_chunks: bool = True): self._queue = queue - self._streaming = streaming + self._accept_chunks = accept_chunks self._stopped = False - self._max_final = max_final - self._final_count = 0 def deliver(self, pack: "Message") -> bool: if self._stopped: return False - if DefaultMessageTypes.is_protocol_type(pack): - if DefaultMessageTypes.ERROR.match(pack): - self._queue.put(pack, block=True) - self._queue.task_done() - self._stopped = True - elif DefaultMessageTypes.FINAL.match(pack): - self._final_count += 1 - if self._final_count >= self._max_final: - self._stopped = True - self._queue.task_done() - self._queue.put(pack, block=True) + if DefaultMessageTypes.is_protocol_message(pack): return True - elif self._streaming and not pack.is_complete(): + elif self._accept_chunks and not pack.is_complete(): # 不发送间包, 只发送尾包. return True else: @@ -42,10 +30,22 @@ def deliver(self, pack: "Message") -> bool: return True def accept_chunks(self) -> bool: - return not self._streaming + return not self._accept_chunks def stopped(self) -> bool: return self._stopped - def close(self): + def stop(self, error: Optional[Exception]) -> None: + if self._stopped: + return self._stopped = True + if error: + final = DefaultMessageTypes.ERROR.new(content=str(error)) + else: + final = DefaultMessageTypes.final() + self._queue.put(final) + self._queue.task_done() + del self._queue + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop(exc_val) diff --git a/ghostos/helpers/time.py b/ghostos/helpers/time.py index c4c00395..0c272174 100644 --- a/ghostos/helpers/time.py +++ b/ghostos/helpers/time.py @@ -11,8 +11,13 @@ def __init__(self, timeout: float): self.start = time.time() def left(self) -> float: - if self.timeout <= 0.0: - return 0.0 - now = time.time() - timeleft = self.timeout - (now - self.start) + passed = self.passed() + timeleft = self.timeout - passed return timeleft + + def alive(self) -> bool: + return self.timeout < 0 or self.passed() < self.timeout + + def passed(self) -> float: + now = time.time() + return now - self.start diff --git a/ghostos/prototypes/console/app.py b/ghostos/prototypes/console/app.py index 00450752..94aea1e4 100644 --- a/ghostos/prototypes/console/app.py +++ b/ghostos/prototypes/console/app.py @@ -92,7 +92,7 @@ def _start_background(self): )) def _stream(self) -> QueueStream: - return QueueStream(self._main_queue, streaming=False) + return QueueStream(self._main_queue, accept_chunks=False) async def _main(self): self._welcome() diff --git a/tests/framework/messages/test_buffer.py b/tests/framework/messages/test_buffer.py index 0d423c01..d4a02a6d 100644 --- a/tests/framework/messages/test_buffer.py +++ b/tests/framework/messages/test_buffer.py @@ -84,7 +84,7 @@ def test_buffer_sent(): count_has_message_id += 1 count += 1 assert count == len(content) - assert count_has_message_id == 1 + assert count_has_message_id == count assert len(buffer.flush().messages) == 1 @@ -216,3 +216,30 @@ def test_buffer_with_xml_functional_token(): caller = flushed.callers[0] assert caller.name == "moss" assert caller.arguments == "world" + + +def test_buffer_with_visible_functional_token(): + functional_tokens = [FunctionalToken( + token="", + end_token="", + name="moss", + description="desc", + visible=True, + deliver=False, + )] + + buffer = DefaultBuffer(functional_tokens=functional_tokens) + contents = ["he", "llo w", "orld'] + content = "".join(contents) + for c in contents: + msg = Message.new_chunk(content=c) + buffer.buff(msg) + flushed = buffer.flush() + assert len(flushed.messages) == 1 + assert len(list(flushed.callers)) > 0 + message = flushed.messages[0] + assert message.content == content + assert message.memory is None + caller = flushed.callers[0] + assert caller.name == "moss" + assert caller.arguments == "world" diff --git a/tests/framework/messenger/test_messenger.py b/tests/framework/messenger/test_messenger.py index 7375c09a..32185e83 100644 --- a/tests/framework/messenger/test_messenger.py +++ b/tests/framework/messenger/test_messenger.py @@ -1,8 +1,8 @@ from ghostos.framework.messengers import Messenger, DefaultMessenger +from ghostos.framework.streams import EmptyStream from ghostos.core.session.threads import MsgThread from ghostos.core.messages import Message from ghostos.core.llms import FunctionalToken -import time def test_default_messenger_baseline(): @@ -18,12 +18,12 @@ def test_default_messenger_baseline(): assert thread.current.generates[0].content == content -def test_messenger_with_moss_xml_token(): +def test_messenger_with_random_token(): functional_tokens = [FunctionalToken( token=">moss:", name="moss", description="desc", - deliver=False, + visible=False, )] thread = MsgThread() @@ -43,8 +43,8 @@ def test_messenger_with_moss_xml_token(): assert caller.name == "moss" assert caller.arguments == " world" - assert len(thread.current.generates) == 1 - assert len(thread.current.generates[0].callers) == 1 + assert len(thread.last_turn().generates) == 1 + assert len(thread.last_turn().generates[0].callers) == 1 def test_messenger_with_single_message(): @@ -53,7 +53,7 @@ def test_messenger_with_single_message(): end_token="", name="moss", description="desc", - deliver=False, + visible=False, )] thread = MsgThread() @@ -66,6 +66,31 @@ def test_messenger_with_single_message(): assert flushed.messages[0].memory == content assert len(flushed.callers) == 1 + +def test_messenger_with_func_token_visible(): + functional_tokens = [FunctionalToken( + token="", + end_token="", + name="moss", + description="desc", + visible=True, + )] + + thread = MsgThread() + messenger = DefaultMessenger( + thread=thread, + functional_tokens=functional_tokens, + upstream=EmptyStream(), + ) + + content = "hello worldhello" + messenger.say(content) + flushed = messenger.flush() + assert flushed.messages[0].content == content + assert flushed.messages[0].memory is None + assert len(flushed.callers) == 1 + assert flushed.callers[0].name == "moss" + # def test_async_sub_messengers(): # from threading import Thread # functional_tokens = [FunctionalToken( @@ -73,7 +98,7 @@ def test_messenger_with_single_message(): # end_token="", # name="moss", # description="desc", -# deliver=False, +# visible=False, # )] # # def make(m: Messenger, idx: int): diff --git a/tests/framework/streams/test_arr_connection.py b/tests/framework/streams/test_arr_connection.py new file mode 100644 index 00000000..4559a09c --- /dev/null +++ b/tests/framework/streams/test_arr_connection.py @@ -0,0 +1,192 @@ +import time + +from ghostos.core.messages import Message +from ghostos.framework.streams import new_connection, Stream +from ghostos.framework.messengers import DefaultMessenger +from ghostos.core.session import MsgThread +from ghostos.core.llms import FunctionalToken +from threading import Thread + + +def test_new_connection_baseline(): + stream, retriever = new_connection(timeout=5, accept_chunks=True) + content = "hello world, ha ha ha ha" + + def send_data(s: Stream): + with s: + for c in content: + s.deliver(Message.new_chunk(content=c)) + time.sleep(0.1) + + t = Thread(target=send_data, args=(stream,)) + t.start() + count = 0 + with retriever as items: + for item in items: + head = item.head() + assert head.msg_id + assert head.chunk + assert head.content == "h" + chunks = 0 + for ck in item.chunks(): + assert ck.content == content[chunks] + chunks += 1 + done = item.done() + assert done is not None, f"current {count}: {item}" + assert done.content == content + count += 1 + assert count == 1 + + +def test_new_connection_timeout(): + stream, retriever = new_connection(timeout=0.2, accept_chunks=True) + content = "hello world" + + def send_data(s: Stream): + err = None + try: + with s: + for c in content: + s.deliver(Message.new_chunk(content=c)) + time.sleep(0.5) + except Exception as e: + err = e + assert err is not None + + t = Thread(target=send_data, args=(stream,)) + t.start() + messages = [] + with retriever as items: + for item in items: + done = item.done() + assert done is not None + messages.append(done) + assert len(messages) == 2 + + +def test_new_connection_not_chunks(): + stream, retriever = new_connection(timeout=-1, accept_chunks=True) + content = "hello world" + + def send_data(s: Stream): + with s: + for i in range(5): + s.deliver(Message.new_tail(content=f"{i}{content}")) + time.sleep(0.05) + + t = Thread(target=send_data, args=(stream,)) + t.start() + messages = [] + with retriever as items: + for item in items: + head = item.head() + assert head is not None + assert head.content.endswith(content) + assert len(list(item.chunks())) == 1 + done = item.done() + assert done is not None + messages.append(done) + assert len(messages) == 5 + for i in range(len(messages)): + assert messages[i].content.startswith(str(i)) + + +def test_new_connection_sync(): + stream, retriever = new_connection(timeout=5, accept_chunks=True) + content = "hello world, ha ha ha ha" + + with stream: + for c in content: + stream.deliver(Message.new_chunk(content=c)) + + messages = [] + with retriever as items: + for item in items: + done = item.done() + messages.append(done) + assert len(messages) == 1 + + +def test_new_connection_with_messenger_sync(): + stream, retriever = new_connection(timeout=5, accept_chunks=True) + content = "hello world, ha ha ha ha" + + with stream: + messenger = DefaultMessenger(upstream=stream, thread=MsgThread()) + with messenger: + for c in content: + messenger.deliver(Message.new_chunk(content=c)) + + messages = [] + with retriever as items: + for item in items: + done = item.done() + messages.append(done) + assert len(messages) == 1 + + +def test_new_connection_with_messenger_async(): + stream, retriever = new_connection(timeout=5, accept_chunks=True) + content = "hello world, ha ha ha ha" + + def send_data(s: Stream): + with s: + messenger = DefaultMessenger(upstream=s, thread=MsgThread()) + with messenger: + for c in content: + messenger.deliver(Message.new_chunk(content=c)) + flushed = messenger.flush() + assert len(flushed.messages) == 1 + + t = Thread(target=send_data, args=(stream,)) + t.start() + + messages = [] + with retriever as items: + for item in items: + done = item.done() + messages.append(done) + assert len(messages) == 1 + t.join() + + +def test_new_connection_with_functional_tokens(): + stream, retriever = new_connection(timeout=5, accept_chunks=True) + content = "hello worldhello" + + msg_thread = MsgThread() + + def send_data(s: Stream): + with s: + messenger = DefaultMessenger( + upstream=s, + thread=msg_thread, + functional_tokens=[ + FunctionalToken( + name="moss", + token="", + end_token="", + visible=True, + ) + ] + ) + for c in content: + messenger.deliver(Message.new_chunk(content=c)) + flushed = messenger.flush() + assert len(flushed.messages) == 1 + assert len(flushed.callers) == 1 + assert flushed.messages[0].memory is None + + t = Thread(target=send_data, args=(stream,)) + t.start() + + messages = [] + with retriever as items: + for item in items: + done = item.done() + messages.append(done) + assert len(messages) == 1 + assert len(messages[0].callers) == 1 + assert messages[0].callers[0].arguments == "hello" + assert len(msg_thread.last_turn().generates[0].callers) == 1 + t.join() diff --git a/tests/python/test_class.py b/tests/python/test_class.py index b089480d..7f3f13a7 100644 --- a/tests/python/test_class.py +++ b/tests/python/test_class.py @@ -201,3 +201,16 @@ class Foo1(Foo): foo: int = 11 assert Foo1 is not Foo + + +def test_generic_class_is_same(): + from typing import Generic, TypeVar + T = TypeVar("T") + + class Foo(Generic[T]): + def __init__(self, val: T): + self.val = val + assert Foo[int] is Foo[int] + obj1 = Foo[int](1) + obj2 = Foo[int](2) + assert type(obj1) is type(obj2) From 1273cd78257f5e7bc3cfef30fc67e93b39bc84e2 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 13 Oct 2024 00:29:38 +0800 Subject: [PATCH 036/148] refact: rename GhostProcess to session process, make myself busy looking --- ghostos/core/ghostos.py | 12 ++++----- ghostos/core/session/__init__.py | 2 +- ghostos/core/session/events.py | 24 +++++++++++++++++- ghostos/core/session/processes.py | 14 +++++------ ghostos/core/session/session.py | 6 ++--- ghostos/core/session/threads.py | 16 ++++++++---- ghostos/framework/ghostos/basic.py | 4 +-- ghostos/framework/ghostos/demo_os.py | 6 ++--- ghostos/framework/ghosts/basic.py | 6 ++--- ghostos/framework/ghosts/demo.py | 4 +-- .../framework/processes/storage_processes.py | 10 ++++---- ghostos/framework/session/basic.py | 25 +++++++++++-------- 12 files changed, 81 insertions(+), 48 deletions(-) diff --git a/ghostos/core/ghostos.py b/ghostos/core/ghostos.py index 7f5b1443..dd14da1e 100644 --- a/ghostos/core/ghostos.py +++ b/ghostos/core/ghostos.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from ghostos.entity import EntityMeta from ghostos.core.messages import Stream -from ghostos.core.session import EventBus, Event, TaskRepo, Task, GhostProcess, GhostProcessRepo +from ghostos.core.session import EventBus, Event, TaskRepo, Task, SessionProcess, GhostProcessRepo from ghostos.core.ghosts import Ghost, GhostConf, Inputs from ghostos.contracts.logger import LoggerItf from ghostos.contracts.shutdown import Shutdown @@ -43,7 +43,7 @@ def get_or_create_process( session_id: str, process_id: Optional[str] = None, task_id: Optional[str] = None, - ) -> Optional[GhostProcess]: + ) -> Optional[SessionProcess]: """ get a process from session_id, if not exists, create one. :param ghost_meta: to create ghost instance. @@ -58,7 +58,7 @@ def get_or_create_process( def make_ghost( self, *, upstream: Stream, - process: GhostProcess, + process: SessionProcess, task: Optional[Task] = None, task_id: Optional[str] = None, ) -> Ghost: @@ -155,7 +155,7 @@ def _tasks(self) -> TaskRepo: def make_ghost( self, *, upstream: Stream, - process: GhostProcess, + process: SessionProcess, task: Optional[Task] = None, task_id: Optional[str] = None, ) -> Ghost: @@ -170,11 +170,11 @@ def get_or_create_process( session_id: str, process_id: Optional[str] = None, task_id: Optional[str] = None, - ) -> Optional[GhostProcess]: + ) -> Optional[SessionProcess]: processes = self._processes() proc = processes.get_session_process(session_id) if proc is None or (process_id and process_id != proc.pid): - proc = GhostProcess.new( + proc = SessionProcess.new( session_id=session_id, ghost_meta=ghost_meta, process_id=process_id, diff --git a/ghostos/core/session/__init__.py b/ghostos/core/session/__init__.py index e7a61025..47807013 100644 --- a/ghostos/core/session/__init__.py +++ b/ghostos/core/session/__init__.py @@ -4,7 +4,7 @@ TaskRepo, TaskState, WaitGroup, ) from ghostos.core.session.threads import MsgThreadRepo, MsgThread, thread_to_chat, Turn -from ghostos.core.session.processes import GhostProcess, GhostProcessRepo +from ghostos.core.session.processes import SessionProcess, GhostProcessRepo from ghostos.core.session.messenger import Messenger, Buffed from ghostos.core.session.events import Event, EventBus, DefaultEventType from ghostos.core.session.simple_thread import SimpleMsgThread diff --git a/ghostos/core/session/events.py b/ghostos/core/session/events.py index 15304e11..fd769b44 100644 --- a/ghostos/core/session/events.py +++ b/ghostos/core/session/events.py @@ -1,4 +1,5 @@ from typing import List, Optional, Dict +from typing_extensions import Self from abc import ABC, abstractmethod from enum import Enum from pydantic import BaseModel, Field @@ -28,7 +29,10 @@ class Event(BaseModel): default="", description="event type, by default the handler shall named on_{type}" ) - + name: Optional[str] = Field( + default=None, + description="sender name of this event messages", + ) id: str = Field( default_factory=uuid, description="event id", @@ -173,10 +177,24 @@ def new( ) +# EventBus 要实现分流设计 +# Task notification queue => Task Event queue +# 0. 一个实例接受到 Task notification 后, 开始对这个 Task 上锁. +# 1. Task 只有在上锁 (全局只有一个实例在处理这个 Task) 后才能消费 Task Event queue +# 2. 如果对 Task 上锁不成功, 意味着有别的实例在消费 Task Event queue. +# 3. 如果 Task 上锁成功, 而拿不到 Event, 说明 Event 被别的实例消费完了. 这时不继续发送 notification. +# 4. 如果 Task 上锁成功, 消费到了 Event, 它应该反复更新锁, 继续消费下去. +# 5. 如果消费 Event 结束, 或者出错, 应该再发送一个 notification. 让下一个拿到的实例检查是否消息都消费完了. +# 6. 发送 event 时, 应该发送 notification, 这样异步消费队列才能感知到 +# 7. n 个事件理论上会发送 n 个 notification. 而在消费过程中被生产, 丢弃的 notification 应该有 1 ~ n 个. +# 8. 如果目标 task 是会话进程的主 task, 而会话状态是端侧管理, 则不应该 notify, 而是由端侧来做推送. class EventBus(ABC): """ global event bus. """ + # @abstractmethod + # def with_process_id(self, process_id: str) -> Self: + # pass @abstractmethod def send_event(self, e: Event, notify: bool) -> None: @@ -218,6 +236,10 @@ def notify_task(self, task_id: str) -> None: def clear_task(self, task_id: str) -> None: pass + # @abstractmethod + # def clear_all(self): + # pass + @contextmanager def transaction(self): yield diff --git a/ghostos/core/session/processes.py b/ghostos/core/session/processes.py index fa54d978..92366aab 100644 --- a/ghostos/core/session/processes.py +++ b/ghostos/core/session/processes.py @@ -6,12 +6,12 @@ from ghostos.helpers import uuid __all__ = [ - 'GhostProcess', + 'SessionProcess', 'GhostProcessRepo', ] -class GhostProcess(BaseModel): +class SessionProcess(BaseModel): process_id: str = Field( description=""" Unique process id for the agent session. Session shall only have one process a time. @@ -48,10 +48,10 @@ def new( ghost_meta: EntityMeta, process_id: Optional[str] = None, main_task_id: Optional[str] = None, - ) -> "GhostProcess": + ) -> "SessionProcess": process_id = process_id if process_id else uuid() main_task_id = process_id if main_task_id is None else main_task_id - return GhostProcess( + return SessionProcess( session_id=session_id, process_id=process_id, main_task_id=main_task_id, @@ -65,7 +65,7 @@ class GhostProcessRepo(ABC): """ @abstractmethod - def get_process(self, process_id: str) -> Optional[GhostProcess]: + def get_process(self, process_id: str) -> Optional[SessionProcess]: """ get process by id :param process_id: process id @@ -73,14 +73,14 @@ def get_process(self, process_id: str) -> Optional[GhostProcess]: pass @abstractmethod - def get_session_process(self, session_id: str) -> Optional[GhostProcess]: + def get_session_process(self, session_id: str) -> Optional[SessionProcess]: """ get session process by session id """ pass @abstractmethod - def save_process(self, process: GhostProcess) -> None: + def save_process(self, process: SessionProcess) -> None: """ save process :param process: diff --git a/ghostos/core/session/session.py b/ghostos/core/session/session.py index 37772056..2291b76d 100644 --- a/ghostos/core/session/session.py +++ b/ghostos/core/session/session.py @@ -3,7 +3,7 @@ from ghostos.core.session.events import Event, EventBus from ghostos.core.session.messenger import Messenger -from ghostos.core.session.processes import GhostProcessRepo, GhostProcess +from ghostos.core.session.processes import GhostProcessRepo, SessionProcess from ghostos.core.session.tasks import TaskRepo, Task, TaskBrief from ghostos.core.session.threads import MsgThreadRepo, MsgThread from ghostos.core.messages import MessageKind, Role, Buffer, Payload, Attachment, Message @@ -56,7 +56,7 @@ def refresh_lock(self) -> bool: # pass @abstractmethod - def process(self) -> "GhostProcess": + def process(self) -> "SessionProcess": """ 当前会话所处的进程数据. 不允许直接修改. 只有指定的 API 会修改结果并保存. @@ -119,7 +119,7 @@ def update_task(self, task: "Task", thread: Optional["MsgThread"], update_histor pass @abstractmethod - def update_process(self, process: "GhostProcess") -> None: + def update_process(self, process: "SessionProcess") -> None: """ 改动 process 并保存. 通常只在初始化里才需要. """ diff --git a/ghostos/core/session/threads.py b/ghostos/core/session/threads.py index 20984152..8facc062 100644 --- a/ghostos/core/session/threads.py +++ b/ghostos/core/session/threads.py @@ -60,6 +60,14 @@ def append(self, *messages: Message, pycontext: Optional[PyContext] = None) -> N def event_messages(self) -> Iterable[Message]: event = self.event + name = event.name + for message in self.iter_event_message(event): + if message.name is None: + message.name = name + yield message + + @staticmethod + def iter_event_message( event: Event) -> Iterable[Message]: if event is None: return [] @@ -72,7 +80,7 @@ def event_messages(self) -> Iterable[Message]: # messages in middle if event.messages: - for message in self.event.messages: + for message in event.messages: yield message # instruction after messages. @@ -242,10 +250,8 @@ def append(self, *messages: Message, pycontext: Optional[PyContext] = None) -> N """ if self.current is None: self.new_turn(None) - if messages: - self.current.append(*messages) - if pycontext: - self.current.pycontext = pycontext + if messages or pycontext: + self.current.append(*messages, pycontext=pycontext) def get_generates(self) -> List[Message]: if self.current is None: diff --git a/ghostos/framework/ghostos/basic.py b/ghostos/framework/ghostos/basic.py index 0e1654f0..b6a72de4 100644 --- a/ghostos/framework/ghostos/basic.py +++ b/ghostos/framework/ghostos/basic.py @@ -7,7 +7,7 @@ from ghostos.core.ghostos import AbsGhostOS from ghostos.core.ghosts import Ghost from ghostos.core.messages import Stream -from ghostos.core.session import GhostProcess, Task +from ghostos.core.session import SessionProcess, Task from ghostos.contracts.shutdown import ShutdownProvider from ghostos.contracts.modules import Modules, DefaultModulesProvider from ghostos.framework.storage import FileStorageProvider @@ -80,7 +80,7 @@ def _on_initialized(self): def make_ghost( self, *, upstream: Stream, - process: GhostProcess, + process: SessionProcess, task: Optional[Task] = None, task_id: Optional[str] = None, ) -> Ghost: diff --git a/ghostos/framework/ghostos/demo_os.py b/ghostos/framework/ghostos/demo_os.py index 03252857..5d114bc7 100644 --- a/ghostos/framework/ghostos/demo_os.py +++ b/ghostos/framework/ghostos/demo_os.py @@ -2,7 +2,7 @@ from ghostos.core.ghosts import Ghost, GhostConf, Workspace, Shell from ghostos.core.messages import Stream -from ghostos.core.session import GhostProcess, Task +from ghostos.core.session import SessionProcess, Task from ghostos.contracts.logger import LoggerItf from ghostos.contracts.configs import Configs, YamlConfig @@ -28,7 +28,7 @@ def _on_initialized(self): def make_ghost( self, *, upstream: Stream, - process: GhostProcess, + process: SessionProcess, task: Optional[Task] = None, task_id: Optional[str] = None, ) -> Ghost: @@ -43,7 +43,7 @@ def _make_ghost_instance( self, conf: GhostConf, upstream: Stream, - process: GhostProcess, + process: SessionProcess, task: Optional[Task] = None, task_id: Optional[str] = None, ) -> Ghost: diff --git a/ghostos/framework/ghosts/basic.py b/ghostos/framework/ghosts/basic.py index 930d92b8..12a2ae54 100644 --- a/ghostos/framework/ghosts/basic.py +++ b/ghostos/framework/ghosts/basic.py @@ -17,7 +17,7 @@ from ghostos.core.session import ( Session, Event, DefaultEventType, EventBus, TaskRepo, GhostProcessRepo, MsgThreadRepo, Messenger, - GhostProcess, Task, MsgThread, + SessionProcess, Task, MsgThread, ) from ghostos.framework.operators import OnEventOperator from ghostos.framework.multitasks import MultiTaskBasicImpl @@ -94,7 +94,7 @@ def __init__( workspace: Workspace, entity_factory: EntityFactory, upstream: Stream, - process: GhostProcess, + process: SessionProcess, max_operator_runs: int, task: Optional[Task] = None, task_id: Optional[str] = None, @@ -196,7 +196,7 @@ def make_session( self, logger: LoggerItf, upstream: Stream, - process: GhostProcess, + process: SessionProcess, root_thought: Thought, task: Optional[Task] = None, task_id: Optional[str] = None, diff --git a/ghostos/framework/ghosts/demo.py b/ghostos/framework/ghosts/demo.py index 55c29360..20e734dc 100644 --- a/ghostos/framework/ghosts/demo.py +++ b/ghostos/framework/ghosts/demo.py @@ -1,7 +1,7 @@ from typing import Optional, List from ghostos.abc import Identifier from ghostos.core.ghosts import GhostConf, Shell, Workspace -from ghostos.core.session import GhostProcess, Task +from ghostos.core.session import SessionProcess, Task from ghostos.contracts.modules import Modules from ghostos.core.messages import Stream from ghostos.framework.ghosts.basic import BasicGhost, InputsPipe @@ -59,7 +59,7 @@ def __init__( container: Container, entity_factory: EntityFactory, workspace: Workspace, - process: GhostProcess, + process: SessionProcess, upstream: Optional[Stream] = None, shell: Optional[Shell] = None, task: Optional[Task] = None, diff --git a/ghostos/framework/processes/storage_processes.py b/ghostos/framework/processes/storage_processes.py index 540108e9..77a953d8 100644 --- a/ghostos/framework/processes/storage_processes.py +++ b/ghostos/framework/processes/storage_processes.py @@ -1,6 +1,6 @@ from typing import Optional, Dict, Type import yaml -from ghostos.core.session import GhostProcess +from ghostos.core.session import SessionProcess from ghostos.core.session.processes import GhostProcessRepo from ghostos.contracts.storage import Storage from ghostos.contracts.logger import LoggerItf @@ -31,12 +31,12 @@ def _get_session_process_map(self) -> Dict[str, str]: def _get_process_filename(process_id: str) -> str: return f"{process_id}.process.yml" - def get_process(self, process_id: str) -> Optional[GhostProcess]: + def get_process(self, process_id: str) -> Optional[SessionProcess]: filename = self._get_process_filename(process_id) if self._storage.exists(filename): content = self._storage.get(filename) data = yaml.safe_load(content) - process = GhostProcess(**data) + process = SessionProcess(**data) return process return None @@ -45,14 +45,14 @@ def _save_session_process_map(self, session_map: Dict[str, str]) -> None: filename = self.session_map_name self._storage.put(filename, content.encode("utf-8")) - def get_session_process(self, session_id: str) -> Optional[GhostProcess]: + def get_session_process(self, session_id: str) -> Optional[SessionProcess]: m = self._get_session_process_map() process_id = m.get(session_id, None) if process_id is None: return None return self.get_process(process_id) - def save_process(self, process: GhostProcess) -> None: + def save_process(self, process: SessionProcess) -> None: session_id = process.session_id process_id = process.process_id with self._lock: diff --git a/ghostos/framework/session/basic.py b/ghostos/framework/session/basic.py index b535f67b..e7b58631 100644 --- a/ghostos/framework/session/basic.py +++ b/ghostos/framework/session/basic.py @@ -5,7 +5,7 @@ ) from ghostos.core.session import ( Session, - GhostProcess, GhostProcessRepo, + SessionProcess, GhostProcessRepo, MsgThread, MsgThreadRepo, Task, TaskRepo, TaskPayload, TaskState, Messenger, @@ -19,17 +19,21 @@ from ghostos.contracts.pool import Pool -class Future: - def __init__(self, future_id: str, bus: EventBus, event: Event): +class FutureCall: + def __init__(self, future_id: str, bus: EventBus, event: Event, notify: bool = True): self.bus = bus self.event = event self.future_id = future_id + self.notify = notify def run(self, callback: Callable[[], Iterable[MessageKind]]) -> None: - messages = list(callback()) + try: + messages = list(callback()) + except Exception as e: + messages = [Role.new_assistant_system("", memory=str(e))] if len(messages) > 0: self.event.messages = messages - self.bus.send_event(self.event, notify=True) + self.bus.send_event(self.event, notify=self.notify) del self.bus del self.event @@ -48,7 +52,7 @@ def __init__( threads: MsgThreadRepo, logger: LoggerItf, # 当前任务信息. - process: GhostProcess, + process: SessionProcess, task: Task, thread: MsgThread, ): @@ -63,7 +67,7 @@ def __init__( self._eventbus: EventBus = eventbus # 需要管理的状态. self._task: Task = task - self._process: GhostProcess = process + self._process: SessionProcess = process self._creating: List[Task] = [] self._thread: MsgThread = thread self._firing_events: List[Event] = [] @@ -86,7 +90,7 @@ def refresh_lock(self) -> bool: return True return False - def process(self) -> "GhostProcess": + def process(self) -> "SessionProcess": return self._process def task(self) -> "Task": @@ -182,7 +186,8 @@ def future(self, name: str, call: Callable[[], Iterable[MessageKind]], reason: s messages=[], ) # 让异步任务全局执行. - future = Future(future_id, self._eventbus, event) + notify = self._process.main_task_id != self._task.task_id + future = FutureCall(future_id, self._eventbus, event, notify=notify) self._pool.submit(future.run) def _do_quit(self) -> None: @@ -271,7 +276,7 @@ def threads(self) -> MsgThreadRepo: def eventbus(self) -> EventBus: return self._eventbus - def update_process(self, process: "GhostProcess") -> None: + def update_process(self, process: "SessionProcess") -> None: self._process = process def quit(self) -> None: From 4bd74754e2df1902c4d6aec926ec997dfba7e610 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 13 Oct 2024 16:21:04 +0800 Subject: [PATCH 037/148] dev: AIFunc refaction raw test passed. aifunc streamlit app is next target --- examples/aifunc_raw_test.py | 69 ++++++++++++++++++++++++++++++ ghostos/core/aifunc/driver.py | 41 ++++++++++++------ ghostos/core/aifunc/executor.py | 6 +-- ghostos/core/aifunc/func.py | 4 ++ ghostos/core/aifunc/interfaces.py | 69 +++++++++++++++++++++++++----- ghostos/core/aifunc/repository.py | 5 ++- ghostos/entity.py | 12 ++++++ ghostos/framework/streams/array.py | 4 +- 8 files changed, 180 insertions(+), 30 deletions(-) create mode 100644 examples/aifunc_raw_test.py diff --git a/examples/aifunc_raw_test.py b/examples/aifunc_raw_test.py new file mode 100644 index 00000000..1708f613 --- /dev/null +++ b/examples/aifunc_raw_test.py @@ -0,0 +1,69 @@ +import sys +from os.path import dirname +from ghostos.core.aifunc import AIFuncExecutor + +# I hate python imports +ghostos_project_dir = dirname(dirname(__file__)) +sys.path.append(ghostos_project_dir) + +""" +Raw test of AIFuncExecutor and Frame +Print out almost every thing. +""" + +if __name__ == '__main__': + from ghostos.bootstrap import application_container + from ghostos.demo.aifuncs.agentic import AgentFn + from ghostos.framework.streams import new_connection + from rich.console import Console + from rich.markdown import Markdown + from rich.panel import Panel + import json + + console = Console() + from threading import Thread + + executor = application_container.force_fetch(AIFuncExecutor) + fn = AgentFn( + request="help me to find news about OpenAI O1 model", + ) + stream, receiver = new_connection(-1, accept_chunks=False) + frame, caller = executor.new_exec_frame(fn, stream) + t = Thread(target=caller) + t.start() + + with receiver as items: + for item in items: + tail = item.done() + console.print(Panel( + Markdown( + f""" +{tail.get_content()} + +```json +{json.dumps(tail.payloads, indent=2, ensure_ascii=False)} +``` +""" + ), + title=tail.name, + )) + result = frame.get_result() + console.print(Panel( + Markdown( + f""" +```json +{result.model_dump_json(indent=2)} +``` +""" + ) + )) + console.print(Panel( + Markdown( + f""" +```json +{frame.model_dump_json(indent=2)} +``` +""" + ) + )) + diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index b10bf10b..c1712b1b 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -1,7 +1,9 @@ import traceback from typing import Tuple, List, Optional, Any -from ghostos.core.aifunc.interfaces import AIFuncDriver, AIFuncExecutor, ExecStep, ExecFrame +from ghostos.core.aifunc.interfaces import ( + AIFuncDriver, AIFuncExecutor, ExecStep, ExecFrame, AIFuncRepository, +) from ghostos.core.aifunc.func import ( AIFunc, get_aifunc_instruction, get_aifunc_result_type, get_aifunc_pycontext, get_aifunc_llmapi, @@ -10,7 +12,7 @@ from ghostos.core.moss.abc import MossRuntime from ghostos.core.session import MsgThread, DefaultEventType, MsgThreadRepo, thread_to_chat from ghostos.core.messages import Role, Message, Stream -from ghostos.contracts.logger import LoggerItf +from ghostos.container import Container __all__ = [ 'DefaultAIFuncDriverImpl', @@ -88,9 +90,15 @@ def __init__(self, fn: AIFunc): def name(self) -> str: return self.aifunc.__class__.__name__ - def initialize(self) -> MsgThread: + def initialize(self, container: Container, frame: ExecFrame) -> MsgThread: pycontext = get_aifunc_pycontext(self.aifunc) messages = [] + threads = container.get(MsgThreadRepo) + if threads: + thread = threads.get_thread(frame.frame_id, create=False) + if thread: + return thread + # create one for frame instruction = get_aifunc_instruction(self.aifunc) if instruction: system_message = Role.SYSTEM.new( @@ -104,6 +112,7 @@ def initialize(self) -> MsgThread: messages=messages, ) thread = MsgThread.new( + thread_id=frame.frame_id, event=event, pycontext=pycontext, ) @@ -126,8 +135,13 @@ def generate_system_messages(self, runtime: MossRuntime) -> List[Message]: def on_chat(self, chat: Chat) -> None: pass - def on_message(self, message: Message) -> None: - pass + def on_message(self, message: Message, step: ExecStep, upstream: Optional[Stream]) -> None: + if upstream: + message = message.model_copy(deep=True) + message.name = self.aifunc.func_name() + payload = step.as_payload() + payload.set(message) + upstream.deliver(message) def on_system_messages(self, messages: List[Message]) -> None: pass @@ -139,7 +153,6 @@ def think( step: ExecStep, upstream: Optional[Stream] ) -> Tuple[MsgThread, Optional[Any], bool]: - logger = manager.container().get(LoggerItf) # get compiler by current exec step # the MossCompiler.container().get(AIFuncCtx) will bind this step. compiler = manager.compiler(step, upstream) @@ -172,7 +185,7 @@ def think( thread.append(ai_generation) step.messages.append(ai_generation) # on_message hook - self.on_message(ai_generation) + self.on_message(ai_generation, step, upstream) # parse the ai_generation. code = self.parse_moss_code_in_message(ai_generation) @@ -183,8 +196,6 @@ def think( error = Role.new_assistant_system( content="Error! You shall only write python code! DO NOT ACT LIKE IN A CHAT" ) - # log - logger.error(f"ai_generation: {repr(ai_generation)}") elif "main(" not in code: error = Role.new_assistant_system( @@ -194,12 +205,12 @@ def think( if error is not None: thread.append(error) step.error = error + self.on_message(error, step, upstream) return thread, None, False result = None # 运行 moss. try: - logger.info(f"executing ai_generation: {code}") executed = runtime.execute( code=code, target='main', @@ -218,6 +229,7 @@ def think( content=f"## Observation\n\nmoss executed main, std output is: \n{output}" ) messages = [output_message] + self.on_message(output_message, step, upstream) else: output_message = Role.new_assistant_system( content=f"## Observation\n\nhave not printed anything" @@ -237,7 +249,6 @@ def think( step.pycontext = pycontext # I think this method is thread-safe step.messages.extend(messages) - self.error_times = 0 except Exception as e: exe_info = "\n".join(traceback.format_exception(e)[-5:]) @@ -253,7 +264,7 @@ def think( ), ) step.error = output_message - + self.on_message(output_message, step, upstream) self.error_times += 1 if self.error_times >= 3: raise RuntimeError(f"AIFunc `{self.name()}` failed {self.error_times} times, can not fix itself: \n{e}") @@ -275,9 +286,11 @@ def parse_moss_code_in_message(self, message: Message) -> str: return content[code_start_index + len(CODE_MARK_LEFT): code_end_index].strip() - def on_save(self, manager: AIFuncExecutor, thread: MsgThread) -> None: + def on_save(self, container: Container, frame: ExecFrame, step: ExecStep, thread: MsgThread) -> None: # 如果 threads 抽象存在, 就保存一下. 还应该做一些日志的工作. - container = manager.container() threads = container.get(MsgThreadRepo) if threads is not None: threads.save_thread(thread) + repo = container.get(AIFuncRepository) + if repo is not None: + repo.save_exec_frame(frame) diff --git a/ghostos/core/aifunc/executor.py b/ghostos/core/aifunc/executor.py index 105750a6..d389b152 100644 --- a/ghostos/core/aifunc/executor.py +++ b/ghostos/core/aifunc/executor.py @@ -95,7 +95,7 @@ def execute( if frame is None: frame = ExecFrame.from_func(fn) driver = self.get_driver(fn) - thread = driver.initialize() + thread = driver.initialize(self.container(), frame) step = 0 finished = False result = None @@ -106,6 +106,7 @@ def execute( if self._max_step != 0 and step > self._max_step: raise RuntimeError(f"exceeded max step {self._max_step}") thread, result, finished = driver.think(self, thread, exec_step, upstream=upstream) + driver.on_save(self.container(), frame, exec_step, thread) if finished: break @@ -113,9 +114,8 @@ def execute( result_type = get_aifunc_result_type(type(fn)) raise RuntimeError(f"result is invalid AIFuncResult {type(result)}, expecting {result_type}") + frame.set_result(result) # if frame is the root, send final message as protocol - if upstream and frame.depth == 0: - upstream.send(DefaultMessageTypes.final()) return result def get_driver( diff --git a/ghostos/core/aifunc/func.py b/ghostos/core/aifunc/func.py index c885e263..87f7191b 100644 --- a/ghostos/core/aifunc/func.py +++ b/ghostos/core/aifunc/func.py @@ -42,6 +42,10 @@ def __class_prompt__(cls) -> str: result_prompt = f"result type of {cls.__name__} (which maybe not imported yet) is :\n{result_prompt}" return source + "\n\n" + add_comment_mark(result_prompt) + @classmethod + def func_name(cls) -> str: + return generate_import_path(cls) + class AIFuncResult(PromptAbleClass, BaseModel, ABC): """ diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index 4d8da838..75eb7e2c 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -5,11 +5,11 @@ from ghostos.core.moss import MossCompiler, PyContext from ghostos.core.llms import LLMApi, Chat from ghostos.core.session import MsgThread -from ghostos.core.messages import Message, Stream +from ghostos.core.messages import Message, Stream, Payload from ghostos.abc import Identifier from ghostos.helpers import generate_import_path, uuid from ghostos.container import Container -from ghostos.entity import EntityMeta, model_to_entity_meta +from ghostos.entity import EntityMeta, model_to_entity_meta, model_from_entity_meta from pydantic import BaseModel, Field __all__ = [ @@ -72,11 +72,19 @@ def values(self) -> Dict[str, Any]: pass +class ExecStepPayload(Payload): + key = "AIFuncExecStep" + func: str = Field(description="AIFunc name") + frame_id: str = Field(description="execution id") + step_id: str = Field(description="step id") + + class ExecStep(BaseModel): """ AIFunc execute in multi-turn thinking. Each turn is a step. """ frame_id: str = Field(description="step id") + func: str = Field(description="AIFunc name") depth: int = Field(description="depth of the ExecFrame") step_id: str = Field(default_factory=uuid, description="step id") chat: Optional[Chat] = Field(default=None, description="llm chat") @@ -84,7 +92,7 @@ class ExecStep(BaseModel): code: str = Field(default="", description="the generated code of the AIFunc") std_output: str = Field(default="", description="the std output of the AIFunc step") pycontext: Optional[PyContext] = Field(default=None, description="pycontext of the step") - error: Optional[Message] = Field(description="the error message") + error: Optional[Message] = Field(default=None, description="the error message") frames: List = Field(default_factory=list, description="list of ExecFrame") def new_frame(self, fn: AIFunc) -> "ExecFrame": @@ -97,6 +105,16 @@ def new_frame(self, fn: AIFunc) -> "ExecFrame": self.frames.append(frame) return frame + def as_payload(self) -> ExecStepPayload: + return ExecStepPayload( + func=self.func, + frame_id=self.frame_id, + step_id=self.step_id, + ) + + def func_name(self) -> str: + return self.func + class ExecFrame(BaseModel): """ @@ -104,21 +122,39 @@ class ExecFrame(BaseModel): """ frame_id: str = Field(default_factory=uuid, description="AIFunc execution id.") parent_step: Optional[str] = Field(default=None, description="parent execution step id") - request: EntityMeta = Field(description="AIFunc request, model to entity") - response: Optional[EntityMeta] = Field(None, description="AIFunc response, model to entity") + args: EntityMeta = Field(description="AIFunc request, model to entity") + result: Optional[EntityMeta] = Field(None, description="AIFunc response, model to entity") depth: int = Field(default=0, description="the depth of the stack") steps: List[ExecStep] = Field(default_factory=list, description="the execution steps") @classmethod def from_func(cls, fn: AIFunc, depth: int = 0, parent_step_id: Optional[str] = None) -> "ExecFrame": return cls( - request=model_to_entity_meta(fn), + args=model_to_entity_meta(fn), parent_step=parent_step_id, depth=depth, ) + def func_name(self) -> str: + return self.args['type'] + + def get_args(self) -> AIFunc: + return model_from_entity_meta(self.args, AIFunc) + + def set_result(self, result: AIFuncResult) -> None: + self.result = model_to_entity_meta(result) + + def get_result(self) -> Optional[AIFuncResult]: + if self.result is None: + return None + return model_from_entity_meta(self.result, AIFuncResult) + def new_step(self) -> ExecStep: - step = ExecStep(frame_id=self.frame_id, depth=self.depth) + step = ExecStep( + frame_id=self.frame_id, + func=self.args['type'], + depth=self.depth, + ) self.steps.append(step) return step @@ -189,14 +225,15 @@ def execute( """ pass - def new_exec_frame(self, fn: AIFunc, upstream: Optional[Stream]) -> Tuple[ExecFrame, Callable[[], AIFuncResult]]: + def new_exec_frame(self, fn: AIFunc, upstream: Stream) -> Tuple[ExecFrame, Callable[[], AIFuncResult]]: """ syntax sugar """ frame = ExecFrame.from_func(fn) def execution() -> AIFuncResult: - return self.execute(fn, frame, upstream) + with upstream: + return self.execute(fn, frame, upstream) return frame, execution @@ -273,12 +310,17 @@ def list(self, offset: int = 0, limit: int = -1) -> Iterable[Identifier]: """ pass + @abstractmethod def validate(self) -> None: """ validate the registered AiFunc, remove invalid ones """ pass + @abstractmethod + def save_exec_frame(self, frame: ExecFrame) -> None: + pass + class AIFuncDriver(ABC): """ @@ -289,7 +331,7 @@ def __init__(self, fn: AIFunc): self.aifunc = fn @abstractmethod - def initialize(self) -> MsgThread: + def initialize(self, container: Container, frame: ExecFrame) -> MsgThread: """ initialize the AIFunc thread by quest configuration. """ @@ -314,3 +356,10 @@ def think( :return: (updated thread, __result__, is finish) """ pass + + @abstractmethod + def on_save(self, container: Container, frame: ExecFrame, step: ExecStep, thread: MsgThread) -> None: + """ + save the status on each step + """ + pass diff --git a/ghostos/core/aifunc/repository.py b/ghostos/core/aifunc/repository.py index d9a5ebcd..0a62f9e1 100644 --- a/ghostos/core/aifunc/repository.py +++ b/ghostos/core/aifunc/repository.py @@ -3,7 +3,7 @@ from types import ModuleType from ghostos.abc import Identifier, identify_class -from ghostos.core.aifunc import AIFunc +from ghostos.core.aifunc import AIFunc, ExecFrame from ghostos.core.aifunc.interfaces import AIFuncRepository from ghostos.contracts.configs import YamlConfig, Configs from ghostos.contracts.modules import Modules @@ -104,6 +104,9 @@ def validate(self) -> None: self.conf.identifiers = identifiers self.configs.save(self.conf) + def save_exec_frame(self, frame: ExecFrame) -> None: + return None + class AIFuncRepoByConfigsProvider(Provider[AIFuncRepository]): diff --git a/ghostos/entity.py b/ghostos/entity.py index 87730910..2e3b4635 100644 --- a/ghostos/entity.py +++ b/ghostos/entity.py @@ -12,6 +12,7 @@ 'ModelEntity', 'EntityFactoryImpl', 'model_to_entity_meta', + 'model_from_entity_meta', ] @@ -38,6 +39,17 @@ def model_to_entity_meta(model: BaseModel) -> EntityMeta: ) +MODEL = TypeVar('MODEL', bound=BaseModel) + + +def model_from_entity_meta(meta: EntityMeta, wrapper: Type[MODEL] = BaseModel) -> MODEL: + type_ = meta['type'] + imported = import_from_path(type_) + if not issubclass(imported, wrapper): + raise TypeError(f"the type of the meta `{type_}` is not a subclass of `{wrapper}`") + return imported(**meta['data']) + + class Entity(ABC): """ meta is a strong type-hint class that can generate meta-data to transport diff --git a/ghostos/framework/streams/array.py b/ghostos/framework/streams/array.py index b790b959..7f449782 100644 --- a/ghostos/framework/streams/array.py +++ b/ghostos/framework/streams/array.py @@ -180,12 +180,12 @@ def __init__(self, head: Message, idle: Callable) -> None: self._stopped = False self._tail: Optional[Message] = None if head.is_complete() or DefaultMessageTypes.is_protocol_message(head): - self._tail = head + self._tail = head.as_tail() self._destroyed = False def add_item(self, item: Message) -> None: if item.is_complete() or DefaultMessageTypes.is_protocol_message(item): - self._tail = item + self._tail = item.as_tail() else: self._items.append(item.model_dump(exclude_defaults=True)) From 1f4e7453bc200fc0f1838f425a11943e5eacc3c0 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 13 Oct 2024 23:14:35 +0800 Subject: [PATCH 038/148] fix: fix AIFuncExecutor destroy parent container. --- ghostos/container.py | 19 +++++++++++++++++++ ghostos/core/aifunc/executor.py | 3 +-- ghostos/core/aifunc/repository.py | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/ghostos/container.py b/ghostos/container.py index 238d7770..fa187252 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -147,11 +147,13 @@ def __init__(self, parent: Optional[Container] = None): self._bootstrapper: List["Bootstrapper"] = [] self._bootstrapped: bool = False self._aliases: Dict[Any, Any] = {} + self._destroyed: bool = False def bootstrap(self) -> None: """ 执行 bootstrap, 只执行一次. 可以操作依赖关系. 比如实例化后反向注册. """ + self._check_destroyed() if self._bootstrapped: return # 必须在这里初始化, 否则会循环调用. @@ -168,6 +170,7 @@ def set(self, abstract: Any, instance: INSTANCE) -> None: """ 设置一个实例, 不会污染父容器. """ + self._check_destroyed() self._set_instance(abstract, instance) def _bind_contract(self, abstract: ABSTRACT) -> None: @@ -180,6 +183,7 @@ def bound(self, contract: Type) -> bool: """ return whether contract is bound. """ + self._check_destroyed() return contract in self._bound or (self.parent is not None and self.parent.bound(contract)) def get(self, abstract: Type[INSTANCE]) -> Optional[INSTANCE]: @@ -190,6 +194,7 @@ def get(self, abstract: Type[INSTANCE]) -> Optional[INSTANCE]: - params 感觉不需要. """ + self._check_destroyed() # 进行初始化. if not self._bootstrapped: warnings.warn("container is not bootstrapped before using") @@ -223,6 +228,7 @@ def get_bound(self, abstract: ABSTRACT) -> Union[INSTANCE, Provider]: get bound of an abstract :return: instance or provider """ + self._check_destroyed() if abstract in self._instances: return self._instances[abstract] elif abstract in self._providers: @@ -240,6 +246,7 @@ def register_maker( maker: Callable[[], INSTANCE], singleton: bool = False, ): + self._check_destroyed() lineinfo = get_caller_info(2) def _maker(c): @@ -252,6 +259,7 @@ def register(self, *providers: Provider) -> None: """ register factory of the contract by provider """ + self._check_destroyed() for provider in providers: self._register(provider) @@ -286,6 +294,7 @@ def add_bootstrapper(self, bootstrapper: Bootstrapper) -> None: :param bootstrapper: 可以定义一些方法, 比如往容器里的某个类里注册一些工具. :return: """ + self._check_destroyed() if not self._bootstrapped: self._bootstrapper.append(bootstrapper) @@ -294,6 +303,7 @@ def fetch(self, abstract: Type[INSTANCE], strict: bool = False) -> Optional[INST get contract with type check :exception: TypeError if instance do not implement abstract """ + self._check_destroyed() instance = self.get(abstract) if instance is not None: if strict and not isinstance(instance, abstract): @@ -307,6 +317,7 @@ def force_fetch(self, contract: Type[INSTANCE], strict: bool = False) -> INSTANC :exception: NotImplementedError if contract is not registered. :exception: TypeError if contract do not implement abstract """ + self._check_destroyed() ins = self.fetch(contract, strict) if ins is None: raise NotImplementedError(f"contract {contract} not register in container") @@ -320,6 +331,7 @@ def _set_instance(self, abstract: Any, instance: Any) -> None: self._instances[abstract] = instance def contracts(self, recursively: bool = True) -> Iterable[ABSTRACT]: + self._check_destroyed() done = set() for contract in self._bound: done.add(contract) @@ -330,10 +342,17 @@ def contracts(self, recursively: bool = True) -> Iterable[ABSTRACT]: done.add(contract) yield contract + def _check_destroyed(self) -> None: + if self._destroyed: + raise RuntimeError("container is called after destroyed") + def destroy(self) -> None: """ Manually delete the container to prevent memory leaks. """ + if self._destroyed: + return + self._destroyed = True del self._instances del self.parent del self._providers diff --git a/ghostos/core/aifunc/executor.py b/ghostos/core/aifunc/executor.py index d389b152..81b42a3b 100644 --- a/ghostos/core/aifunc/executor.py +++ b/ghostos/core/aifunc/executor.py @@ -173,10 +173,9 @@ def values(self) -> Dict[str, Any]: def destroy(self) -> None: if self._destroyed: # destroy once. - # not every submanager is created at self.execute + # not every submanager is created at self.execute, # so they could be destroyed outside already return - self._container.destroy() del self._container del self._values del self._exec_step diff --git a/ghostos/core/aifunc/repository.py b/ghostos/core/aifunc/repository.py index 0a62f9e1..53810b1c 100644 --- a/ghostos/core/aifunc/repository.py +++ b/ghostos/core/aifunc/repository.py @@ -14,7 +14,7 @@ class AIFuncsConf(YamlConfig): - relative_path = "registered_aifunc.yaml" + relative_path = "registered_aifunc.yml" identifiers: Dict[str, Identifier] = Field( default_factory=dict, From c18ab99d3bb9af39d0de0099ca8bbeaee6716807 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 13 Oct 2024 23:15:05 +0800 Subject: [PATCH 039/148] test: test more features about streamlit --- .../prototypes/streamlitapp/tests/duplex.py | 77 +++++++++++++++++++ .../streamlitapp/tests/status_elements.py | 8 ++ .../streamlitapp/tests/text_input.py | 19 +++++ .../prototypes/streamlitapp/utils/route.py | 6 ++ 4 files changed, 110 insertions(+) create mode 100644 ghostos/prototypes/streamlitapp/tests/duplex.py create mode 100644 ghostos/prototypes/streamlitapp/tests/status_elements.py create mode 100644 ghostos/prototypes/streamlitapp/tests/text_input.py diff --git a/ghostos/prototypes/streamlitapp/tests/duplex.py b/ghostos/prototypes/streamlitapp/tests/duplex.py new file mode 100644 index 00000000..5554a117 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/duplex.py @@ -0,0 +1,77 @@ +import streamlit as st +import time +from typing import List, Iterator +from ghostos.prototypes.streamlitapp.utils.route import Route, Link + + +class ChatRoute(Route): + link = Link( + name="chat", + import_path="path", + ) + messages: List[str] = [] + input: str = "" + disabled: bool = False + buffer: str = "" + + def set_input(self): + i = st.session_state["t_inputs"] + self.input = i + + def iter_messages(self) -> Iterator[str]: + for line in self.messages: + yield line + if self.buffer: + self.messages.append(self.buffer) + buffer = self.buffer + self.buffer = "" + yield buffer + + def iter_output(self, line: str) -> Iterator[str]: + self.buffer = "" + for c in line: + self.buffer += c + yield c + if self.input: + break + time.sleep(0.2) + + +chat = ChatRoute().get_or_bind(st.session_state) + + +@st.fragment +def run_messages(): + count = 0 + for msg in chat.iter_messages(): + role = "ai" + if msg.startswith("user:"): + role = "user" + with st.chat_message(role): + st.write(msg) + with st.expander("debug mode", expanded=False): + st.write("hello") + + while True: + if i := chat.input: + content = f"user:{i}" + chat.input = "" + with st.chat_message("user"): + st.write(content) + chat.messages.append(content) + count += 1 + if count % 30 == 0: + chat.disabled = True + content = f"another round {count}" + with st.chat_message("ai"): + items = chat.iter_output(content) + st.write_stream(items) + chat.messages.append(content) + chat.disabled = False + time.sleep(0.1) + + +if i := st.chat_input("input"): + chat.input = i + +run_messages() diff --git a/ghostos/prototypes/streamlitapp/tests/status_elements.py b/ghostos/prototypes/streamlitapp/tests/status_elements.py new file mode 100644 index 00000000..1f41faf3 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/status_elements.py @@ -0,0 +1,8 @@ +import streamlit as st + +st.success('This is a success message!', icon="✅") +st.info('This is a purely informational message', icon="ℹ️") +st.warning('This is a warning', icon="⚠️") +st.error('This is an error', icon="🚨") +e = RuntimeError("This is an exception of type RuntimeError") +st.exception(e) diff --git a/ghostos/prototypes/streamlitapp/tests/text_input.py b/ghostos/prototypes/streamlitapp/tests/text_input.py new file mode 100644 index 00000000..37d93ac1 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/text_input.py @@ -0,0 +1,19 @@ +import streamlit as st + + +@st.fragment +def show_text_input(): + text_input = st.session_state['text_input'] + with st.empty(): + st.write(text_input) + + +title = st.text_input( + "Movie title", + "Life of Brian", + key="text_input", +) + +show_text_input() + +st.write("The current movie title is", title) diff --git a/ghostos/prototypes/streamlitapp/utils/route.py b/ghostos/prototypes/streamlitapp/utils/route.py index 26ea3dd5..8e1e8689 100644 --- a/ghostos/prototypes/streamlitapp/utils/route.py +++ b/ghostos/prototypes/streamlitapp/utils/route.py @@ -145,6 +145,12 @@ def get(cls, session_state: MutableMapping) -> Optional[Self]: return session_state[key] return None + def get_or_bind(self, session_state: MutableMapping) -> Self: + key = self.session_state_key() + if key not in session_state: + session_state[key] = self + return session_state[key] + @classmethod def default(cls) -> Self: return cls() From b7d08cd470090ae0b81c99d95bf889544c693d6e Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 14 Oct 2024 00:23:23 +0800 Subject: [PATCH 040/148] fix: aifunc debug and fix some. far not enough --- examples/aifunc_raw_test.py | 57 ++++++++++++++++--------- ghostos/core/aifunc/driver.py | 23 +++++++--- ghostos/core/aifunc/interfaces.py | 14 +++++- ghostos/core/llms/chat.py | 1 + ghostos/core/messages/message.py | 2 +- ghostos/core/moss/abc.py | 9 ++++ ghostos/core/session/threads.py | 4 +- ghostos/demo/aifuncs/agentic.py | 4 +- ghostos/framework/llms/openai_driver.py | 1 + 9 files changed, 84 insertions(+), 31 deletions(-) diff --git a/examples/aifunc_raw_test.py b/examples/aifunc_raw_test.py index 1708f613..46eb0413 100644 --- a/examples/aifunc_raw_test.py +++ b/examples/aifunc_raw_test.py @@ -23,6 +23,8 @@ console = Console() from threading import Thread + debug = False + executor = application_container.force_fetch(AIFuncExecutor) fn = AgentFn( request="help me to find news about OpenAI O1 model", @@ -35,35 +37,52 @@ with receiver as items: for item in items: tail = item.done() - console.print(Panel( - Markdown( - f""" -{tail.get_content()} + payloads = json.dumps(tail.payloads, indent=2, ensure_ascii=False) + payloads_info = "" + if debug: + payloads_info = f""" + ```json -{json.dumps(tail.payloads, indent=2, ensure_ascii=False)} +{payloads} ``` """ + console.print(Panel( + Markdown( + tail.get_content() + payloads_info ), title=tail.name, )) + if debug: + console.print(Panel( + Markdown( + f""" + ```json + {frame.model_dump_json(indent=2)} + ``` + """ + ), + title="frame details", + )) result = frame.get_result() - console.print(Panel( - Markdown( - f""" + if result is not None: + console.print(Panel( + Markdown( + f""" ```json {result.model_dump_json(indent=2)} ``` """ - ) - )) - console.print(Panel( - Markdown( - f""" -```json -{frame.model_dump_json(indent=2)} -``` -""" - ) - )) + ), + title="final result", + )) + elif err := frame.last_step().error: + console.print(Panel( + Markdown( + err.content + ), + title="result error", + )) + + diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index c1712b1b..b33264f8 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -3,6 +3,7 @@ from ghostos.core.aifunc.interfaces import ( AIFuncDriver, AIFuncExecutor, ExecStep, ExecFrame, AIFuncRepository, + TooManyFailureError, ) from ghostos.core.aifunc.func import ( AIFunc, @@ -168,7 +169,7 @@ def think( # build chat self.on_system_messages(systems) chat = thread_to_chat(thread.id, systems, thread) - step.chat = chat + step.chat = chat.model_copy(deep=True) # on_chat hook self.on_chat(chat) @@ -183,7 +184,7 @@ def think( # append ai_generation thread.append(ai_generation) - step.messages.append(ai_generation) + step.generate = ai_generation # on_message hook self.on_message(ai_generation, step, upstream) @@ -194,16 +195,23 @@ def think( # handle code: if not code: error = Role.new_assistant_system( - content="Error! You shall only write python code! DO NOT ACT LIKE IN A CHAT" + content="Error! You shall only write python code! DO NOT ACT LIKE IN A CHAT. " + "Generate code in ``." ) elif "main(" not in code: error = Role.new_assistant_system( - content="Error! No main function found in your generation!" + content="Error! No main function found in your generation! use `` to wrap your code." ) if error is not None: - thread.append(error) + thread.new_turn( + event=DefaultEventType.OBSERVE.new( + messages=[error], + task_id=thread.id, + from_task_id=thread.id, + ), + ) step.error = error self.on_message(error, step, upstream) return thread, None, False @@ -250,12 +258,13 @@ def think( # I think this method is thread-safe step.messages.extend(messages) self.error_times = 0 + except TooManyFailureError: + raise except Exception as e: exe_info = "\n".join(traceback.format_exception(e)[-5:]) output_message = Role.new_assistant_system( content=f"moss executed main, exception occurs: \n{exe_info}" ) - thread.new_turn( event=DefaultEventType.OBSERVE.new( messages=[output_message], @@ -267,7 +276,7 @@ def think( self.on_message(output_message, step, upstream) self.error_times += 1 if self.error_times >= 3: - raise RuntimeError(f"AIFunc `{self.name()}` failed {self.error_times} times, can not fix itself: \n{e}") + raise TooManyFailureError(f"AIFunc `{self.name()}` failed {self.error_times} times, can not fix itself: \n{e}") else: finish = False finally: diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index 75eb7e2c..ed0cfd5e 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -17,9 +17,14 @@ 'AIFuncExecutor', 'AIFuncCtx', 'AIFuncDriver', 'AIFuncRepository', 'ExecFrame', 'ExecStep', + 'TooManyFailureError', ] +class TooManyFailureError(RuntimeError): + pass + + @cls_source_code() class AIFuncCtx(ABC): """ @@ -33,6 +38,7 @@ def run(self, key: str, fn: AIFunc) -> AIFuncResult: :param key: the key that ctx keep the result in multi-turns thinking. :param fn: instance of AIFunc that define the task. :return: the certain result that match AIFuncResult and is not None + :exception: TooManyFailureError """ pass @@ -88,8 +94,9 @@ class ExecStep(BaseModel): depth: int = Field(description="depth of the ExecFrame") step_id: str = Field(default_factory=uuid, description="step id") chat: Optional[Chat] = Field(default=None, description="llm chat") - messages: List[Message] = Field(default_factory=list, description="list of messages") + generate: Optional[Message] = Field(default=None, description="AI generate message") code: str = Field(default="", description="the generated code of the AIFunc") + messages: List[Message] = Field(default_factory=list, description="list of messages") std_output: str = Field(default="", description="the std output of the AIFunc step") pycontext: Optional[PyContext] = Field(default=None, description="pycontext of the step") error: Optional[Message] = Field(default=None, description="the error message") @@ -158,6 +165,11 @@ def new_step(self) -> ExecStep: self.steps.append(step) return step + def last_step(self) -> Optional[ExecStep]: + if len(self.steps) == 0: + return None + return self.steps[-1] + class AIFuncExecutor(ABC): """ diff --git a/ghostos/core/llms/chat.py b/ghostos/core/llms/chat.py index e270de7e..9dc32e05 100644 --- a/ghostos/core/llms/chat.py +++ b/ghostos/core/llms/chat.py @@ -107,6 +107,7 @@ class Chat(BaseModel): 模拟对话的上下文. """ id: str = Field(default_factory=helpers.uuid, description="trace id") + streaming: bool = Field(default=False, description="streaming mode") system: List[Message] = Field(default_factory=list, description="system messages") history: List[Message] = Field(default_factory=list) diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index c8158877..52865e73 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -35,7 +35,7 @@ def new_assistant_system( content: str, memory: Optional[str] = None, ): - return cls.ASSISTANT.new(content, memory=memory, name="__system__") + return cls.USER.new(content, memory=memory, name="__system__") def new( self, diff --git a/ghostos/core/moss/abc.py b/ghostos/core/moss/abc.py index 8734a7a6..9bcde474 100644 --- a/ghostos/core/moss/abc.py +++ b/ghostos/core/moss/abc.py @@ -1,3 +1,4 @@ +import contextlib from typing import Dict, Any, Union, List, Optional, NamedTuple, Type, Callable from types import ModuleType from abc import ABC, abstractmethod @@ -9,6 +10,7 @@ ) from ghostos.core.messages import Message, Role from ghostos.core.moss.decorators import cls_source_code +from contextlib import contextmanager """ MOSS 是 Model-oriented Operating System Simulation 的简写. @@ -220,6 +222,13 @@ def compile( # 手动管理一下, 避免外部解决内存泄漏的心智成本. self.destroy() + @contextmanager + def compile_ctx(self, modulename: Optional[str]): + runtime = self.compile(modulename) + yield runtime + # destroy it in case of memory leak + runtime.destroy() + @abstractmethod def _compile(self, modulename: Optional[str] = None) -> ModuleType: """ diff --git a/ghostos/core/session/threads.py b/ghostos/core/session/threads.py index 8facc062..9d79d801 100644 --- a/ghostos/core/session/threads.py +++ b/ghostos/core/session/threads.py @@ -59,6 +59,8 @@ def append(self, *messages: Message, pycontext: Optional[PyContext] = None) -> N self.pycontext = pycontext def event_messages(self) -> Iterable[Message]: + if not self.event: + return [] event = self.event name = event.name for message in self.iter_event_message(event): @@ -67,7 +69,7 @@ def event_messages(self) -> Iterable[Message]: yield message @staticmethod - def iter_event_message( event: Event) -> Iterable[Message]: + def iter_event_message(event: Event) -> Iterable[Message]: if event is None: return [] diff --git a/ghostos/demo/aifuncs/agentic.py b/ghostos/demo/aifuncs/agentic.py index 35e7fd0a..acc4db3a 100644 --- a/ghostos/demo/aifuncs/agentic.py +++ b/ghostos/demo/aifuncs/agentic.py @@ -1,8 +1,8 @@ from typing import Optional from ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx from ghostos.core.moss import Moss as Parent -from ghostos.demo.aifuncs.weather import WeatherAIFunc -from ghostos.demo.aifuncs.news import NewsAIFunc +from ghostos.demo.aifuncs.weather import WeatherAIFunc, WeatherAIFuncResult +from ghostos.demo.aifuncs.news import NewsAIFunc, NewsAIFuncResult from pydantic import Field diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index d8a631b3..b28d27f1 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -150,6 +150,7 @@ def chat_completion(self, chat: Chat) -> Message: message: ChatCompletion = self._chat_completion(chat, stream=False) pack = self._parser.from_chat_completion(message.choices[0].message) # add completion usage + self._model.set(pack) if message.usage: usage = CompletionUsagePayload.from_usage(message.usage) usage.set(pack) From 40429d36408910d8131e1451286f8747811589c1 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 14 Oct 2024 22:33:59 +0800 Subject: [PATCH 041/148] dev: add abstract class designings --- ghostos/core/abcd/__init__.py | 0 ghostos/core/abcd/actor.py | 75 +++++++++++++ ghostos/core/abcd/messages_designing.py | 20 ++++ ghostos/core/abcd/transport.py | 139 ++++++++++++++++++++++++ 4 files changed, 234 insertions(+) create mode 100644 ghostos/core/abcd/__init__.py create mode 100644 ghostos/core/abcd/actor.py create mode 100644 ghostos/core/abcd/messages_designing.py create mode 100644 ghostos/core/abcd/transport.py diff --git a/ghostos/core/abcd/__init__.py b/ghostos/core/abcd/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/core/abcd/actor.py b/ghostos/core/abcd/actor.py new file mode 100644 index 00000000..0034c235 --- /dev/null +++ b/ghostos/core/abcd/actor.py @@ -0,0 +1,75 @@ +from abc import abstractmethod +from typing import Iterable, Optional +from typing_extensions import Protocol +from .transport import Message +from ghostos.abc import Identifier + +__all__ = ("Actor", "Address", "Topic", "Mail") + + +class Address(Protocol): + """ + instance of the actor + """ + + @abstractmethod + def identifier(self) -> Identifier: + pass + + +class Topic(Protocol): + """ + topic that transport messages + """ + + @abstractmethod + def identifier(self) -> Identifier: + pass + + @abstractmethod + def get_parent(self) -> Optional[str]: + pass + + +class Mail(Protocol): + + @abstractmethod + def issuer(self) -> Optional[Address]: + pass + + @abstractmethod + def receiver(self) -> Optional[Address]: + pass + + @abstractmethod + def topic(self) -> Topic: + pass + + @abstractmethod + def content(self) -> Iterable[Message]: + pass + + +class ActCtx(Protocol): + + @abstractmethod + def topics(self) -> Iterable[Topic]: + pass + + +class Actor(Protocol): + + @abstractmethod + def identifier(self) -> Identifier: + pass + + @abstractmethod + def on_recv( + self, + ctx: ActCtx, + recv: Mail, + ) -> Iterable[Message]: + """ + 回复一个邮件 + """ + pass diff --git a/ghostos/core/abcd/messages_designing.py b/ghostos/core/abcd/messages_designing.py new file mode 100644 index 00000000..6a262def --- /dev/null +++ b/ghostos/core/abcd/messages_designing.py @@ -0,0 +1,20 @@ +from abc import ABC +from .transport import Message, Delivery, Item, UpStream + +__all__ = ['Message', 'MessageDelivery', 'MessageStream', 'MessagePack'] + + +class MessageItem(Item, ABC): + pass + + +class MessagePack(Message, ABC): + pass + + +class MessageDelivery(Delivery, ABC): + pass + + +class MessageStream(UpStream, ABC): + pass diff --git a/ghostos/core/abcd/transport.py b/ghostos/core/abcd/transport.py new file mode 100644 index 00000000..e7449fd9 --- /dev/null +++ b/ghostos/core/abcd/transport.py @@ -0,0 +1,139 @@ +from abc import abstractmethod +from typing import Iterable, Callable, Tuple +from typing_extensions import Literal, Protocol, Self + +__all__ = [ + "Item", "Message", + "Delivery", + "UpStream", "Connection", + "Pipe", "Pipeline", "PipeAdapter", + "build_pipeline", +] + + +class Item(Protocol): + """ + 消息协议中的最小传输单元. + """ + + @abstractmethod + def seq(self) -> Literal["head", "chunk", "complete"]: + pass + + +class Message(Protocol): + """ + 消息协议中一个完整的包. 包含首包, 间包, 尾包. + """ + + @abstractmethod + def head(self) -> Item: + pass + + @abstractmethod + def chunks(self) -> Iterable[Item]: + pass + + @abstractmethod + def tail(self) -> Item: + pass + + +class Delivery(Protocol): + """ + 获取一组传输的 Package, 或许需要资源回收. + """ + + @abstractmethod + def __enter__(self) -> Iterable[Message]: + pass + + @abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + pass + + +class UpStream(Protocol): + """ + 下游向上游传输消息的方式. + 用于异步模型. + """ + + @abstractmethod + def deliver(self, item: Item) -> bool: + pass + + @abstractmethod + def stopped(self) -> bool: + pass + + @abstractmethod + def send(self, items: Iterable[Item]) -> bool: + pass + + @abstractmethod + def accept_chunks(self) -> bool: + pass + + @abstractmethod + def __enter__(self) -> Self: + pass + + @abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + """ + inform final or error to upstream + :param exc_type: + :param exc_val: + :param exc_tb: + :return: + """ + pass + + +Connection = Callable[[...], Tuple[UpStream, Delivery]] +""" +Connection is a function create both upstream and delivery. +""" + +Parser = Callable[[Iterable[Item]], Iterable[Message]] +""" +解析 Item 完成粘包. +""" + +Pipeline = Callable[[Iterable[Message]], Iterable[Message]] +""" +对流式传输的 Package 进行阻断和过滤. +""" + + +class Pipe(Protocol[Pipeline]): + """ + Pipeline 的一个中间节点. 可以任意拼组顺序. + """ + + def attach(self, pipeline: Pipeline) -> Pipeline: + def run(inputs: Iterable[Message]) -> Iterable[Message]: + next_inputs = self.receive(inputs) + outputs = pipeline(next_inputs) + return self.callback(outputs) + + return run + + @abstractmethod + def receive(self, inputs: Iterable[Message]) -> Iterable[Message]: + pass + + @abstractmethod + def callback(self, outputs: Iterable[Message]) -> Iterable[Message]: + pass + + +def build_pipeline(destination: Pipeline, *pipes: Pipe[Pipeline]) -> Pipeline: + pipeline = destination + for pipe in reversed(pipes): + pipeline = pipe.attach(pipeline) + return pipeline + + +PipeAdapter = Callable[[Pipe], Pipe] From 84ccc40c115f78f1cbb184cb922c8abcbdc1f73a Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 14 Oct 2024 22:35:07 +0800 Subject: [PATCH 042/148] feat: enrich identifier --- ghostos/abc.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/ghostos/abc.py b/ghostos/abc.py index 622b1b36..e6d1170d 100644 --- a/ghostos/abc.py +++ b/ghostos/abc.py @@ -1,6 +1,10 @@ from abc import ABC, abstractmethod +from typing import Any, Optional, Dict, Union from pydantic import BaseModel, Field from ghostos.helpers import generate_import_path +from typing_extensions import Protocol +from types import FunctionType +import inspect __all__ = [ 'Descriptive', @@ -20,6 +24,7 @@ class Identifier(BaseModel): id: str = Field(default="", description="Unique identifier") name: str = Field(default="", description="Name of the object") description: str = Field(default="", description="Description of the object") + kind: Optional[str] = Field(default=None, description="Kind of the object") class Identifiable(ABC): @@ -32,6 +37,12 @@ def identifier(self) -> Identifier: pass +class IdentifiableProtocol(Protocol): + id: Optional[str] + name: str + description: str + + class IdentifiableClass(ABC): @classmethod @@ -51,13 +62,61 @@ def identify_class(cls: type) -> Identifier: id_ = identify_class_id(cls) name = cls.__name__ desc = cls.__doc__ - return Identifier(id=id_, name=name, description=desc) + return Identifier( + id=id_, + name=name, + description=desc, + kind="class", + ) def identify_class_id(cls: type) -> str: return generate_import_path(cls) +def get_identifier(value: Any) -> Optional[Identifier]: + if isinstance(value, Identifier): + return value + if isinstance(value, Identifiable): + return value.identifier() + if isinstance(value, IdentifiableClass): + return value.class_identifier() + if inspect.isfunction(value): + return Identifier( + id=generate_import_path(value), + name=value.__name__, + description=value.__doc__, + kind="function", + ) + if inspect.ismethod(value): + return Identifiable( + id=generate_import_path(value.__class__) + ":" + value.__name__, + name=value.__name__, + description=value.__doc__, + kind="method", + ) + if isinstance(value, type): + return identify_class(value) + if isinstance(value, Dict) and "name" in value and "description" in value: + return Identifier( + id=value.get("id", None), + name=value["name"], + description=value["description"], + kind=str(type(value)), + ) + if isinstance(value, object) and hasattr(value, 'name') and hasattr(value, 'description'): + return Identifier( + id=getattr(value, 'id', None), + name=getattr(value, 'name'), + description=getattr(value, 'description'), + kind=str(type(value)), + ) + return None + + +IDAble = Union[Identifier, Identifiable, IdentifiableClass, type, FunctionType, IdentifiableProtocol] + + class PromptAble(ABC): """ 拥有 __prompt__ 方法的类. From 620f030e5fd434d6a51b542804f2230dbb2d830b Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 14 Oct 2024 22:40:38 +0800 Subject: [PATCH 043/148] refact: refact ghostos.abc to ghostos.common, in case of conflict to builtin abc --- ghostos/{abc.py => common.py} | 0 ghostos/core/abcd/actor.py | 2 +- ghostos/core/aifunc/func.py | 2 +- ghostos/core/aifunc/interfaces.py | 2 +- ghostos/core/aifunc/repository.py | 2 +- ghostos/core/ghosts/actions.py | 2 +- ghostos/core/ghosts/assistants.py | 2 +- ghostos/core/ghosts/ghost.py | 2 +- ghostos/core/ghosts/thoughts.py | 2 +- ghostos/core/ghosts/user.py | 2 +- ghostos/core/llms/chat.py | 2 +- ghostos/core/moss/prompts.py | 2 +- ghostos/core/moss/utils.py | 2 +- ghostos/core/session/tasks.py | 2 +- ghostos/framework/actions/moss_action.py | 2 +- ghostos/framework/ghosts/demo.py | 2 +- tests/test_abc.py | 2 +- 17 files changed, 16 insertions(+), 16 deletions(-) rename ghostos/{abc.py => common.py} (100%) diff --git a/ghostos/abc.py b/ghostos/common.py similarity index 100% rename from ghostos/abc.py rename to ghostos/common.py diff --git a/ghostos/core/abcd/actor.py b/ghostos/core/abcd/actor.py index 0034c235..d3ee5dec 100644 --- a/ghostos/core/abcd/actor.py +++ b/ghostos/core/abcd/actor.py @@ -2,7 +2,7 @@ from typing import Iterable, Optional from typing_extensions import Protocol from .transport import Message -from ghostos.abc import Identifier +from ghostos.common import Identifier __all__ = ("Actor", "Address", "Topic", "Mail") diff --git a/ghostos/core/aifunc/func.py b/ghostos/core/aifunc/func.py index 87f7191b..06057494 100644 --- a/ghostos/core/aifunc/func.py +++ b/ghostos/core/aifunc/func.py @@ -3,7 +3,7 @@ from abc import ABC from pydantic import BaseModel from ghostos.helpers import generate_import_path, import_from_path -from ghostos.abc import PromptAbleClass +from ghostos.common import PromptAbleClass from ghostos.core.llms import LLMs, LLMApi from ghostos.core.moss.utils import make_class_prompt, add_comment_mark from ghostos.core.moss.prompts import get_class_magic_prompt diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index ed0cfd5e..5b2f1605 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -6,7 +6,7 @@ from ghostos.core.llms import LLMApi, Chat from ghostos.core.session import MsgThread from ghostos.core.messages import Message, Stream, Payload -from ghostos.abc import Identifier +from ghostos.common import Identifier from ghostos.helpers import generate_import_path, uuid from ghostos.container import Container from ghostos.entity import EntityMeta, model_to_entity_meta, model_from_entity_meta diff --git a/ghostos/core/aifunc/repository.py b/ghostos/core/aifunc/repository.py index 53810b1c..79c58ca0 100644 --- a/ghostos/core/aifunc/repository.py +++ b/ghostos/core/aifunc/repository.py @@ -2,7 +2,7 @@ from typing import List, Type, Dict, Set, Iterable, Optional from types import ModuleType -from ghostos.abc import Identifier, identify_class +from ghostos.common import Identifier, identify_class from ghostos.core.aifunc import AIFunc, ExecFrame from ghostos.core.aifunc.interfaces import AIFuncRepository from ghostos.contracts.configs import YamlConfig, Configs diff --git a/ghostos/core/ghosts/actions.py b/ghostos/core/ghosts/actions.py index 7a3ed8b2..9afa3520 100644 --- a/ghostos/core/ghosts/actions.py +++ b/ghostos/core/ghosts/actions.py @@ -6,7 +6,7 @@ from ghostos.core.ghosts.operators import Operator from ghostos.core.messages.message import Caller from ghostos.core.session import Session -from ghostos.abc import Identifiable, Identifier +from ghostos.common import Identifiable, Identifier from pydantic import BaseModel __all__ = ['Action', 'ToolAction'] diff --git a/ghostos/core/ghosts/assistants.py b/ghostos/core/ghosts/assistants.py index fb6f9095..3dc50369 100644 --- a/ghostos/core/ghosts/assistants.py +++ b/ghostos/core/ghosts/assistants.py @@ -1,6 +1,6 @@ from typing import Optional, TypeVar, Generic, Type from abc import ABC, abstractmethod -from ghostos.abc import Identifiable, Identifier +from ghostos.common import Identifiable, Identifier from ghostos.core.ghosts import Ghost from ghostos.core.ghosts.thoughts import Thought, ModelThought from ghostos.helpers import generate_import_path, md5, import_from_path diff --git a/ghostos/core/ghosts/ghost.py b/ghostos/core/ghosts/ghost.py index 2a87fad3..cb535d8f 100644 --- a/ghostos/core/ghosts/ghost.py +++ b/ghostos/core/ghosts/ghost.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from ghostos.entity import ModelEntity, EntityMeta, EntityFactory from ghostos.container import Container -from ghostos.abc import Identifiable, Identifier +from ghostos.common import Identifiable, Identifier from ghostos.contracts.logger import LoggerItf from ghostos.contracts.modules import Modules from ghostos.contracts.configs import Configs diff --git a/ghostos/core/ghosts/thoughts.py b/ghostos/core/ghosts/thoughts.py index b1a63818..e2041ccc 100644 --- a/ghostos/core/ghosts/thoughts.py +++ b/ghostos/core/ghosts/thoughts.py @@ -5,7 +5,7 @@ from ghostos.core.session import Event, MsgThread, Session from ghostos.core.ghosts.ghost import Ghost from ghostos.core.ghosts.operators import Operator -from ghostos.abc import Identifiable, Identifier, PromptAbleClass +from ghostos.common import Identifiable, Identifier, PromptAbleClass from ghostos.helpers import uuid, generate_import_path from pydantic import Field diff --git a/ghostos/core/ghosts/user.py b/ghostos/core/ghosts/user.py index 17dd3321..e3637fa2 100644 --- a/ghostos/core/ghosts/user.py +++ b/ghostos/core/ghosts/user.py @@ -1,6 +1,6 @@ from typing import List from abc import ABC, abstractmethod -from ghostos.abc import Identifiable +from ghostos.common import Identifiable from ghostos.core.llms import ChatPreparer diff --git a/ghostos/core/llms/chat.py b/ghostos/core/llms/chat.py index 9dc32e05..68c37549 100644 --- a/ghostos/core/llms/chat.py +++ b/ghostos/core/llms/chat.py @@ -10,7 +10,7 @@ from openai.types.chat.chat_completion_tool_param import ChatCompletionToolParam from pydantic import BaseModel, Field -from ghostos.abc import Identifiable, Identifier +from ghostos.common import Identifiable, Identifier from ghostos import helpers from ghostos.core.messages import Message, Role, Caller diff --git a/ghostos/core/moss/prompts.py b/ghostos/core/moss/prompts.py index ac1fa55f..bb020a22 100644 --- a/ghostos/core/moss/prompts.py +++ b/ghostos/core/moss/prompts.py @@ -10,7 +10,7 @@ add_source_indent, ) from ghostos.core.moss.exports import Exporter -from ghostos.abc import PromptAble, PromptAbleClass +from ghostos.common import PromptAble, PromptAbleClass from ghostos.helpers import generate_import_path import inspect diff --git a/ghostos/core/moss/utils.py b/ghostos/core/moss/utils.py index cf75bc55..5a2c4114 100644 --- a/ghostos/core/moss/utils.py +++ b/ghostos/core/moss/utils.py @@ -2,7 +2,7 @@ import re from typing import Any, Dict, Callable, Optional, List, Iterable, TypedDict, is_typeddict from pydantic import BaseModel -from ghostos.abc import Identifiable, Descriptive +from ghostos.common import Identifiable, Descriptive __all__ = [ diff --git a/ghostos/core/session/tasks.py b/ghostos/core/session/tasks.py index c4d246a0..389cd7c0 100644 --- a/ghostos/core/session/tasks.py +++ b/ghostos/core/session/tasks.py @@ -4,7 +4,7 @@ from enum import Enum from pydantic import BaseModel, Field from ghostos.entity import EntityMeta -from ghostos.abc import Identifier, Identifiable +from ghostos.common import Identifier, Identifiable from ghostos.core.messages import Payload from contextlib import contextmanager diff --git a/ghostos/framework/actions/moss_action.py b/ghostos/framework/actions/moss_action.py index 1ecc5b18..c6ec1f77 100644 --- a/ghostos/framework/actions/moss_action.py +++ b/ghostos/framework/actions/moss_action.py @@ -9,7 +9,7 @@ from ghostos.core.moss import MossRuntime, moss_message from ghostos.core.ghosts.operators import Operator from ghostos.core.session import Session -from ghostos.abc import Identifier +from ghostos.common import Identifier from pydantic import BaseModel, Field from traceback import format_exc diff --git a/ghostos/framework/ghosts/demo.py b/ghostos/framework/ghosts/demo.py index 20e734dc..b6bc57ee 100644 --- a/ghostos/framework/ghosts/demo.py +++ b/ghostos/framework/ghosts/demo.py @@ -1,5 +1,5 @@ from typing import Optional, List -from ghostos.abc import Identifier +from ghostos.common import Identifier from ghostos.core.ghosts import GhostConf, Shell, Workspace from ghostos.core.session import SessionProcess, Task from ghostos.contracts.modules import Modules diff --git a/tests/test_abc.py b/tests/test_abc.py index 86dea525..c4baed62 100644 --- a/tests/test_abc.py +++ b/tests/test_abc.py @@ -1,4 +1,4 @@ -from ghostos.abc import PromptAble, PromptAbleClass +from ghostos.common import PromptAble, PromptAbleClass import inspect From 21399b415c49890d070f55d134fe8ddf424670d9 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 14 Oct 2024 22:43:34 +0800 Subject: [PATCH 044/148] fix: fix invalid identifier name --- ghostos/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostos/common.py b/ghostos/common.py index e6d1170d..91064a71 100644 --- a/ghostos/common.py +++ b/ghostos/common.py @@ -89,7 +89,7 @@ def get_identifier(value: Any) -> Optional[Identifier]: kind="function", ) if inspect.ismethod(value): - return Identifiable( + return Identifier( id=generate_import_path(value.__class__) + ":" + value.__name__, name=value.__name__, description=value.__doc__, From 781b4ea9dd323b2c0baae7e18539bb716c3a8592 Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 17 Oct 2024 01:29:43 +0800 Subject: [PATCH 045/148] dev: message redesign for streaming and receiving --- ghostos/core/abcd/messages_designing.py | 20 -- ghostos/core/abcd/transport.py | 139 +++++---- ghostos/core/messages/helpers.py | 28 +- ghostos/core/messages/message.py | 115 +++++--- ghostos/core/messages/transport.py | 266 ++++++++++++++++++ .../core/messages/test_arr_stream_receiver.py | 124 ++++++++ tests/python/test_yield.py | 82 ++++++ 7 files changed, 638 insertions(+), 136 deletions(-) delete mode 100644 ghostos/core/abcd/messages_designing.py create mode 100644 ghostos/core/messages/transport.py create mode 100644 tests/core/messages/test_arr_stream_receiver.py create mode 100644 tests/python/test_yield.py diff --git a/ghostos/core/abcd/messages_designing.py b/ghostos/core/abcd/messages_designing.py deleted file mode 100644 index 6a262def..00000000 --- a/ghostos/core/abcd/messages_designing.py +++ /dev/null @@ -1,20 +0,0 @@ -from abc import ABC -from .transport import Message, Delivery, Item, UpStream - -__all__ = ['Message', 'MessageDelivery', 'MessageStream', 'MessagePack'] - - -class MessageItem(Item, ABC): - pass - - -class MessagePack(Message, ABC): - pass - - -class MessageDelivery(Delivery, ABC): - pass - - -class MessageStream(UpStream, ABC): - pass diff --git a/ghostos/core/abcd/transport.py b/ghostos/core/abcd/transport.py index e7449fd9..a2652e31 100644 --- a/ghostos/core/abcd/transport.py +++ b/ghostos/core/abcd/transport.py @@ -1,139 +1,132 @@ from abc import abstractmethod -from typing import Iterable, Callable, Tuple +from typing import Iterable, Callable, List, Optional from typing_extensions import Literal, Protocol, Self __all__ = [ - "Item", "Message", - "Delivery", - "UpStream", "Connection", - "Pipe", "Pipeline", "PipeAdapter", - "build_pipeline", + "Message", ] -class Item(Protocol): +class Message(Protocol): """ 消息协议中的最小传输单元. """ @abstractmethod - def seq(self) -> Literal["head", "chunk", "complete"]: + def get_seq(self) -> Literal["head", "chunk", "complete"]: pass + @abstractmethod + def get_copy(self) -> Self: + pass -class Message(Protocol): - """ - 消息协议中一个完整的包. 包含首包, 间包, 尾包. - """ + +class Parser(Protocol): @abstractmethod - def head(self) -> Item: + def batch(self, messages: Iterable[Message]) -> Iterable[Message]: pass @abstractmethod - def chunks(self) -> Iterable[Item]: + def parse(self, message: Message) -> Iterable[Message]: pass @abstractmethod - def tail(self) -> Item: + def completes(self) -> List[Message]: pass -class Delivery(Protocol): - """ - 获取一组传输的 Package, 或许需要资源回收. - """ +class Connection(Protocol): @abstractmethod - def __enter__(self) -> Iterable[Message]: + def on_message(self, callback: Callable[[Message], None]): pass @abstractmethod - def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + def on_error(self, callback: Callable[[Message], None]): pass - -class UpStream(Protocol): - """ - 下游向上游传输消息的方式. - 用于异步模型. - """ - @abstractmethod - def deliver(self, item: Item) -> bool: + def send(self, inputs: Iterable[Message]) -> None: pass @abstractmethod - def stopped(self) -> bool: + def cancel(self, error: Optional[str]) -> None: pass @abstractmethod - def send(self, items: Iterable[Item]) -> bool: + def wait( + self, + on_message: Optional[Callable[[Message], None]] = None, + on_error: Optional[Callable[[Message], None]] = None, + ) -> None: pass @abstractmethod - def accept_chunks(self) -> bool: + def close(self) -> None: pass @abstractmethod - def __enter__(self) -> Self: + def closed(self) -> bool: pass - @abstractmethod - def __exit__(self, exc_type, exc_val, exc_tb) -> bool: - """ - inform final or error to upstream - :param exc_type: - :param exc_val: - :param exc_tb: - :return: - """ - pass + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.closed(): + return + if exc_val is not None: + self.cancel(error=str(exc_val)) + self.close() -Connection = Callable[[...], Tuple[UpStream, Delivery]] -""" -Connection is a function create both upstream and delivery. -""" +class Request(Protocol): -Parser = Callable[[Iterable[Item]], Iterable[Message]] -""" -解析 Item 完成粘包. -""" + @abstractmethod + def ack(self) -> None: + pass -Pipeline = Callable[[Iterable[Message]], Iterable[Message]] -""" -对流式传输的 Package 进行阻断和过滤. -""" + @abstractmethod + def inputs(self) -> Iterable[Message]: + pass + @abstractmethod + def write(self, messages: Iterable[Message]) -> None: + pass -class Pipe(Protocol[Pipeline]): - """ - Pipeline 的一个中间节点. 可以任意拼组顺序. - """ + @abstractmethod + def done(self) -> None: + pass - def attach(self, pipeline: Pipeline) -> Pipeline: - def run(inputs: Iterable[Message]) -> Iterable[Message]: - next_inputs = self.receive(inputs) - outputs = pipeline(next_inputs) - return self.callback(outputs) + @abstractmethod + def fail(self, error: str) -> None: + pass - return run + @abstractmethod + def buffer(self) -> List[Message]: + pass @abstractmethod - def receive(self, inputs: Iterable[Message]) -> Iterable[Message]: + def close(self) -> None: pass @abstractmethod - def callback(self, outputs: Iterable[Message]) -> Iterable[Message]: + def closed(self) -> None: pass + def __enter__(self): + return self -def build_pipeline(destination: Pipeline, *pipes: Pipe[Pipeline]) -> Pipeline: - pipeline = destination - for pipe in reversed(pipes): - pipeline = pipe.attach(pipeline) - return pipeline + def __exit__(self, exc_type, exc_val, exc_tb): + if self.closed(): + return + if exc_val is not None: + self.fail(error=str(exc_val)) + self.close() -PipeAdapter = Callable[[Pipe], Pipe] +class Server(Protocol): + + def run(self, func: Callable[[Request], None]) -> Connection: + pass diff --git a/ghostos/core/messages/helpers.py b/ghostos/core/messages/helpers.py index 79eecb39..509aed96 100644 --- a/ghostos/core/messages/helpers.py +++ b/ghostos/core/messages/helpers.py @@ -1,15 +1,33 @@ -from typing import Iterable, List -from ghostos.core.messages.message import Message +from typing import Iterable, List, Union, Dict +from ghostos.core.messages.message import Message, Role, MessageClass __all__ = [ - 'copy_messages', + 'copy_messages', 'iter_messages', ] def copy_messages(messages: Iterable[Message]) -> List[Message]: + """ + syntax sugar for copy + """ result = [] for message in messages: - result.append(message.model_copy(deep=True)) + result.append(message.get_copy()) return result -# seems at last not so many helper function are made.... + +def iter_messages(messages: Iterable[Union[Message, str, Dict, MessageClass]]) -> Iterable[Message]: + """ + yield from all kinds of messages + """ + for item in messages: + if isinstance(item, Message): + yield item + elif isinstance(item, str): + yield Role.ASSISTANT.new(content=item) + elif isinstance(item, MessageClass): + yield item.to_message() + elif isinstance(item, Dict): + yield Message(**item) + else: + raise TypeError(f"Unexpected type {type(item)}") diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 52865e73..e6624373 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -1,10 +1,10 @@ import enum -import time from typing import Optional, Dict, Set, Iterable, Union, List, ClassVar -from typing_extensions import Self +from typing_extensions import Self, Literal from abc import ABC, abstractmethod from pydantic import BaseModel, Field from ghostos.helpers import uuid +from copy import deepcopy __all__ = [ "Message", "Role", "DefaultMessageTypes", @@ -13,6 +13,8 @@ "Payload", "PayloadItem", "Attachment", "Caller", ] +Seq = Literal["head", "chunk", "complete"] + class Role(str, enum.Enum): """ @@ -56,8 +58,9 @@ def new( class DefaultMessageTypes(str, enum.Enum): DEFAULT = "" CHAT_COMPLETION = "openai.chat_completion" - ERROR = "ghostos.messages.error" - FINAL = "ghostos.messages.final" + ERROR = "ghostos.streaming.error" + FINAL = "ghostos.streaming.final" + ACK = "ghostos.streaming.ack" def new( self, *, @@ -101,7 +104,9 @@ def is_final(cls, pack: "Message") -> bool: return pack.type == cls.FINAL.value @classmethod - def is_protocol_message(cls, message: "Message") -> bool: + def is_protocol_message(cls, message: Optional["Message"]) -> bool: + if message is None: + return True return cls.is_protocol_type(message.type) @classmethod @@ -228,10 +233,11 @@ class Message(BaseModel): msg_id: str = Field(default="", description="unique message id. ") ref_id: Optional[str] = Field(default=None, description="the referenced message id.") type: str = Field(default="", description="default message type, if empty, means text") - created: float = Field( - default=0.0, - description="Message creation time, only available in head chunk or complete one", - ) + # created: float = Field( + # default=0.0, + # description="Message creation time, only available in head chunk or complete one", + # ) + # todo: remove later, use seq instead chunk: bool = Field(default=True, description="if the message is a chunk or a complete one") role: str = Field(default=Role.ASSISTANT.value, description="Message role", enum=Role.all()) @@ -262,13 +268,14 @@ class Message(BaseModel): description="the callers parsed in a complete message." ) - chunk_count: int = Field(default=0, description="how many chunks of this complete message") - time_cast: float = Field(default=0.0, description="from first chunk to tail message.") + # chunk_count: int = Field(default=0, description="how many chunks of this complete message") + # time_cast: float = Field(default=0.0, description="from first chunk to tail message.") streaming_id: Optional[str] = Field( default=None, description="may be multiple streaming exists, use streaming id to separate them into a order", ) + seq: Seq = Field(default="chunk") @classmethod def new_head( @@ -280,7 +287,7 @@ def new_head( name: Optional[str] = None, msg_id: Optional[str] = None, ref_id: Optional[str] = None, - created: int = 0, + # created: int = 0, ): """ create a head chunk message @@ -291,18 +298,19 @@ def new_head( :param name: :param msg_id: :param ref_id: - :param created: + # :param created: :return: """ if msg_id is None: msg_id = uuid() - if created <= 0: - created = round(time.time(), 4) + # if created <= 0: + # created = round(time.time(), 4) return cls( role=role, name=name, content=content, memory=memory, chunk=True, type=typ_, ref_id=ref_id, - msg_id=msg_id, created=created, + msg_id=msg_id, + # created=created, ) @classmethod @@ -315,7 +323,7 @@ def new_tail( name: Optional[str] = None, msg_id: Optional[str] = None, ref_id: Optional[str] = None, - created: int = 0, + # created: int = 0, ): """ create a tail message, is the complete message of chunks. @@ -326,7 +334,7 @@ def new_tail( :param name: :param msg_id: :param ref_id: - :param created: + # :param created: :return: """ msg = cls.new_head( @@ -334,7 +342,7 @@ def new_tail( typ_=type_, msg_id=msg_id, ref_id=ref_id, - created=created, + # created=created, ) msg.chunk = False return msg @@ -394,25 +402,28 @@ def patch(self, chunk: "Message") -> Optional["Message"]: chunk.msg_id = self.msg_id return self - def as_head(self) -> Self: - item = self.model_copy(deep=True) + def as_head(self, copy: bool = True) -> Self: + if copy: + item = self.get_copy() + else: + item = self if not item.msg_id: item.msg_id = uuid() - if not self.created: - item.created = time.time() + # if not self.created: + # item.created = time.time() + if item.seq == "chunk": + item.seq = "head" return item - def as_tail(self) -> Self: - item = self.as_head() + def get_copy(self) -> Self: + return self.model_copy(deep=True) + + def as_tail(self, copy: bool = True) -> Self: + item = self.as_head(copy) item.chunk = False + item.seq = "complete" return item - def get_copy(self) -> "Message": - """ - :return: deep copy - """ - return self.model_copy(deep=True) - def update(self, pack: "Message") -> None: """ update the fields. @@ -437,19 +448,19 @@ def update(self, pack: "Message") -> None: if pack.memory is not None: self.memory = pack.memory - self.payloads.update(pack.payloads) + self.payloads.update(deepcopy(pack.payloads)) if pack.attachments is not None: for key, items in pack.attachments.items(): saved = self.attachments.get(key, []) - saved.append(*items) + saved.append(*[deepcopy(at) for at in saved]) self.attachments[key] = saved if pack.callers: self.callers.extend(pack.callers) - self.chunk_count += 1 - if self.created: - now = round(time.time(), 4) - self.time_cast = round(now - self.created, 4) + # self.chunk_count += 1 + # if self.created: + # now = round(time.time(), 4) + # self.time_cast = round(now - self.created, 4) def get_type(self) -> str: """ @@ -469,7 +480,13 @@ def is_complete(self) -> bool: """ complete message is not a chunk one """ - return not self.chunk + return not self.chunk or self.seq == "complete" + + def is_head(self) -> bool: + return self.seq == "head" + + def get_seq(self) -> Seq: + return self.seq def dump(self) -> Dict: """ @@ -477,6 +494,28 @@ def dump(self) -> Dict: """ return self.model_dump(exclude_defaults=True) + def __str__(self): + return self.get_content() + + + +class Buffer(ABC): + + @abstractmethod + def buffer(self, message: Iterable[Message]) -> Iterable[Message]: + pass + + @abstractmethod + def completes(self) -> List[Message]: + pass + + def __enter__(self): + return self + + @abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb): + pass + class MessageClass(ABC): """ diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py new file mode 100644 index 00000000..c6ad979b --- /dev/null +++ b/ghostos/core/messages/transport.py @@ -0,0 +1,266 @@ +from typing import Iterable, Optional, Callable, List, Tuple + +from typing_extensions import Protocol +from abc import abstractmethod +from ghostos.core.messages.message import Message, DefaultMessageTypes +from ghostos.core.messages.pipeline import SequencePipe +import time + +__all__ = ["Stream", "Receiver", "ArrayReceiver", "ArrayStream", "new_arr_connection"] + + +class Stream(Protocol): + """ + an interface that can send messages asynchronously. + """ + + @abstractmethod + def send(self, messages: Iterable[Message]) -> bool: + """ + send batch of messages + :return: successful. if False, maybe error occur + """ + pass + + @abstractmethod + def deliver(self, message: Message) -> bool: + return self.send([message]) + + @abstractmethod + def completes_only(self) -> bool: + """ + if the stream receive complete message only + :return: + """ + pass + + @abstractmethod + def alive(self) -> bool: + """ + :return: the upstream channel is alive + """ + pass + + @abstractmethod + def close(self): + pass + + @abstractmethod + def fail(self, error: str) -> bool: + """ + 端的 fail 会传递给 receiver. + :param error: + :return: + """ + pass + + @abstractmethod + def error(self) -> Optional[Message]: + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> Optional[bool]: + if not self.alive(): + return None + intercept = None + if exc_val is not None: + intercept = self.fail(error=str(exc_val)) + self.close() + return intercept + + +class Receiver(Protocol): + @abstractmethod + def recv(self) -> Iterable[Message]: + pass + + @abstractmethod + def cancel(self): + pass + + @abstractmethod + def fail(self, error: str) -> bool: + """ + receiver 的 fail 会传递到端. + :param error: + :return: + """ + pass + + @abstractmethod + def done(self) -> bool: + pass + + @abstractmethod + def error(self) -> Optional[Message]: + pass + + @abstractmethod + def close(self): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> Optional[bool]: + if self.done(): + return None + intercept = None + if exc_val is not None: + intercept = self.fail(str(exc_val)) + self.close() + return intercept + + +class ArrayReceiver(Receiver): + + def __init__(self, alive: Callable[[], bool], idle: float = 0.1, complete_only: bool = False): + self._check_alive = alive + self._idle = idle + self._chunks: List[Message] = [] + self._closed = False + self._done = False + self._error: Optional[Message] = None + self._complete_only = complete_only + + def recv(self) -> Iterable[Message]: + if self._closed: + raise RuntimeError("Receiver is closed") + idx = 0 + alive = self._check_alive + while not self._done: + if idx < len(self._chunks): + yield self._chunks[idx] + idx += 1 + continue + is_alive = alive() + if not is_alive: + self._error = DefaultMessageTypes.ERROR.new(content="Receiver is closed") + self._done = True + break + if self._idle: + time.sleep(self._idle) + if idx < len(self._chunks): + while idx < len(self._chunks): + yield self._chunks[idx] + idx += 1 + if self._error is not None: + yield self._error + + def add(self, message: Message) -> bool: + if self._closed or self._done: + return False + if not self._check_alive(): + return False + if DefaultMessageTypes.is_protocol_message(message): + self._done = True + if DefaultMessageTypes.ERROR.match(message): + self._error = message + return True + else: + if message.is_complete() or not self._complete_only: + self._chunks.append(message) + return True + + def cancel(self): + self._done = True + + def fail(self, error: str) -> bool: + self._done = True + self._error = DefaultMessageTypes.ERROR.new(content=error) + return False + + def done(self) -> bool: + return self._done + + def error(self) -> Optional[Message]: + return self._error + + def close(self): + if self._closed: + return + self._done = True + self._error = None + self._chunks = [] + del self._check_alive + + +class ArrayStream(Stream): + + def __init__(self, receiver: ArrayReceiver, complete_only: bool): + self._receiver = receiver + self._alive = not receiver.done() + self._closed = False + self._error: Optional[Message] = None + self._complete_only = complete_only + + def send(self, messages: Iterable[Message]) -> bool: + if self._closed or not self._alive: + raise RuntimeError("Stream is closed") + if self._error is not None: + raise RuntimeError(self._error.get_content()) + items = SequencePipe().across(messages) + for item in items: + if self._complete_only and not item.is_complete(): + continue + success = self._receiver.add(item) + if success: + continue + self._alive = False + self._error = self._receiver.error() + if self._error is not None: + raise RuntimeError(f"upstream is closed: {self._error.get_content()}") + else: + raise RuntimeError(f"send upstream failed") + return True + + def completes_only(self) -> bool: + return self._complete_only + + def alive(self) -> bool: + if not self._alive: + return False + if self._receiver.done(): + self._alive = False + return self._alive + + def close(self): + if self._closed: + return + if self._alive: + self._receiver.add(DefaultMessageTypes.final()) + self._alive = False + self._closed = True + del self._receiver + + def fail(self, error: str) -> bool: + if self._error is not None: + return False + self._error = DefaultMessageTypes.ERROR.new(content=error) + if self._alive: + self._receiver.add(self._error) + self._alive = False + return False + + def error(self) -> Optional[Message]: + return self._error + + +def new_arr_connection( + timeout: float = -1, + idle: float = 0.2, + complete_only: bool = False, +) -> Tuple[Stream, Receiver]: + from ghostos.helpers import Timeleft + timeleft = Timeleft(timeout) + + def alive_check() -> bool: + if not timeleft.alive(): + return False + return True + + receiver = ArrayReceiver(alive_check, idle, complete_only) + stream = ArrayStream(receiver, complete_only) + return stream, receiver diff --git a/tests/core/messages/test_arr_stream_receiver.py b/tests/core/messages/test_arr_stream_receiver.py new file mode 100644 index 00000000..94ef5f19 --- /dev/null +++ b/tests/core/messages/test_arr_stream_receiver.py @@ -0,0 +1,124 @@ +from typing import Iterable +from ghostos.core.messages.transport import new_arr_connection, Stream, Receiver +from ghostos.core.messages.message import Message +from threading import Thread +import time + + +def iter_content(content: str, gap: float) -> Iterable[Message]: + for c in content: + item = Message.new_chunk(content=c) + yield item + if gap > 0: + time.sleep(gap) + + +def test_new_connection_baseline(): + stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + assert stream.alive() + assert not retriever.done() + content = "hello world, ha ha ha ha" + + def send_data(s: Stream, c: str): + with s: + s.send(iter_content(c, 0.02)) + + t = Thread(target=send_data, args=(stream, content)) + t.start() + last = None + first = None + with retriever: + count = 0 + for item in retriever.recv(): + if not first: + first = item + count += 1 + last = item + assert count == len(content) + 1 + assert first is not None + assert first.is_head() + assert last.is_complete() + t.join() + + +def test_new_connection_timeout(): + stream, retriever = new_arr_connection(timeout=0.2, idle=0.2, complete_only=False) + content = "hello world" + + def send_data(s: Stream, c: str): + error = None + try: + with s: + s.send(iter_content(c, 1)) + except RuntimeError as e: + error = e + finally: + assert error is not None + + t = Thread(target=send_data, args=(stream, content)) + t.start() + with retriever: + messages = list(retriever.recv()) + assert retriever.done() + assert retriever.error() is not None + assert not stream.alive() + t.join() + + +def test_new_connection_complete_only(): + stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=True) + content = "hello world" + + def send_data(s: Stream, c: str): + with s: + s.send(iter_content(c, 0.02)) + + t = Thread(target=send_data, args=(stream, content)) + t.start() + with retriever: + messages = list(retriever.recv()) + assert len(messages) == 1 + assert messages[0].is_complete() + assert messages[0].content == content + t.join() + + +def test_new_connection_timeout(): + stream, retriever = new_arr_connection(timeout=0.2, idle=0.2, complete_only=False) + content = "hello world" + + def send_data(s: Stream, c: str): + error = None + try: + with s: + s.send(iter_content(c, 1)) + except RuntimeError as e: + error = e + finally: + assert error is not None + + t = Thread(target=send_data, args=(stream, content)) + t.start() + with retriever: + messages = list(retriever.recv()) + assert retriever.done() + assert retriever.error() is not None + assert not stream.alive() + t.join() + + +def test_new_connection_sync(): + stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + content = "hello world" + + def send_data(s: Stream, c: str): + with s: + s.send(iter_content(c, 0.02)) + + send_data(stream, content) + with retriever: + messages = list(retriever.recv()) + assert len(messages) == len(content) + 1 + assert messages[len(content)].is_complete() + assert messages[len(content)].content == content + assert messages[3].get_seq() == "chunk" diff --git a/tests/python/test_yield.py b/tests/python/test_yield.py new file mode 100644 index 00000000..065e3fd7 --- /dev/null +++ b/tests/python/test_yield.py @@ -0,0 +1,82 @@ +from typing import Iterable + + +def test_yield_is_blocking(): + tests = [] + + def foo(values: Iterable[int]) -> Iterable[int]: + for value in values: + yield value + tests.append("foo") + + def bar(values: Iterable[int]) -> Iterable[int]: + for value in values: + yield value + tests.append("bar") + + list(bar(foo([1, 2, 3]))) + # yield still block + # and if upstream not iter any, the downstream function is not running. + assert tests == ["foo", "bar"] + + +def test_yield_none(): + def foo(): + yield 1 + yield 2 + yield None + print("foo") + + v = list(foo()) + assert v == [1, 2, None] + + def bar(): + try: + yield 1 + yield 2 + return None + finally: + # print("bar") + pass + + v = list(bar()) + assert v == [1, 2] + + +def test_yield_with_finally(): + values = [] + + def foo(): + try: + values.append(1) + yield 1 + values.append(2) + yield 2 + finally: + values.append(3) + + foo() + # finally is not called as well. + assert values == [] + +# iterable can not define __awaits__ +# def test_yield_is_blocking_with_none(): +# tests = [] +# +# async def foo(values: Iterable[int]) -> Iterable[int]: +# for value in values: +# yield value +# tests.append("foo") +# +# async def bar(values: Iterable[int]) -> Iterable[int]: +# for value in values: +# yield value +# yield None +# tests.append("bar") +# +# async def main(): +# list(await bar(await foo([1, 2, 3]))) +# +# import asyncio +# asyncio.run(main()) +# assert tests == ["foo", "bar"] From 0dc888394a6c5b6e503726f2634d64f8dda96ade Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 18 Oct 2024 15:32:11 +0800 Subject: [PATCH 046/148] dev: develop new stream and receiver, replace old one later --- examples/aifunc_raw_test.py | 15 ++-- ghostos/core/messages/buffers2.py | 66 +++++++++++++++++ ghostos/core/messages/pipeline.py | 110 +++++++++++++++++++++++++++++ ghostos/core/messages/transport.py | 9 ++- 4 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 ghostos/core/messages/buffers2.py create mode 100644 ghostos/core/messages/pipeline.py diff --git a/examples/aifunc_raw_test.py b/examples/aifunc_raw_test.py index 46eb0413..3f3738ff 100644 --- a/examples/aifunc_raw_test.py +++ b/examples/aifunc_raw_test.py @@ -1,6 +1,7 @@ import sys from os.path import dirname from ghostos.core.aifunc import AIFuncExecutor +from ghostos.core.messages.transport import new_arr_connection # I hate python imports ghostos_project_dir = dirname(dirname(__file__)) @@ -14,7 +15,6 @@ if __name__ == '__main__': from ghostos.bootstrap import application_container from ghostos.demo.aifuncs.agentic import AgentFn - from ghostos.framework.streams import new_connection from rich.console import Console from rich.markdown import Markdown from rich.panel import Panel @@ -29,18 +29,19 @@ fn = AgentFn( request="help me to find news about OpenAI O1 model", ) - stream, receiver = new_connection(-1, accept_chunks=False) + stream, receiver = new_arr_connection(timeout=-1, complete_only=True) frame, caller = executor.new_exec_frame(fn, stream) t = Thread(target=caller) t.start() - with receiver as items: - for item in items: - tail = item.done() - payloads = json.dumps(tail.payloads, indent=2, ensure_ascii=False) - + with receiver: + for item in receiver.recv(): + if not item.is_complete(): + continue + tail = item payloads_info = "" if debug: + payloads = json.dumps(tail.payloads, indent=2, ensure_ascii=False) payloads_info = f""" ```json diff --git a/ghostos/core/messages/buffers2.py b/ghostos/core/messages/buffers2.py new file mode 100644 index 00000000..e63b197a --- /dev/null +++ b/ghostos/core/messages/buffers2.py @@ -0,0 +1,66 @@ +from typing import List, Iterable, Optional, Tuple +from typing_extensions import Self +from abc import ABC, abstractmethod +from ghostos.core.messages.message import Message + + +class Buffer(ABC): + + @abstractmethod + def new(self) -> Self: + pass + + @abstractmethod + def match(self, message: Message) -> bool: + pass + + @abstractmethod + def buffer(self, message: Message) -> Iterable[Message]: + pass + + @abstractmethod + def flush(self) -> Tuple[List[Message], List[Message]]: + pass + + +class GroupBuffer(Buffer): + def __init__(self, default: Buffer, buffers: Iterable[Buffer]): + self._default = default + self._buffers = list(buffers) + self._current: Optional[Buffer] = None + self._completes: List[Message] = [] + + def new(self) -> Self: + return GroupBuffer(buffers=self._buffers) + + def match(self, message: Message) -> bool: + return True + + def _find_buffer(self, message: Message) -> Buffer: + for buffer in self._buffers: + if buffer.match(message): + return buffer.new() + return self._default.new() + + def buffer(self, message: Message) -> Iterable[Message]: + if self._current is None: + self._current = self._find_buffer(message) + yield from self._current.buffer(message) + elif self._current.match(message): + yield from self._current.buffer(message) + else: + unsent, completes = self._current.flush() + self._completes.extend(completes) + yield from unsent + self._current = self._find_buffer(message) + yield from self._current.buffer(message) + + def flush(self) -> Tuple[List[Message], List[Message]]: + unsent = [] + if self._current is not None: + unsent, completes = self._current.flush() + self._completes.extend(completes) + self._current = None + completes = self._completes + self._completes = [] + return unsent, completes diff --git a/ghostos/core/messages/pipeline.py b/ghostos/core/messages/pipeline.py new file mode 100644 index 00000000..1f78d5c3 --- /dev/null +++ b/ghostos/core/messages/pipeline.py @@ -0,0 +1,110 @@ +from typing import Iterable, List, Optional, Dict +from typing_extensions import Self +from abc import ABC, abstractmethod +from ghostos.core.messages.message import Message, DefaultMessageTypes +from ghostos.core.messages.helpers import iter_messages + + +class Pipe(ABC): + + @abstractmethod + def new(self) -> Self: + pass + + @abstractmethod + def across(self, messages: Iterable[Message]) -> Iterable[Message]: + pass + + +def pipeline(pipes: Iterable[Pipe], messages: Iterable[Message]) -> Iterable[Message]: + """ + build pipeline with pipes + :param pipes: + :param messages: + :return: + """ + ordered = reversed(list(pipes)) + outputs = messages + for pipe in ordered: + outputs = pipe.across(messages) + yield from outputs + + +class SequencePipe(Pipe): + """ + make sure messages are sent in a ?head-?chunk-tail-?tail sequence + """ + + def new(self) -> Self: + return SequencePipe() + + def across(self, messages: Iterable[Message]) -> Iterable[Message]: + head: Optional[Message] = None + final: Optional[Message] = None + for item in messages: + if DefaultMessageTypes.is_protocol_message(item): + break + if head is None: + if item.is_complete(): + yield item + else: + head = item.as_head() + yield head.get_copy() + else: + patched = head.patch(item) + if patched: + if patched.is_complete(): + head = patched + yield patched.get_copy() + else: + yield item.get_copy() + else: + yield head.as_tail() + head = item.as_head() + yield head.get_copy() + if head is not None: + yield head.as_tail(copy=False) + if final is not None: + yield final + + +class CompleteOnly(Pipe): + """ + return complete only + """ + + def new(self) -> Self: + return CompleteOnly() + + def across(self, messages: Iterable[Message]) -> Iterable[Message]: + for item in messages: + if DefaultMessageTypes.is_protocol_message(item): + yield item + break + elif item.is_complete(): + yield item + + +class TailPatchPipe(Pipe): + + def new(self) -> Self: + return TailPatchPipe() + + def across(self, messages: Iterable[Message]) -> Iterable[Message]: + last_tail: Optional[Message] = None + for item in messages: + if DefaultMessageTypes.is_protocol_message(item): + yield item + break + if not item.is_complete(): + yield item + continue + if last_tail is None: + last_tail = item + continue + patched = last_tail.patch(item) + if patched: + last_tail = patched + continue + yield last_tail.as_tail() + last_tail = item diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index c6ad979b..3b707dba 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -22,7 +22,6 @@ def send(self, messages: Iterable[Message]) -> bool: """ pass - @abstractmethod def deliver(self, message: Message) -> bool: return self.send([message]) @@ -249,10 +248,18 @@ def error(self) -> Optional[Message]: def new_arr_connection( + *, timeout: float = -1, idle: float = 0.2, complete_only: bool = False, ) -> Tuple[Stream, Receiver]: + """ + use array to pass and receive messages in multi-thread + :param timeout: if negative, wait until done + :param idle: sleep time in seconds wait for next pull + :param complete_only: only receive complete message + :return: created stream and receiver + """ from ghostos.helpers import Timeleft timeleft = Timeleft(timeout) From 9704ca128dd1a33061d76125ce9de3e105ae027a Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 18 Oct 2024 16:43:01 +0800 Subject: [PATCH 047/148] feat: move workspace from ghosts to contracts --- app/runtime/aifunc_frames/.gitignore | 2 ++ ghostos/bootstrap.py | 26 +++++++++++++---- .../{core/ghosts => contracts}/workspace.py | 0 ghostos/core/aifunc/repository.py | 28 +++++++++++++++---- ghostos/core/ghosts/__init__.py | 1 - ghostos/core/ghosts/ghost.py | 4 +-- ghostos/framework/configs/storageimpl.py | 2 +- .../framework/processes/storage_processes.py | 2 +- ghostos/framework/tasks/storage_tasks.py | 2 +- ghostos/framework/threads/storage_threads.py | 2 +- ghostos/framework/workspaces/__init__.py | 1 - ghostos/framework/workspaces/basic.py | 2 +- 12 files changed, 52 insertions(+), 20 deletions(-) create mode 100644 app/runtime/aifunc_frames/.gitignore rename ghostos/{core/ghosts => contracts}/workspace.py (100%) diff --git a/app/runtime/aifunc_frames/.gitignore b/app/runtime/aifunc_frames/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/app/runtime/aifunc_frames/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 34d4f51d..63c63e98 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -93,8 +93,8 @@ def default_application_contracts() -> Contracts: from ghostos.contracts.pool import Pool from ghostos.contracts.shutdown import Shutdown from ghostos.contracts.modules import Modules + from ghostos.contracts.workspace import Workspace from ghostos.entity import EntityFactory - from ghostos.framework.workspaces import Workspace from ghostos.framework.configs import Configs from ghostos.framework.processes import GhostProcessRepo from ghostos.framework.threads import MsgThreadRepo @@ -161,6 +161,11 @@ def default_application_providers( from ghostos.framework.entities import EntityFactoryProvider from ghostos.core.aifunc import DefaultAIFuncExecutorProvider, AIFuncRepoByConfigsProvider return [ + + # --- logger ---# + + NamedLoggerProvider(logger_name), + # --- workspace --- # BasicWorkspaceProvider( workspace_dir=root_dir, configs_path=workspace_configs_dir, @@ -169,17 +174,26 @@ def default_application_providers( WorkspaceConfigsProvider(), WorkspaceProcessesProvider(runtime_processes_dir), WorkspaceTasksProvider(runtime_tasks_dir), + + # --- session ---# MsgThreadsRepoByWorkSpaceProvider(runtime_threads_dir), DefaultPoolProvider(100), - ConfigBasedLLMsProvider(llms_conf_path), - DefaultModulesProvider(), MemEventBusImplProvider(), - ShutdownProvider(), - NamedLoggerProvider(logger_name), + + # --- moss --- # DefaultMOSSProvider(), + + # --- llm --- # + ConfigBasedLLMsProvider(llms_conf_path), + + # --- basic library --- # EntityFactoryProvider(), + DefaultModulesProvider(), + ShutdownProvider(), + + # --- aifunc --- # DefaultAIFuncExecutorProvider(), - AIFuncRepoByConfigsProvider(), + AIFuncRepoByConfigsProvider(runtime_frame_dir="aifunc_frames"), ] diff --git a/ghostos/core/ghosts/workspace.py b/ghostos/contracts/workspace.py similarity index 100% rename from ghostos/core/ghosts/workspace.py rename to ghostos/contracts/workspace.py diff --git a/ghostos/core/aifunc/repository.py b/ghostos/core/aifunc/repository.py index 79c58ca0..6ebbd299 100644 --- a/ghostos/core/aifunc/repository.py +++ b/ghostos/core/aifunc/repository.py @@ -7,9 +7,12 @@ from ghostos.core.aifunc.interfaces import AIFuncRepository from ghostos.contracts.configs import YamlConfig, Configs from ghostos.contracts.modules import Modules +from ghostos.contracts.storage import Storage +from ghostos.contracts.workspace import Workspace from ghostos.helpers import generate_module_spec -from ghostos.container import Provider, Container, INSTANCE +from ghostos.container import Provider, Container from pydantic import Field +from os.path import join import time @@ -20,7 +23,7 @@ class AIFuncsConf(YamlConfig): default_factory=dict, description="registered AiFuncs identifier", ) - validated_at: int = Field(0, description="Validation time in seconds") + validated_at: int = Field(0, description="validate the identifiers, validation time in seconds") overdue: int = Field(3600, description="Overdue time in seconds") def is_overdue(self) -> bool: @@ -35,12 +38,14 @@ def __init__( conf: AIFuncsConf, configs: Configs, modules: Modules, + frame_storage: Optional[Storage] = None, ): self.conf = conf self.configs = configs self.modules = modules if self.conf.is_overdue(): self.validate() + self.frame_storage = frame_storage def register(self, *fns: Type[AIFunc]) -> None: saving = [] @@ -105,11 +110,22 @@ def validate(self) -> None: self.configs.save(self.conf) def save_exec_frame(self, frame: ExecFrame) -> None: + if self.frame_storage is not None: + filename = self._frame_filepath(frame.func_name(), frame.frame_id + ".json") + value = frame.model_dump_json(exclude_defaults=True, indent=2) + self.frame_storage.put(filename, value.encode()) return None + @classmethod + def _frame_filepath(cls, func_name: str, frame_id: str, ext: str = ".json") -> str: + return join(func_name, frame_id + ext) + class AIFuncRepoByConfigsProvider(Provider[AIFuncRepository]): + def __init__(self, runtime_frame_dir: Optional[str] = None): + self._runtime_frame_dir = runtime_frame_dir + def singleton(self) -> bool: return True @@ -118,6 +134,8 @@ def factory(self, con: Container) -> Optional[AIFuncRepository]: modules = con.force_fetch(Modules) conf = configs.get(AIFuncsConf) conf.validated_at = int(time.time()) - return AIFuncRepoByConfigs(conf, configs, modules) - - + runtime_storage = None + if self._runtime_frame_dir: + workspace = con.force_fetch(Workspace) + runtime_storage = workspace.runtime().sub_storage(self._runtime_frame_dir) + return AIFuncRepoByConfigs(conf, configs, modules, runtime_storage) diff --git a/ghostos/core/ghosts/__init__.py b/ghostos/core/ghosts/__init__.py index b3fe9846..000aeedf 100644 --- a/ghostos/core/ghosts/__init__.py +++ b/ghostos/core/ghosts/__init__.py @@ -11,4 +11,3 @@ ) from ghostos.core.ghosts.shells import Shell from ghostos.core.ghosts.utils import Utils, NewTask -from ghostos.core.ghosts.workspace import Workspace diff --git a/ghostos/core/ghosts/ghost.py b/ghostos/core/ghosts/ghost.py index cb535d8f..9e8d929f 100644 --- a/ghostos/core/ghosts/ghost.py +++ b/ghostos/core/ghosts/ghost.py @@ -7,7 +7,7 @@ from ghostos.contracts.modules import Modules from ghostos.contracts.configs import Configs from ghostos.core.session import Session, Event -from ghostos.core.messages import Message, Caller, Role +from ghostos.core.messages import Message, Role from ghostos.core.moss import MossCompiler from ghostos.core.llms import LLMs from pydantic import BaseModel, Field @@ -19,7 +19,7 @@ from ghostos.core.ghosts.thoughts import Mindset, Thought from ghostos.core.ghosts.schedulers import MultiTask, Taskflow, Replier from ghostos.core.ghosts.operators import Operator - from ghostos.core.ghosts.workspace import Workspace + from ghostos.contracts.workspace import Workspace from ghostos.core.ghosts.actions import Action __all__ = ['Ghost', 'Inputs', 'GhostConf'] diff --git a/ghostos/framework/configs/storageimpl.py b/ghostos/framework/configs/storageimpl.py index 7c5cbf5f..74ddbc7f 100644 --- a/ghostos/framework/configs/storageimpl.py +++ b/ghostos/framework/configs/storageimpl.py @@ -2,7 +2,7 @@ from ghostos.contracts.configs import Configs from ghostos.contracts.storage import Storage from ghostos.container import Provider, Container -from ghostos.core.ghosts import Workspace +from ghostos.contracts.workspace import Workspace from .basic import BasicConfigs diff --git a/ghostos/framework/processes/storage_processes.py b/ghostos/framework/processes/storage_processes.py index 77a953d8..c740c771 100644 --- a/ghostos/framework/processes/storage_processes.py +++ b/ghostos/framework/processes/storage_processes.py @@ -4,7 +4,7 @@ from ghostos.core.session.processes import GhostProcessRepo from ghostos.contracts.storage import Storage from ghostos.contracts.logger import LoggerItf -from ghostos.core.ghosts.workspace import Workspace +from ghostos.contracts.workspace import Workspace from threading import Lock from ghostos.container import Provider, Container diff --git a/ghostos/framework/tasks/storage_tasks.py b/ghostos/framework/tasks/storage_tasks.py index a9af98d7..63b48b43 100644 --- a/ghostos/framework/tasks/storage_tasks.py +++ b/ghostos/framework/tasks/storage_tasks.py @@ -1,7 +1,7 @@ from typing import Optional, List, Iterable, Dict, Type import yaml from ghostos.core.session import TaskState, TaskBrief, Task, TaskRepo -from ghostos.core.ghosts import Workspace +from ghostos.contracts.workspace import Workspace from ghostos.contracts.logger import LoggerItf from ghostos.contracts.storage import Storage from ghostos.container import Provider, Container diff --git a/ghostos/framework/threads/storage_threads.py b/ghostos/framework/threads/storage_threads.py index 759a90ad..726a06e7 100644 --- a/ghostos/framework/threads/storage_threads.py +++ b/ghostos/framework/threads/storage_threads.py @@ -1,6 +1,6 @@ from typing import Optional, Type from ghostos.core.session import MsgThread, MsgThreadRepo, SimpleMsgThread -from ghostos.core.ghosts import Workspace +from ghostos.contracts.workspace import Workspace from ghostos.contracts.storage import Storage from ghostos.contracts.logger import LoggerItf from ghostos.helpers import yaml_pretty_dump diff --git a/ghostos/framework/workspaces/__init__.py b/ghostos/framework/workspaces/__init__.py index a6a425b3..43cc7c6a 100644 --- a/ghostos/framework/workspaces/__init__.py +++ b/ghostos/framework/workspaces/__init__.py @@ -1,2 +1 @@ -from ghostos.core.ghosts.workspace import Workspace from ghostos.framework.workspaces.basic import BasicWorkspaceProvider diff --git a/ghostos/framework/workspaces/basic.py b/ghostos/framework/workspaces/basic.py index 74b8c25c..c3aa7160 100644 --- a/ghostos/framework/workspaces/basic.py +++ b/ghostos/framework/workspaces/basic.py @@ -1,6 +1,6 @@ from typing import Optional, Type -from ghostos.core.ghosts.workspace import Workspace +from ghostos.contracts.workspace import Workspace from ghostos.framework.storage import FileStorage, FileStorageImpl from ghostos.container import Provider, Container, INSTANCE From 03908b363d794881f69589c1de3865c6b564ea11 Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 18 Oct 2024 21:05:34 +0800 Subject: [PATCH 048/148] dev: add translation for i18n usage. but not fit the gettext now. todo later --- app/asserts/translations/GhostOS.yml | 55 ++++++++ app/streamlit_main.py | 3 +- ghostos/bootstrap.py | 4 + ghostos/contracts/translation.py | 80 ++++++++++++ ghostos/contracts/workspace.py | 4 + ghostos/framework/translation/__init__.py | 2 + ghostos/framework/translation/dict_impl.py | 120 ++++++++++++++++++ ghostos/framework/workspaces/basic.py | 10 +- ghostos/prototypes/streamlitapp/app.py | 5 +- ghostos/prototypes/streamlitapp/configs.py | 8 ++ ghostos/prototypes/streamlitapp/navigation.py | 59 ++++++--- .../streamlitapp/{utils => }/options.py | 0 .../prototypes/streamlitapp/pages/aifuncs.py | 9 ++ .../prototypes/streamlitapp/pages/homepage.py | 2 +- ghostos/prototypes/streamlitapp/resources.py | 22 ++++ .../prototypes/streamlitapp/utils/route.py | 8 +- .../prototypes/streamlitapp/utils/session.py | 17 ++- ghostos/prototypes/streamlitapp/widgets.py | 2 +- 18 files changed, 371 insertions(+), 39 deletions(-) create mode 100644 app/asserts/translations/GhostOS.yml create mode 100644 ghostos/contracts/translation.py create mode 100644 ghostos/framework/translation/__init__.py create mode 100644 ghostos/framework/translation/dict_impl.py create mode 100644 ghostos/prototypes/streamlitapp/configs.py rename ghostos/prototypes/streamlitapp/{utils => }/options.py (100%) create mode 100644 ghostos/prototypes/streamlitapp/pages/aifuncs.py diff --git a/app/asserts/translations/GhostOS.yml b/app/asserts/translations/GhostOS.yml new file mode 100644 index 00000000..dde33d50 --- /dev/null +++ b/app/asserts/translations/GhostOS.yml @@ -0,0 +1,55 @@ +# model is ghostos.framework.translation.dict_impl:DomainTranslationData +domain: GhostOS +langs: +- zh +- en +default_lang: en +items: + Home: + id: Home + description: '' + translations: {} + GhostOS Host: + id: GhostOS Host + description: '' + translations: {} + AIFunc List: + id: AIFunc List + description: '' + translations: {} + Hello World: + id: Hello World + description: '' + translations: {} + Navigator: + id: Navigator + description: '' + translations: {} + help: + id: help + description: '' + translations: {} + Help Mode: + id: Help Mode + description: '' + translations: {} + switch help mode at every page: + id: switch help mode at every page + description: '' + translations: {} + Debug Mode: + id: Debug Mode + description: '' + translations: {} + switch debug mode at every page: + id: switch debug mode at every page + description: '' + translations: {} + GhostOS Homepage: + id: GhostOS Homepage + description: '' + translations: {} + App Menu: + id: App Menu + description: '' + translations: {} diff --git a/app/streamlit_main.py b/app/streamlit_main.py index 10fa9ac7..03aa66ca 100644 --- a/app/streamlit_main.py +++ b/app/streamlit_main.py @@ -9,8 +9,9 @@ def bootstrap() -> SINGLETONS: app_dir = dirname(__file__) app_container = make_app_container(app_dir) + # bind container before everything yield Singleton(app_container) - yield Singleton(default_router) + yield Singleton(default_router()) run_ghostos_streamlit_app(bootstrap) diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 63c63e98..8b9c2fa3 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -102,6 +102,7 @@ def default_application_contracts() -> Contracts: from ghostos.framework.eventbuses import EventBus from ghostos.framework.llms import LLMs from ghostos.framework.logger import LoggerItf + from ghostos.framework.translation import Translation from ghostos.core.aifunc import AIFuncExecutor, AIFuncRepository return Contracts([ @@ -116,6 +117,7 @@ def default_application_contracts() -> Contracts: LoggerItf, # the logger instance of application Modules, # the import_module proxy EntityFactory, # wrap and un-wrap Entity class + Translation, # moss MossCompiler, @@ -160,6 +162,7 @@ def default_application_providers( from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.entities import EntityFactoryProvider from ghostos.core.aifunc import DefaultAIFuncExecutorProvider, AIFuncRepoByConfigsProvider + from ghostos.framework.translation import WorkspaceTranslationProvider return [ # --- logger ---# @@ -190,6 +193,7 @@ def default_application_providers( EntityFactoryProvider(), DefaultModulesProvider(), ShutdownProvider(), + WorkspaceTranslationProvider("translations"), # --- aifunc --- # DefaultAIFuncExecutorProvider(), diff --git a/ghostos/contracts/translation.py b/ghostos/contracts/translation.py new file mode 100644 index 00000000..bd2a8ac7 --- /dev/null +++ b/ghostos/contracts/translation.py @@ -0,0 +1,80 @@ +from typing import List, Iterable, Dict +from abc import ABC, abstractmethod +from pydantic import BaseModel, Field + +__all__ = ["Translation", "Translator", "DomainTranslator", "TransItem"] + + +class TransItem(BaseModel): + id: str = Field(description="the target text") + description: str = Field(default="", description="the description") + translations: Dict[str, str] = Field( + default_factory=dict, + description="the translations from lang to value" + ) + + def gettext(self, lang: str, **kwargs: str) -> str: + if lang in self.translations: + template = self.translations[lang] + return template.format(**kwargs) + # fallback + return self.id + + +class Translator(ABC): + """ + for i18n or l10n translation + """ + + @abstractmethod + def domain(self) -> str: + pass + + @abstractmethod + def default_lang(self) -> str: + pass + + @abstractmethod + def gettext(self, message: str, lang: str = "", **kwargs: str) -> str: + pass + + +class DomainTranslator(ABC): + @abstractmethod + def domain(self) -> str: + pass + + @abstractmethod + def langs(self) -> List[str]: + pass + + @abstractmethod + def default_lang(self) -> str: + pass + + @abstractmethod + def get_translator(self, lang: str = "") -> Translator: + pass + + @abstractmethod + def update(self, lang: str, text: str, value: str): + pass + + @abstractmethod + def save(self) -> None: + pass + + @abstractmethod + def items(self) -> Iterable[TransItem]: + pass + + +class Translation(ABC): + """ + i18n or l10n translation, can update from user interface + todo: use gettext + """ + + @abstractmethod + def get_domain(self, domain: str) -> DomainTranslator: + pass diff --git a/ghostos/contracts/workspace.py b/ghostos/contracts/workspace.py index 69d8b591..54e2cd1f 100644 --- a/ghostos/contracts/workspace.py +++ b/ghostos/contracts/workspace.py @@ -14,6 +14,10 @@ def root(self) -> FileStorage: """ pass + @abstractmethod + def assets(self) -> FileStorage: + pass + @abstractmethod def runtime(self) -> FileStorage: """ diff --git a/ghostos/framework/translation/__init__.py b/ghostos/framework/translation/__init__.py new file mode 100644 index 00000000..7ed2135b --- /dev/null +++ b/ghostos/framework/translation/__init__.py @@ -0,0 +1,2 @@ +from ghostos.contracts.translation import Translation +from ghostos.framework.translation.dict_impl import WorkspaceTranslationProvider diff --git a/ghostos/framework/translation/dict_impl.py b/ghostos/framework/translation/dict_impl.py new file mode 100644 index 00000000..cb9b5a1f --- /dev/null +++ b/ghostos/framework/translation/dict_impl.py @@ -0,0 +1,120 @@ +from typing import Dict, List, Optional, Iterable +from abc import ABC, abstractmethod +from ghostos.contracts.translation import Translator, Translation, DomainTranslator, TransItem +from ghostos.contracts.storage import FileStorage +from ghostos.contracts.workspace import Workspace +from ghostos.container import Provider, Container, INSTANCE +from ghostos.helpers import yaml_pretty_dump, generate_import_path +from pydantic import BaseModel, Field +import yaml + + +class DomainTranslationData(BaseModel): + domain: str = Field(description="the target domain") + langs: List[str] = Field(default_factory=lambda: ["zh", "en"], description="the target langs") + default_lang: str = "en" + items: Dict[str, TransItem] = Field(default_factory=dict) + + +class BasicDomainTranslator(DomainTranslator, Translator, ABC): + + def __init__(self, data: DomainTranslationData): + self.data = data + + def gettext(self, message: str, lang: str = "", **kwargs: str) -> str: + item = self.get_item(message) + if not lang: + lang = self.default_lang + return item.gettext(lang, **kwargs) + + def get_item(self, item_id: str) -> TransItem: + if item_id not in self.data.items: + self.data.items[item_id] = TransItem(id=item_id) + self.save() + item = self.data.items.get(item_id) + return item + + def domain(self) -> str: + return self.data.domain + + def langs(self) -> List[str]: + return self.data.langs + + def default_lang(self) -> str: + return self.data.default_lang + + def update(self, lang: str, text: str, value: str): + item = self.data.items.get(text) + if item is None: + item = TransItem(id=text) + item.translations[lang] = value + self.data.items[item.id] = item + self.save() + + +class YamlDomainTranslator(BasicDomainTranslator): + + def __init__(self, storage: FileStorage, domain: str): + self.storage = storage + filename = self.domain_yaml_name(domain) + if not storage.exists(filename): + data = DomainTranslationData(domain=domain) + self.do_save(data) + else: + content = storage.get(filename) + unmarshal = yaml.safe_load(content) + data = DomainTranslationData(**unmarshal) + super().__init__(data) + + def get_translator(self, lang: str = "") -> Translator: + if lang and lang != self.data.default_lang: + self.data.default_lang = lang + self.save() + return self + + @staticmethod + def domain_yaml_name(domain: str) -> str: + return f"{domain}.yml" + + def items(self) -> Iterable[TransItem]: + yield from self.data.items.values() + + def save(self) -> None: + self.do_save(self.data) + + def do_save(self, data_obj: DomainTranslationData) -> None: + data = data_obj.model_dump() + content = yaml_pretty_dump(data) + content = f"# model is {generate_import_path(DomainTranslationData)} \n" + content + filename = f"{data_obj.domain}.yml" + self.storage.put(filename, content.encode()) + + +class YamlAssetTranslation(Translation): + + def __init__(self, asset_storage: FileStorage): + self.asset_storage = asset_storage + self.domain_translators = {} + + def get_domain(self, domain: str) -> DomainTranslator: + if domain not in self.domain_translators: + translator = YamlDomainTranslator(self.asset_storage, domain) + self.domain_translators[domain] = translator + return self.domain_translators[domain] + + +class WorkspaceTranslationProvider(Provider[Translation]): + + def __init__( + self, + translation_dir: str = "translations", + ): + self.translation_dir = translation_dir + + def singleton(self) -> bool: + return True + + def factory(self, con: Container) -> Optional[INSTANCE]: + workspace = con.force_fetch(Workspace) + storage = workspace.assets().sub_storage(self.translation_dir) + return YamlAssetTranslation(storage) diff --git a/ghostos/framework/workspaces/basic.py b/ghostos/framework/workspaces/basic.py index c3aa7160..bc538327 100644 --- a/ghostos/framework/workspaces/basic.py +++ b/ghostos/framework/workspaces/basic.py @@ -11,11 +11,13 @@ def __init__( self, workspace_storage: FileStorage, runtime_path: str = "runtime", - configs_path="configs", + configs_path: str = "configs", + assets_path: str = "asserts", ): self._storage: FileStorage = workspace_storage self._runtime_storage = workspace_storage.sub_storage(runtime_path) self._configs_storage = workspace_storage.sub_storage(configs_path) + self._assets_storage = workspace_storage.sub_storage(assets_path) def root(self) -> FileStorage: return self._storage @@ -23,6 +25,9 @@ def root(self) -> FileStorage: def runtime(self) -> FileStorage: return self._runtime_storage + def assets(self) -> FileStorage: + return self._assets_storage + def configs(self) -> FileStorage: return self._configs_storage @@ -37,6 +42,7 @@ def __init__( workspace_dir: str, runtime_path: str = "runtime", configs_path="configs", + assets_path: str = "asserts", ): """ :param workspace_dir: relative workspace dir to the root path @@ -46,6 +52,7 @@ def __init__( self._root_path = workspace_dir self._runtime_path = runtime_path self._configs_path = configs_path + self._assets_path = assets_path def singleton(self) -> bool: return True @@ -59,4 +66,5 @@ def factory(self, con: Container) -> Optional[INSTANCE]: root_storage, runtime_path=self._runtime_path, configs_path=self._configs_path, + assets_path=self._assets_path, ) diff --git a/ghostos/prototypes/streamlitapp/app.py b/ghostos/prototypes/streamlitapp/app.py index 1d53db7e..d2cae451 100644 --- a/ghostos/prototypes/streamlitapp/app.py +++ b/ghostos/prototypes/streamlitapp/app.py @@ -3,9 +3,8 @@ from ghostos.container import Container from ghostos.prototypes.streamlitapp.utils.session import expect, SingletonContracts, Singleton from ghostos.prototypes.streamlitapp.utils.route import Router -from ghostos.prototypes.streamlitapp.utils.options import BoolOpts -from ghostos.prototypes.streamlitapp.widgets import application_navigator_menu -from gettext import gettext as _ +from ghostos.prototypes.streamlitapp.options import BoolOpts +from ghostos.prototypes.streamlitapp.resources import trans as _ __all__ = [ "SINGLETONS", "BOOTSTRAP", "BOOTSTRAPPED_KEY", diff --git a/ghostos/prototypes/streamlitapp/configs.py b/ghostos/prototypes/streamlitapp/configs.py new file mode 100644 index 00000000..6485d517 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/configs.py @@ -0,0 +1,8 @@ +import streamlit as st +from ghostos.prototypes.streamlitapp.utils.session import ModelSingleton +from pydantic import BaseModel, Field + + +class AppConf(ModelSingleton): + name: str = "GhostOS" + default_lang: str = "en" diff --git a/ghostos/prototypes/streamlitapp/navigation.py b/ghostos/prototypes/streamlitapp/navigation.py index a8355df2..9202f043 100644 --- a/ghostos/prototypes/streamlitapp/navigation.py +++ b/ghostos/prototypes/streamlitapp/navigation.py @@ -1,15 +1,19 @@ from ghostos.prototypes.streamlitapp.utils.route import Route, Router, Link from ghostos.prototypes.streamlitapp.pages import homepage from enum import Enum +from pydantic import Field class PagePath(str, Enum): HOMEPAGE = "ghostos.prototypes.streamlitapp.pages.homepage" + AIFUNCS = "ghostos.prototypes.streamlitapp.pages.aifuncs" def spec(self, attr_name: str): return self.value + ':' + attr_name +# --- home --- # + class Home(Route): link = Link( name="Home", @@ -48,22 +52,39 @@ class Helloworld(Route): ) -default_router = Router( - [ - Home(), - Helloworld(), - Navigator(), - GhostOSHost(), - ], - home=Home.label(), - navigator_names=[ - GhostOSHost.label(), - Helloworld.label(), - ], - default_menu={ - Home.label(): None, - Helloworld.label(): None, - }, - default_sidebar_buttons=[ - ], -) +# --- ai functions --- # + +class AIFuncListRoute(Route): + link = Link( + name="AIFunc List", + import_path=PagePath.AIFUNCS.spec("aifuncs_list"), + streamlit_icon=":material/functions:", + ) + search: str = Field( + default="", + description="search ai functions with keyword", + ) + + +# --- routers --- # + +def default_router() -> Router: + return Router( +[ + Home(), + Helloworld(), + Navigator(), + GhostOSHost(), + AIFuncListRoute(), + ], + home=Home.label(), + navigator_names=[ + GhostOSHost.label(), + AIFuncListRoute.label(), + ], + default_menu={ + Home.label(): None, + }, + default_sidebar_buttons=[ + ], + ) diff --git a/ghostos/prototypes/streamlitapp/utils/options.py b/ghostos/prototypes/streamlitapp/options.py similarity index 100% rename from ghostos/prototypes/streamlitapp/utils/options.py rename to ghostos/prototypes/streamlitapp/options.py diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs.py b/ghostos/prototypes/streamlitapp/pages/aifuncs.py new file mode 100644 index 00000000..4a70ab4c --- /dev/null +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs.py @@ -0,0 +1,9 @@ +import streamlit as st +from ghostos.prototypes.streamlitapp.navigation import AIFuncListRoute +from ghostos.prototypes.streamlitapp.resources import trans as _ + + +def aifuncs_list(): + # bind if route value not bind before + route = AIFuncListRoute().get_or_bind(st.session_state) + st.title(_("AI functions")) diff --git a/ghostos/prototypes/streamlitapp/pages/homepage.py b/ghostos/prototypes/streamlitapp/pages/homepage.py index f7f418c3..31d619fd 100644 --- a/ghostos/prototypes/streamlitapp/pages/homepage.py +++ b/ghostos/prototypes/streamlitapp/pages/homepage.py @@ -1,4 +1,4 @@ -from gettext import gettext as _ +from ghostos.prototypes.streamlitapp.resources import trans as _ def home(): diff --git a/ghostos/prototypes/streamlitapp/resources.py b/ghostos/prototypes/streamlitapp/resources.py index 60912369..74e6d3f8 100644 --- a/ghostos/prototypes/streamlitapp/resources.py +++ b/ghostos/prototypes/streamlitapp/resources.py @@ -1,2 +1,24 @@ import streamlit as st from ghostos.container import Container +from ghostos.contracts.translation import Translation +from ghostos.prototypes.streamlitapp.utils.session import Singleton +from ghostos.prototypes.streamlitapp.configs import AppConf + + +@st.cache_resource +def container() -> Container: + return Singleton.get(Container, st.session_state) + + +@st.cache_resource +def translator(): + conf = AppConf.get_or_bind(st.session_state) + translation = container().force_fetch(Translation) + return translation.get_domain(conf.name).get_translator(conf.default_lang) + + +def trans(text: str, **kwargs) -> str: + """ + i18n trans + """ + return translator().gettext(text, **kwargs) diff --git a/ghostos/prototypes/streamlitapp/utils/route.py b/ghostos/prototypes/streamlitapp/utils/route.py index 8e1e8689..d1614f9b 100644 --- a/ghostos/prototypes/streamlitapp/utils/route.py +++ b/ghostos/prototypes/streamlitapp/utils/route.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field import streamlit as st from pathlib import Path -from gettext import gettext as _ +from ghostos.prototypes.streamlitapp.resources import trans as _ import streamlit_antd_components as sac __all__ = ["Router", 'Route', 'Link'] @@ -145,12 +145,6 @@ def get(cls, session_state: MutableMapping) -> Optional[Self]: return session_state[key] return None - def get_or_bind(self, session_state: MutableMapping) -> Self: - key = self.session_state_key() - if key not in session_state: - session_state[key] = self - return session_state[key] - @classmethod def default(cls) -> Self: return cls() diff --git a/ghostos/prototypes/streamlitapp/utils/session.py b/ghostos/prototypes/streamlitapp/utils/session.py index 3a520827..f4c0f9cd 100644 --- a/ghostos/prototypes/streamlitapp/utils/session.py +++ b/ghostos/prototypes/streamlitapp/utils/session.py @@ -29,7 +29,7 @@ def get(cls, session_state: MutableMapping) -> Optional[Self]: pass @classmethod - def load(cls, session_state: MutableMapping) -> Self: + def get_or_bind(cls, session_state: MutableMapping) -> Self: value = cls.get(session_state) if value is None: default_value = cls.default() @@ -61,9 +61,6 @@ class ModelSingleton(BaseModel, SessionStateValue, ABC): use pydantic.BaseModel to define state value """ - session_key: ClassVar[str] - """Streamlit Session State Key""" - @classmethod def get(cls, session_state: MutableMapping) -> Optional[Self]: """ @@ -71,7 +68,14 @@ def get(cls, session_state: MutableMapping) -> Optional[Self]: :param session_state: the streamlit session state :return: None if not bound yet """ - return session_state.get(cls.session_key, None) + key = cls.session_key() + if key not in session_state: + return None + return session_state.get(key, None) + + @classmethod + def session_key(cls) -> str: + return generate_import_path(cls) @classmethod def default(cls) -> Self: @@ -79,7 +83,8 @@ def default(cls) -> Self: return cls() def bind(self, session_state: MutableMapping) -> None: - session_state[self.session_key] = self + key = self.session_key() + session_state[key] = self T = TypeVar('T') diff --git a/ghostos/prototypes/streamlitapp/widgets.py b/ghostos/prototypes/streamlitapp/widgets.py index 7770f1c0..8bb93ad4 100644 --- a/ghostos/prototypes/streamlitapp/widgets.py +++ b/ghostos/prototypes/streamlitapp/widgets.py @@ -1,5 +1,5 @@ import streamlit as st -from gettext import gettext as _ +from ghostos.prototypes.streamlitapp.resources import trans as _ from ghostos.prototypes.streamlitapp.utils.route import Router from ghostos.prototypes.streamlitapp.utils.session import Singleton From fb64a7525782a4c95a51937350c547d255a2ddc3 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 19 Oct 2024 20:17:27 +0800 Subject: [PATCH 049/148] dev: aifunc related developments --- app/asserts/translations/GhostOS.yml | 55 ------- .../docs/ghostos/en/aifunc_introduction.md | 2 + .../docs/ghostos/zh/aifunc/introduction.md | 4 + .../docs/ghostos/zh/aifunc/request_info.md | 1 + .../docs/ghostos/zh/aifunc/usage_example.md | 46 ++++++ app/configs/documents_registry.yml | 5 + app/configs/registered_aifunc.yml | 20 ++- app/configs/streamlit_app.yml | 8 + app/streamlit_main.py | 3 +- ghostos/bootstrap.py | 11 +- ghostos/common.py | 10 ++ ghostos/contracts/configs.py | 6 +- ghostos/contracts/documents.py | 61 ++++++++ ghostos/contracts/modules.py | 12 +- ghostos/contracts/translation.py | 2 + ghostos/core/aifunc/__init__.py | 1 + ghostos/core/aifunc/func.py | 1 + ghostos/core/aifunc/repository.py | 19 ++- ghostos/core/messages/__init__.py | 2 + ghostos/demo/aifuncs/baseline.py | 32 ---- ghostos/framework/configs/basic.py | 11 ++ ghostos/framework/configs/memimpl.py | 3 + ghostos/framework/configs/storageimpl.py | 3 + ghostos/framework/documents/__init__.py | 2 + ghostos/framework/documents/storage_impl.py | 140 +++++++++++++++++ ghostos/framework/workspaces/basic.py | 4 +- ghostos/helpers/__init__.py | 11 +- ghostos/helpers/coding.py | 7 + ghostos/helpers/modules.py | 12 +- ghostos/helpers/trans.py | 22 +++ ghostos/prototypes/streamlitapp/configs.py | 8 - .../streamlitapp/{app.py => main.py} | 53 ++++--- ghostos/prototypes/streamlitapp/navigation.py | 31 +++- ghostos/prototypes/streamlitapp/options.py | 47 ------ .../prototypes/streamlitapp/pages/aifuncs.py | 142 +++++++++++++++++- .../prototypes/streamlitapp/pages/homepage.py | 4 +- ghostos/prototypes/streamlitapp/resources.py | 79 ++++++++-- .../prototypes/streamlitapp/utils/route.py | 14 +- ghostos/prototypes/streamlitapp/widgets.py | 24 ++- pyproject.toml | 1 + tests/python/test_pkg.py | 7 + 41 files changed, 712 insertions(+), 214 deletions(-) delete mode 100644 app/asserts/translations/GhostOS.yml create mode 100644 app/assets/docs/ghostos/en/aifunc_introduction.md create mode 100644 app/assets/docs/ghostos/zh/aifunc/introduction.md create mode 100644 app/assets/docs/ghostos/zh/aifunc/request_info.md create mode 100644 app/assets/docs/ghostos/zh/aifunc/usage_example.md create mode 100644 app/configs/documents_registry.yml create mode 100644 app/configs/streamlit_app.yml create mode 100644 ghostos/contracts/documents.py delete mode 100644 ghostos/demo/aifuncs/baseline.py create mode 100644 ghostos/framework/documents/__init__.py create mode 100644 ghostos/framework/documents/storage_impl.py create mode 100644 ghostos/helpers/coding.py create mode 100644 ghostos/helpers/trans.py delete mode 100644 ghostos/prototypes/streamlitapp/configs.py rename ghostos/prototypes/streamlitapp/{app.py => main.py} (64%) delete mode 100644 ghostos/prototypes/streamlitapp/options.py create mode 100644 tests/python/test_pkg.py diff --git a/app/asserts/translations/GhostOS.yml b/app/asserts/translations/GhostOS.yml deleted file mode 100644 index dde33d50..00000000 --- a/app/asserts/translations/GhostOS.yml +++ /dev/null @@ -1,55 +0,0 @@ -# model is ghostos.framework.translation.dict_impl:DomainTranslationData -domain: GhostOS -langs: -- zh -- en -default_lang: en -items: - Home: - id: Home - description: '' - translations: {} - GhostOS Host: - id: GhostOS Host - description: '' - translations: {} - AIFunc List: - id: AIFunc List - description: '' - translations: {} - Hello World: - id: Hello World - description: '' - translations: {} - Navigator: - id: Navigator - description: '' - translations: {} - help: - id: help - description: '' - translations: {} - Help Mode: - id: Help Mode - description: '' - translations: {} - switch help mode at every page: - id: switch help mode at every page - description: '' - translations: {} - Debug Mode: - id: Debug Mode - description: '' - translations: {} - switch debug mode at every page: - id: switch debug mode at every page - description: '' - translations: {} - GhostOS Homepage: - id: GhostOS Homepage - description: '' - translations: {} - App Menu: - id: App Menu - description: '' - translations: {} diff --git a/app/assets/docs/ghostos/en/aifunc_introduction.md b/app/assets/docs/ghostos/en/aifunc_introduction.md new file mode 100644 index 00000000..627f52f3 --- /dev/null +++ b/app/assets/docs/ghostos/en/aifunc_introduction.md @@ -0,0 +1,2 @@ + +`AI Func` \ No newline at end of file diff --git a/app/assets/docs/ghostos/zh/aifunc/introduction.md b/app/assets/docs/ghostos/zh/aifunc/introduction.md new file mode 100644 index 00000000..1a7e7e2e --- /dev/null +++ b/app/assets/docs/ghostos/zh/aifunc/introduction.md @@ -0,0 +1,4 @@ + +AI Func 将大语言模型的能力整合到 python 代码中, 将一个继承自 `pydantic.BaseModel` 的类可以作为函数使用, +运行时大模型将看到代码所处的上下文, 在理解代码的基础上, 自行进行多轮思考, 并写出执行代码. +`AI Func` 可以相互嵌套. \ No newline at end of file diff --git a/app/assets/docs/ghostos/zh/aifunc/request_info.md b/app/assets/docs/ghostos/zh/aifunc/request_info.md new file mode 100644 index 00000000..69e5f938 --- /dev/null +++ b/app/assets/docs/ghostos/zh/aifunc/request_info.md @@ -0,0 +1 @@ +AIFunc 使用 `pydantic.BaseModel` 来定义, 是一个强类型的数据结构. \ No newline at end of file diff --git a/app/assets/docs/ghostos/zh/aifunc/usage_example.md b/app/assets/docs/ghostos/zh/aifunc/usage_example.md new file mode 100644 index 00000000..572891ec --- /dev/null +++ b/app/assets/docs/ghostos/zh/aifunc/usage_example.md @@ -0,0 +1,46 @@ +同步调用的例子: + +```python +from ghostos.container import Container +from ghostos.core.aifunc import AIFuncExecutor +from ghostos.demo.aifuncs.weather import WeatherAIFunc, WeatherAIFuncResult + + +# 同步调用. +def call_example(con: Container, req: WeatherAIFunc) -> WeatherAIFuncResult: + ''' + async call an AIFunc and wait for result + ''' + executor = con.force_fetch(AIFuncExecutor) + return executor.execute(req) +``` + +异步调用的例子: + +```python +from ghostos.container import Container +from ghostos.core.aifunc import AIFuncExecutor, ExecFrame +from ghostos.core.messages import new_arr_connection +from ghostos.demo.aifuncs.weather import WeatherAIFunc, WeatherAIFuncResult + + +def stream_call_example(con: Container, req: WeatherAIFunc) -> WeatherAIFuncResult: + ''' + async call an AIFunc and wait for result + ''' + from threading import Thread + + executor = con.force_fetch(AIFuncExecutor) + + stream, receiver = new_arr_connection() + frame = ExecFrame.from_func(req) + t = Thread(target=executor.execute, args=(req, frame, stream)) + t.start() + + with receiver: + for msg in receiver.recv(): + # do something + pass + t.join() + return frame.get_result() +``` \ No newline at end of file diff --git a/app/configs/documents_registry.yml b/app/configs/documents_registry.yml new file mode 100644 index 00000000..c0ca7a8b --- /dev/null +++ b/app/configs/documents_registry.yml @@ -0,0 +1,5 @@ +docs: + - directory: docs/ + domain: ghostos + extension: .md + default_lang: zh \ No newline at end of file diff --git a/app/configs/registered_aifunc.yml b/app/configs/registered_aifunc.yml index 9e26dfee..e250630f 100644 --- a/app/configs/registered_aifunc.yml +++ b/app/configs/registered_aifunc.yml @@ -1 +1,19 @@ -{} \ No newline at end of file +# from class: ghostos.core.aifunc.repository:AIFuncsConf +identifiers: + ghostos.demo.aifuncs.agentic:AgentFn: + description: "\n AIFunc that act like an agent\n " + id: ghostos.demo.aifuncs.agentic:AgentFn + kind: null + name: AgentFn + ghostos.demo.aifuncs.news:NewsAIFunc: + description: "\n search news\n " + id: ghostos.demo.aifuncs.news:NewsAIFunc + kind: null + name: NewsAIFunc + ghostos.demo.aifuncs.weather:WeatherAIFunc: + description: "\n tell about weather\n " + id: ghostos.demo.aifuncs.weather:WeatherAIFunc + kind: null + name: WeatherAIFunc +overdue: 3600 +validated_at: 1729316054 diff --git a/app/configs/streamlit_app.yml b/app/configs/streamlit_app.yml new file mode 100644 index 00000000..17784775 --- /dev/null +++ b/app/configs/streamlit_app.yml @@ -0,0 +1,8 @@ +# from class: ghostos.prototypes.streamlitapp.resources:AppConf +bool_options: + DEBUG_MODE: false + HELP_MODE: false +debug_mode: false +domain: ghostos +help_mode: false +lang: zh diff --git a/app/streamlit_main.py b/app/streamlit_main.py index 03aa66ca..23a12c4e 100644 --- a/app/streamlit_main.py +++ b/app/streamlit_main.py @@ -1,4 +1,4 @@ -from ghostos.prototypes.streamlitapp.app import run_ghostos_streamlit_app, SINGLETONS +from ghostos.prototypes.streamlitapp.main import run_ghostos_streamlit_app, SINGLETONS from ghostos.prototypes.streamlitapp.utils.session import Singleton @@ -9,6 +9,7 @@ def bootstrap() -> SINGLETONS: app_dir = dirname(__file__) app_container = make_app_container(app_dir) + # bind container before everything yield Singleton(app_container) yield Singleton(default_router()) diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 8b9c2fa3..8484c0cc 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -2,7 +2,6 @@ from os.path import dirname, join from ghostos.container import Container, Provider, Contracts from ghostos.contracts.logger import config_logging -# from ghostos.providers import default_application_providers, application_contracts from ghostos.prototypes.ghostfunc import init_ghost_func, GhostFunc import dotenv import os @@ -102,7 +101,7 @@ def default_application_contracts() -> Contracts: from ghostos.framework.eventbuses import EventBus from ghostos.framework.llms import LLMs from ghostos.framework.logger import LoggerItf - from ghostos.framework.translation import Translation + from ghostos.framework.documents import DocumentRegistry from ghostos.core.aifunc import AIFuncExecutor, AIFuncRepository return Contracts([ @@ -117,7 +116,8 @@ def default_application_contracts() -> Contracts: LoggerItf, # the logger instance of application Modules, # the import_module proxy EntityFactory, # wrap and un-wrap Entity class - Translation, + + DocumentRegistry, # moss MossCompiler, @@ -162,7 +162,7 @@ def default_application_providers( from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.entities import EntityFactoryProvider from ghostos.core.aifunc import DefaultAIFuncExecutorProvider, AIFuncRepoByConfigsProvider - from ghostos.framework.translation import WorkspaceTranslationProvider + from ghostos.framework.documents import ConfiguredDocumentRegistryProvider return [ # --- logger ---# @@ -177,6 +177,7 @@ def default_application_providers( WorkspaceConfigsProvider(), WorkspaceProcessesProvider(runtime_processes_dir), WorkspaceTasksProvider(runtime_tasks_dir), + ConfiguredDocumentRegistryProvider("documents_registry.yml"), # --- session ---# MsgThreadsRepoByWorkSpaceProvider(runtime_threads_dir), @@ -193,7 +194,7 @@ def default_application_providers( EntityFactoryProvider(), DefaultModulesProvider(), ShutdownProvider(), - WorkspaceTranslationProvider("translations"), + # WorkspaceTranslationProvider("translations"), # --- aifunc --- # DefaultAIFuncExecutorProvider(), diff --git a/ghostos/common.py b/ghostos/common.py index 91064a71..ecfaad7b 100644 --- a/ghostos/common.py +++ b/ghostos/common.py @@ -26,6 +26,16 @@ class Identifier(BaseModel): description: str = Field(default="", description="Description of the object") kind: Optional[str] = Field(default=None, description="Kind of the object") + def match_keyword(self, keyword: str) -> bool: + keyword = keyword.strip() + if not keyword: + return True + return ( + keyword.lower() in self.name.lower() + or keyword.lower() in self.description.lower() + or keyword.lower() in self.id.lower() + ) + class Identifiable(ABC): """ diff --git a/ghostos/contracts/configs.py b/ghostos/contracts/configs.py index 5bae8dc3..7ca79f58 100644 --- a/ghostos/contracts/configs.py +++ b/ghostos/contracts/configs.py @@ -60,6 +60,10 @@ def get(self, conf_type: Type[C], relative_path: Optional[str] = None) -> C: """ pass + @abstractmethod + def get_or_create(self, conf: C) -> C: + pass + @abstractmethod def save(self, conf: Config, relative_path: Optional[str] = None) -> None: """ @@ -98,7 +102,7 @@ def unmarshal(cls, content: str) -> "Config": return cls(**value) def marshal(self) -> bytes: - value = self.model_dump(exclude_defaults=True) + value = self.model_dump(exclude_defaults=False) comment = f"# from class: {generate_import_path(self.__class__)}" result = yaml.safe_dump(value) return "\n".join([comment, result]).encode() diff --git a/ghostos/contracts/documents.py b/ghostos/contracts/documents.py new file mode 100644 index 00000000..e721f9da --- /dev/null +++ b/ghostos/contracts/documents.py @@ -0,0 +1,61 @@ +from typing import List, Iterable +from typing_extensions import Self +from abc import ABC, abstractmethod +from ghostos.common import Identifiable, Identifier + + +class Documents(Identifiable, ABC): + + @abstractmethod + def domain(self) -> str: + pass + + @abstractmethod + def with_lang(self, lang: str) -> Self: + pass + + @abstractmethod + def directory(self) -> str: + pass + + @abstractmethod + def description(self) -> str: + pass + + def identifier(self) -> Identifier: + return Identifier( + id=self.directory(), + name=self.domain(), + description=self.description(), + ) + + @abstractmethod + def default_lang(self) -> str: + pass + + @abstractmethod + def langs(self) -> List[str]: + pass + + @abstractmethod + def read(self, filename: str, locale: str = "") -> str: + pass + + @abstractmethod + def iterate(self, depth: int = -1) -> Iterable[str]: + pass + + +class DocumentRegistry(ABC): + + @abstractmethod + def get_domain(self, domain: str, lang: str = "") -> Documents: + pass + + @abstractmethod + def register(self, domain: Documents) -> None: + pass + + @abstractmethod + def list_domains(self) -> Iterable[Identifier]: + pass diff --git a/ghostos/contracts/modules.py b/ghostos/contracts/modules.py index 4939a9f3..568b7e7c 100644 --- a/ghostos/contracts/modules.py +++ b/ghostos/contracts/modules.py @@ -1,4 +1,4 @@ -from typing import Optional, Type, Union, List, Iterable +from typing import Optional, Type, Union, List, Iterable, Tuple from types import ModuleType from abc import ABC, abstractmethod from importlib import import_module @@ -23,10 +23,10 @@ def import_module(self, modulename) -> ModuleType: pass @abstractmethod - def iter_modules(self, module: Union[str, ModuleType]) -> Iterable[str]: + def iter_modules(self, module: Union[str, ModuleType]) -> Iterable[Tuple[str, bool]]: """ like pkgutil.iter_modules. - :return: module names + :return: Iterable[(module_name, is_package)]. """ pass @@ -60,7 +60,7 @@ class DefaultModules(Modules): def import_module(self, modulename) -> ModuleType: return import_module(modulename) - def iter_modules(self, module: Union[str, ModuleType]) -> Iterable[str]: + def iter_modules(self, module: Union[str, ModuleType]) -> Iterable[Tuple[str, bool]]: if isinstance(module, str): module_type = self.import_module(module) elif isinstance(module, ModuleType): @@ -68,9 +68,11 @@ def iter_modules(self, module: Union[str, ModuleType]) -> Iterable[str]: else: raise ValueError(f'Invalid module type: {type(module)}') prefix = module_type.__name__ + "." + if not hasattr(module_type, "__path__"): + return [] path = module_type.__path__ for i, name, is_pkg in pkgutil.iter_modules(path, prefix): - yield name + yield name, is_pkg class DefaultModulesProvider(Provider[Modules]): diff --git a/ghostos/contracts/translation.py b/ghostos/contracts/translation.py index bd2a8ac7..b00f1673 100644 --- a/ghostos/contracts/translation.py +++ b/ghostos/contracts/translation.py @@ -4,6 +4,8 @@ __all__ = ["Translation", "Translator", "DomainTranslator", "TransItem"] +# deprecated: use gettext instead + class TransItem(BaseModel): id: str = Field(description="the target text") diff --git a/ghostos/core/aifunc/__init__.py b/ghostos/core/aifunc/__init__.py index abec1111..bf840f2c 100644 --- a/ghostos/core/aifunc/__init__.py +++ b/ghostos/core/aifunc/__init__.py @@ -7,3 +7,4 @@ from ghostos.core.aifunc.func import get_aifunc_result_type from ghostos.core.aifunc.executor import DefaultAIFuncExecutorImpl, DefaultAIFuncExecutorProvider from ghostos.core.aifunc.repository import AIFuncRepoByConfigsProvider, AIFuncRepoByConfigs, AIFuncsConf + diff --git a/ghostos/core/aifunc/func.py b/ghostos/core/aifunc/func.py index 06057494..3091aae7 100644 --- a/ghostos/core/aifunc/func.py +++ b/ghostos/core/aifunc/func.py @@ -82,6 +82,7 @@ def __aifunc_llmapi__(fn: AIFunc, llms: LLMs) -> LLMApi: """ pass +# ---- some helpers ---# def get_aifunc_llmapi(fn: AIFunc, llms: LLMs) -> Optional[LLMApi]: """ diff --git a/ghostos/core/aifunc/repository.py b/ghostos/core/aifunc/repository.py index 6ebbd299..1cfee700 100644 --- a/ghostos/core/aifunc/repository.py +++ b/ghostos/core/aifunc/repository.py @@ -64,21 +64,18 @@ def _save_aifunc_identifier(self, *identifiers: Identifier) -> None: def scan(self, module_name: str, *, recursive: bool, save: bool) -> List[Identifier]: mod = self.modules.import_module(module_name) result: Set[Type[AIFunc]] = set() - self._scan_aifuncs_in_module(mod, result) - if recursive: - for sub_module_name in self.modules.iter_modules(mod): - sub_module = self.modules.import_module(sub_module_name) - self._scan_aifuncs_in_module(sub_module, result) + self._scan_aifuncs_in_module(mod, result, recursive) returns = [] for fn in result: + if fn is AIFunc: + continue identifier = self.identify(fn) returns.append(identifier) if save: self._save_aifunc_identifier(*returns) return returns - @staticmethod - def _scan_aifuncs_in_module(mod: ModuleType, scanned: Set[Type[AIFunc]]) -> None: + def _scan_aifuncs_in_module(self, mod: ModuleType, scanned: Set[Type[AIFunc]], recursive: bool) -> None: """ scan a single module, not recursively """ @@ -88,10 +85,16 @@ def _scan_aifuncs_in_module(mod: ModuleType, scanned: Set[Type[AIFunc]]) -> None value = mod.__dict__[name] if value and inspect.isclass(value) and issubclass(value, AIFunc): scanned.add(value) + for sub_module_name, is_pkg in self.modules.iter_modules(mod): + try: + sub_module = self.modules.import_module(sub_module_name) + self._scan_aifuncs_in_module(sub_module, scanned, recursive) + except Exception: + continue def list(self, offset: int = 0, limit: int = -1) -> Iterable[Identifier]: limit = limit if limit > 0 else len(self.conf.identifiers) - return self.conf.identifiers.values()[offset:offset + limit] + return list(self.conf.identifiers.values())[offset:offset + limit] def validate(self) -> None: identifiers = {} diff --git a/ghostos/core/messages/__init__.py b/ghostos/core/messages/__init__.py index 1fe549c3..e056ecf8 100644 --- a/ghostos/core/messages/__init__.py +++ b/ghostos/core/messages/__init__.py @@ -9,4 +9,6 @@ ) from ghostos.core.messages.buffers import Buffer, Flushed from ghostos.core.messages.helpers import copy_messages +# todo: replace with transport from ghostos.core.messages.stream import Stream, Receiver, Received +from ghostos.core.messages.transport import new_arr_connection diff --git a/ghostos/demo/aifuncs/baseline.py b/ghostos/demo/aifuncs/baseline.py deleted file mode 100644 index 2e86fb62..00000000 --- a/ghostos/demo/aifuncs/baseline.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Optional -from ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx -from ghostos.core.moss import Moss as Parent -from pydantic import Field - - -class AgentFunc(AIFunc): - """ - agent func that act like an agent - """ - pass - - -class AgentFuncResult(AIFuncResult): - """ - the result that follow the agent func instruction - """ - result: str = Field(description="response from the agent func") - err: Optional[str] = Field(default=None, description="error message") - - -class Moss(Parent): - ai_func_ctx: AIFuncCtx - """useful to run AIFunc""" - - -# - - -baseline_case = AgentFunc() - -# diff --git a/ghostos/framework/configs/basic.py b/ghostos/framework/configs/basic.py index cb243c8d..1d785651 100644 --- a/ghostos/framework/configs/basic.py +++ b/ghostos/framework/configs/basic.py @@ -14,6 +14,13 @@ def get(self, conf_type: Type[C], relative_path: Optional[str] = None) -> C: content = self._get(relative_path) return conf_type.unmarshal(content) + def get_or_create(self, conf: C) -> C: + path = conf.conf_path() + if not self._exists(path): + self._put(path, conf.marshal()) + return conf + return self.get(type(conf)) + @abstractmethod def _get(self, relative_path: str) -> bytes: pass @@ -22,6 +29,10 @@ def _get(self, relative_path: str) -> bytes: def _put(self, relative_path: str, content: bytes) -> None: pass + @abstractmethod + def _exists(self, relative_path: str) -> bool: + pass + def save(self, conf: Config, relative_path: Optional[str] = None) -> None: marshaled = conf.marshal() relative_path = relative_path if relative_path else conf.conf_path() diff --git a/ghostos/framework/configs/memimpl.py b/ghostos/framework/configs/memimpl.py index 3e1bd509..867e8592 100644 --- a/ghostos/framework/configs/memimpl.py +++ b/ghostos/framework/configs/memimpl.py @@ -15,3 +15,6 @@ def _get(self, relative_path: str) -> bytes: def _put(self, relative_path: str, content: bytes) -> None: self._cache[relative_path] = content + + def _exists(self, relative_path: str) -> bool: + return relative_path in self._cache diff --git a/ghostos/framework/configs/storageimpl.py b/ghostos/framework/configs/storageimpl.py index 74ddbc7f..f54cb1df 100644 --- a/ghostos/framework/configs/storageimpl.py +++ b/ghostos/framework/configs/storageimpl.py @@ -17,6 +17,9 @@ def _get(self, relative_path: str) -> bytes: def _put(self, relative_path: str, content: bytes) -> None: self._storage.put(relative_path, content) + def _exists(self, relative_path: str) -> bool: + return self._storage.exists(relative_path) + class ConfigsByStorageProvider(Provider[Configs]): diff --git a/ghostos/framework/documents/__init__.py b/ghostos/framework/documents/__init__.py new file mode 100644 index 00000000..847cf4d5 --- /dev/null +++ b/ghostos/framework/documents/__init__.py @@ -0,0 +1,2 @@ +from ghostos.contracts.documents import DocumentRegistry +from .storage_impl import ConfiguredDocumentRegistryProvider diff --git a/ghostos/framework/documents/storage_impl.py b/ghostos/framework/documents/storage_impl.py new file mode 100644 index 00000000..c554deb6 --- /dev/null +++ b/ghostos/framework/documents/storage_impl.py @@ -0,0 +1,140 @@ +from typing import Iterable, List, Dict, Optional +from typing_extensions import Self + +from ghostos.common import Identifier +from ghostos.contracts.storage import FileStorage +from ghostos.helpers import get_current_locale +from ghostos.contracts.documents import Documents, DocumentRegistry +from ghostos.container import Provider, Container +from ghostos.contracts.configs import Configs, YamlConfig +from ghostos.contracts.workspace import Workspace +from pydantic import BaseModel, Field +from os.path import join + + +class StorageDocuments(Documents): + + def __init__( + self, + storage: FileStorage, + *, + domain: str, + description: str, + default_lang: str, + ext: str, + lang: str = "", + ): + self._domain = domain + self._storage = storage + self._description = description + self._default_lang = default_lang + self._ext = ext + self._lang = lang or default_lang + + def with_lang(self, lang: str) -> Self: + return StorageDocuments( + self._storage, + domain=self._domain, + description=self._description, + default_lang=self._default_lang, + ext=self._ext, + lang=lang, + ) + + def domain(self) -> str: + return self._domain + + def directory(self) -> str: + return self._storage.abspath() + + def description(self) -> str: + return self._description + + def default_lang(self) -> str: + return self._default_lang + + def langs(self) -> List[str]: + # todo + raise NotImplemented("todo") + + def make_path(self, locale: str, filename: str) -> str: + return join(self.domain(), locale, filename + self._ext) + + def read(self, filename: str, lang: str = "") -> str: + if not lang: + lang = self._default_lang + return self._read(lang, filename) + + def _read(self, locale: str, filename: str) -> str: + path = self.make_path(locale, filename) + if not self._storage.exists(path): + path = self.make_path(self.default_lang(), filename) + content = self._storage.get(path) + return content.decode('utf-8') + + def iterate(self, depth: int = -1) -> Iterable[str]: + raise NotImplemented("todo") + + +class StorageDocumentsRegistry(DocumentRegistry): + + def __init__(self): + self._documents: Dict[str, Documents] = {} + + def get_domain(self, domain: str, lang: str = "") -> Documents: + if domain in self._documents: + docs = self._documents[domain] + return docs.with_lang(lang) + raise FileNotFoundError(f"documents domain not found: {domain}") + + def register(self, domain: Documents) -> None: + self._documents[domain.domain()] = domain + + def list_domains(self) -> List[Identifier]: + for domain in self._documents.values(): + yield domain.identifier() + + +class StorageDocumentsConfig(YamlConfig): + relative_path = "documents_registry.yml" + + class DocConf(BaseModel): + directory: str = Field(description="sub directory to the assets directory") + domain: str = Field(description="Domain name") + extension: str = Field(description="File extension") + default_lang: str = Field(description="Default locale language name") + description: str = Field(default="", description="Description") + + docs: List[DocConf] = Field(default_factory=list) + + +class ConfiguredDocumentRegistryProvider(Provider[DocumentRegistry]): + + def __init__(self, config_file: str = "documents_registry.yml"): + self._config_file = config_file + + def singleton(self) -> bool: + return True + + def factory(self, con: Container) -> Optional[DocumentRegistry]: + class Conf(StorageDocumentsConfig): + relative_path = self._config_file + + configs = con.force_fetch(Configs) + conf = configs.get_or_create(Conf()) + + workspace = con.force_fetch(Workspace) + assets = workspace.assets() + + registry = StorageDocumentsRegistry() + + for c in conf.docs: + doc = StorageDocuments( + assets.sub_storage(c.directory), + domain=c.domain, + description=c.description, + default_lang=c.default_lang, + ext=c.extension, + ) + registry.register(doc) + return registry diff --git a/ghostos/framework/workspaces/basic.py b/ghostos/framework/workspaces/basic.py index bc538327..aaab9ae4 100644 --- a/ghostos/framework/workspaces/basic.py +++ b/ghostos/framework/workspaces/basic.py @@ -12,7 +12,7 @@ def __init__( workspace_storage: FileStorage, runtime_path: str = "runtime", configs_path: str = "configs", - assets_path: str = "asserts", + assets_path: str = "assets", ): self._storage: FileStorage = workspace_storage self._runtime_storage = workspace_storage.sub_storage(runtime_path) @@ -42,7 +42,7 @@ def __init__( workspace_dir: str, runtime_path: str = "runtime", configs_path="configs", - assets_path: str = "asserts", + assets_path: str = "assets", ): """ :param workspace_dir: relative workspace dir to the root path diff --git a/ghostos/helpers/__init__.py b/ghostos/helpers/__init__.py index 5b7789e0..ac87b586 100644 --- a/ghostos/helpers/__init__.py +++ b/ghostos/helpers/__init__.py @@ -1,8 +1,10 @@ +from typing import TYPE_CHECKING from ghostos.helpers.dictionary import (dict_without_none, dict_without_zero) from ghostos.helpers.string import camel_to_snake from ghostos.helpers.yaml import yaml_pretty_dump, yaml_multiline_string_pipe from ghostos.helpers.modules import ( import_from_path, + import_class_from_path, parse_import_module_and_spec, join_import_module_and_spec, get_module_spec, @@ -17,8 +19,12 @@ from ghostos.helpers.io import BufferPrint from ghostos.helpers.time import Timeleft from ghostos.helpers.hashes import md5 +from ghostos.helpers.trans import gettext, ngettext, get_current_locale, GHOSTOS_DOMAIN -from typing import Callable +from ghostos.helpers.coding import reflect_module_code + +if TYPE_CHECKING: + from typing import Callable # --- private methods --- # @@ -29,6 +35,5 @@ def __uuid() -> str: # --- facade --- # -uuid: Callable[[], str] = __uuid +uuid: "Callable[[], str]" = __uuid """ patch this method to change global uuid generator""" - diff --git a/ghostos/helpers/coding.py b/ghostos/helpers/coding.py new file mode 100644 index 00000000..6f7c3638 --- /dev/null +++ b/ghostos/helpers/coding.py @@ -0,0 +1,7 @@ +from types import ModuleType +import inspect + + +def reflect_module_code(module: ModuleType) -> str: + with open(module.__file__) as f: + return f.read() diff --git a/ghostos/helpers/modules.py b/ghostos/helpers/modules.py index 54350cc6..816cf64d 100644 --- a/ghostos/helpers/modules.py +++ b/ghostos/helpers/modules.py @@ -1,10 +1,11 @@ import inspect -from typing import Any, Tuple, Optional, Dict, Callable, Type +from typing import Any, Tuple, Optional, Dict, Callable, Type, TypeVar from types import ModuleType __all__ = [ 'Importer', 'import_from_path', + 'import_class_from_path', 'get_calling_modulename', 'get_module_spec', 'generate_import_path', @@ -18,6 +19,15 @@ Importer = Callable[[str], ModuleType] +T = TypeVar('T', bound=type) + + +def import_class_from_path(path: str, parent: Optional[T]) -> T: + imported = import_from_path(path) + if parent and not issubclass(imported, parent): + raise TypeError(f'{path} is not a subclass of {parent}') + return imported + def import_from_path(module_spec: str, importer: Optional[Importer] = None) -> Any: if importer is None: diff --git a/ghostos/helpers/trans.py b/ghostos/helpers/trans.py new file mode 100644 index 00000000..a7ff3f11 --- /dev/null +++ b/ghostos/helpers/trans.py @@ -0,0 +1,22 @@ +from gettext import dgettext, dngettext + +__all__ = ['GHOSTOS_DOMAIN', 'gettext', 'ngettext', 'get_current_locale'] + +GHOSTOS_DOMAIN = 'ghostos' + + +def gettext(message): + return dgettext(GHOSTOS_DOMAIN, message) + + +def ngettext(singular, plural, n): + return dngettext(GHOSTOS_DOMAIN, singular, plural, n) + + +def get_current_locale() -> str: + from babel import default_locale + return default_locale() + + +if __name__ == '__main__': + print(get_current_locale()) \ No newline at end of file diff --git a/ghostos/prototypes/streamlitapp/configs.py b/ghostos/prototypes/streamlitapp/configs.py deleted file mode 100644 index 6485d517..00000000 --- a/ghostos/prototypes/streamlitapp/configs.py +++ /dev/null @@ -1,8 +0,0 @@ -import streamlit as st -from ghostos.prototypes.streamlitapp.utils.session import ModelSingleton -from pydantic import BaseModel, Field - - -class AppConf(ModelSingleton): - name: str = "GhostOS" - default_lang: str = "en" diff --git a/ghostos/prototypes/streamlitapp/app.py b/ghostos/prototypes/streamlitapp/main.py similarity index 64% rename from ghostos/prototypes/streamlitapp/app.py rename to ghostos/prototypes/streamlitapp/main.py index d2cae451..6a4deb2c 100644 --- a/ghostos/prototypes/streamlitapp/app.py +++ b/ghostos/prototypes/streamlitapp/main.py @@ -1,14 +1,17 @@ import streamlit as st from typing import Callable, List -from ghostos.container import Container +from ghostos.container import Container, Contracts +from ghostos.contracts.configs import Configs from ghostos.prototypes.streamlitapp.utils.session import expect, SingletonContracts, Singleton from ghostos.prototypes.streamlitapp.utils.route import Router -from ghostos.prototypes.streamlitapp.options import BoolOpts -from ghostos.prototypes.streamlitapp.resources import trans as _ +from ghostos.prototypes.streamlitapp.resources import AppConf +from ghostos.helpers import gettext as _ __all__ = [ "SINGLETONS", "BOOTSTRAP", "BOOTSTRAPPED_KEY", - "contracts", "validate_container", + "streamlit_contracts", + "container_contracts", + "SingletonContracts", "run_ghostos_streamlit_app", ] @@ -18,29 +21,34 @@ BOOTSTRAPPED_KEY = "ghostos.streamlit.app.bootstrapped" -contracts = SingletonContracts([ +container_contracts = Contracts([ + Configs, +]) + +streamlit_contracts = SingletonContracts([ Container, Router, ]) -def validate_container(container: Container) -> None: - pass +def boot(fn: BOOTSTRAP) -> None: + if expect(st.session_state, BOOTSTRAPPED_KEY, True): + return + singletons = fn() + for s in singletons: + s.bind(st.session_state, force=False) + # validate streamlit bounds + unbound = streamlit_contracts.validate(st.session_state) + if unbound: + error = ",".join([str(c) for c in unbound]) + raise NotImplementedError(f'GhostOS Streamlit app unbound contracts: {error}') -def boot(fn: BOOTSTRAP) -> None: - if not expect(st.session_state, BOOTSTRAPPED_KEY, True): - singletons = fn() - for s in singletons: - s.bind(st.session_state, force=False) - unbound = contracts.validate(st.session_state) - if unbound: - error = ",".join([str(c) for c in unbound]) - raise NotImplementedError(f'GhostOS Streamlit app unbound contracts: {error}') - # validate the container bootstrapped outside. - container = Singleton.get(Container, st.session_state) - validate_container(container) - st.session_state[BOOTSTRAPPED_KEY] = True + # validate container bounds + container = Singleton.get(Container, st.session_state) + container_contracts.validate(container) + # validate the container bootstrapped outside. + st.session_state[BOOTSTRAPPED_KEY] = True def run_ghostos_streamlit_app(bootstrap: BOOTSTRAP) -> None: @@ -72,14 +80,15 @@ def run_ghostos_streamlit_app(bootstrap: BOOTSTRAP) -> None: # use_container_width=True, # ) with st.expander(label="Options", expanded=False, icon=":material/settings:"): - BoolOpts.HELP_MODE.render_toggle( + AppConf.BoolOpts.HELP_MODE.render_toggle( label=_("Help Mode"), tips=_("switch help mode at every page"), ) - BoolOpts.DEBUG_MODE.render_toggle( + AppConf.BoolOpts.DEBUG_MODE.render_toggle( label=_("Debug Mode"), tips=_("switch debug mode at every page"), ) + st.subheader(_("page menu")) # global navigator dialog # if open_navigator: diff --git a/ghostos/prototypes/streamlitapp/navigation.py b/ghostos/prototypes/streamlitapp/navigation.py index 9202f043..07b61123 100644 --- a/ghostos/prototypes/streamlitapp/navigation.py +++ b/ghostos/prototypes/streamlitapp/navigation.py @@ -1,7 +1,11 @@ +from typing import Optional, List from ghostos.prototypes.streamlitapp.utils.route import Route, Router, Link from ghostos.prototypes.streamlitapp.pages import homepage +from ghostos.core.messages import Message +from ghostos.core.aifunc import ExecFrame from enum import Enum from pydantic import Field +from ghostos.entity import EntityMeta class PagePath(str, Enum): @@ -66,16 +70,41 @@ class AIFuncListRoute(Route): ) +class AIFuncDetailRoute(Route): + link = Link( + name="AIFunc Detail", + import_path=PagePath.AIFUNCS.spec("aifunc_detail"), + streamlit_icon=":material/functions:", + ) + aifunc_id: str = Field( + default="", + description="AIFunc ID, which is import path of it", + ) + aifunc_meta: Optional[EntityMeta] = Field( + default=None, + description="aifuncs metadata", + ) + frame: Optional[ExecFrame] = Field( + default=None, + description="current execution frame", + ) + messages: List[Message] = Field( + default_factory=list, + description="list of execution messages", + ) + + # --- routers --- # def default_router() -> Router: return Router( -[ + [ Home(), Helloworld(), Navigator(), GhostOSHost(), AIFuncListRoute(), + AIFuncDetailRoute(), ], home=Home.label(), navigator_names=[ diff --git a/ghostos/prototypes/streamlitapp/options.py b/ghostos/prototypes/streamlitapp/options.py deleted file mode 100644 index 7c319731..00000000 --- a/ghostos/prototypes/streamlitapp/options.py +++ /dev/null @@ -1,47 +0,0 @@ -import streamlit as st -from typing import Optional -from enum import Enum - - -class BoolOpts(str, Enum): - - HELP_MODE = "ghostos.streamlit.app.help_mode" - """global help mode""" - - DEBUG_MODE = "ghostos.streamlit.app.debug_mode" - - def set(self, val: bool) -> None: - st.session_state[self.value] = val - - def get(self) -> bool: - key = self.value - return key in st.session_state and st.session_state[self.value] is True - - def toggle(self) -> None: - st.session_state[self.value] = not self.get() - - def render_toggle( - self, - label: str, *, - tips: Optional[str] = None, - disabled: bool = False, - ) -> None: - st.toggle( - label, - key=self.value, - disabled=disabled, - help=tips, - ) - - def render_button( - self, - label: str, *, - tips: Optional[str] = None, - disabled: bool = False, - ) -> bool: - return st.button( - label, - key=self.value, - disabled=disabled, - help=tips, - ) diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs.py b/ghostos/prototypes/streamlitapp/pages/aifuncs.py index 4a70ab4c..bf66ee56 100644 --- a/ghostos/prototypes/streamlitapp/pages/aifuncs.py +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs.py @@ -1,9 +1,145 @@ +import inspect +from typing import List, Iterable import streamlit as st -from ghostos.prototypes.streamlitapp.navigation import AIFuncListRoute -from ghostos.prototypes.streamlitapp.resources import trans as _ +from ghostos.prototypes.streamlitapp.navigation import AIFuncListRoute, AIFuncDetailRoute +from ghostos.prototypes.streamlitapp.resources import ( + get_container, + get_app_conf, + get_app_docs, +) +from ghostos.prototypes.streamlitapp.widgets import open_code_dialog, help_document, markdown_document +from ghostos.core.aifunc import AIFuncRepository, AIFunc, get_aifunc_result_type +from ghostos.core.aifunc import func, interfaces +from ghostos.common import Identifier, identify_class +from ghostos.helpers import ( + gettext as _, + reflect_module_code, + import_from_path, + import_class_from_path, generate_import_path, + parse_import_module_and_spec, +) +import inspect +import webbrowser + + +def render_aifuncs(items: Iterable[Identifier], keyword: str = "") -> None: + for idt in items: + if not idt.match_keyword(keyword): + continue + with st.container(border=True): + st.subheader(idt.name) + st.caption(idt.id) + st.markdown(idt.description) + key = idt.id + ":run" + if st.button("run", key=key): + route = AIFuncDetailRoute( + aifunc_id=idt.id, + ) + route.switch_page() def aifuncs_list(): # bind if route value not bind before route = AIFuncListRoute().get_or_bind(st.session_state) - st.title(_("AI functions")) + app_conf = get_app_conf() + # render title and description. + st.title(_("AI Functions")) + with st.expander(_("introduce"), expanded=app_conf.BoolOpts.HELP_MODE.get()): + doc = get_app_docs().read("aifunc/introduction") + st.markdown(doc) + + code_pattern = "AIFunc Code Pattern" + contracts_pattern = "AIFunc Interfaces" + + if st.button(code_pattern): + file_code = reflect_module_code(func) + open_code_dialog(code_pattern, file_code) + if st.button(contracts_pattern): + file_code = reflect_module_code(interfaces) + open_code_dialog(contracts_pattern, file_code) + + # scan + repo = get_container().force_fetch(AIFuncRepository) + with st.expander(_("tools"), expanded=app_conf.BoolOpts.DEBUG_MODE.get()): + do_scan = st.text_input( + label=_("scan ai funcs"), + value="", + placeholder=_("input python module name, scan all the funcs recursively"), + ) + + if do_scan: + found = repo.scan(do_scan, recursive=True, save=True) + st.write(f"Found {len(found)} AIFuncs") + render_aifuncs(found) + else: + funcs = list(repo.list()) + col1, col2 = st.columns([2, 1]) + with col2: + keyword = st.text_input( + label=_("filter by keyword"), + help=_("filter the funcs list by keyword"), + value=route.search, + ) + render_aifuncs(funcs, keyword) + + +def aifunc_detail(): + route = AIFuncDetailRoute().get_or_bind(st.session_state) + with st.sidebar: + AIFuncListRoute().render_page_link(use_container_width=True) + + if not route.aifunc_id: + st.error("No AI Functions found") + return + try: + fn = import_class_from_path(route.aifunc_id, AIFunc) + except TypeError as e: + st.error(e) + return + + idt = identify_class(fn) + + # 渲染全局信息. + st.title(idt.name) + st.caption(idt.id) + st.markdown(idt.description) + + tab_exec, tab_source = st.tabs([_("Execute AIFuncs"), _("Source Code")]) + with tab_exec: + st.write("hello") + + with tab_source: + # prepare + module_name, attr_name = parse_import_module_and_spec(idt.id) + mod = import_from_path(module_name) + result_type = get_aifunc_result_type(fn) + + # open source code + if st.button("Open The Source File"): + webbrowser.open(f"file://{mod.__file__}") + + # func code panel + st.subheader(_("Func Request")) + st.caption(idt.id) + source = inspect.getsource(fn) + st.code(source, line_numbers=True, wrap_lines=True) + help_document("aifunc/request_info") + st.divider() + + # result code panel + st.subheader(_("Func Result")) + st.caption(generate_import_path(result_type)) + source = inspect.getsource(result_type) + st.code(source, line_numbers=True, wrap_lines=True) + st.divider() + + # run + st.subheader(_("Usage Example")) + markdown_document("aifunc/usage_example") + + # full context + st.subheader(_("AIFunc Full Context")) + with st.expander(module_name): + source = inspect.getsource(mod) + st.code(source, line_numbers=True, wrap_lines=True) + st.divider() diff --git a/ghostos/prototypes/streamlitapp/pages/homepage.py b/ghostos/prototypes/streamlitapp/pages/homepage.py index 31d619fd..bb884fcd 100644 --- a/ghostos/prototypes/streamlitapp/pages/homepage.py +++ b/ghostos/prototypes/streamlitapp/pages/homepage.py @@ -1,4 +1,4 @@ -from ghostos.prototypes.streamlitapp.resources import trans as _ +from ghostos.helpers import gettext as _, get_current_locale def home(): @@ -6,7 +6,7 @@ def home(): from ghostos.prototypes.streamlitapp.widgets import application_navigator_menu st.title(_("GhostOS Homepage")) - with st.expander(_("App Menu"), expanded=False): + with st.expander(_("App Menu"), expanded=True): application_navigator_menu() diff --git a/ghostos/prototypes/streamlitapp/resources.py b/ghostos/prototypes/streamlitapp/resources.py index 74e6d3f8..3baa6397 100644 --- a/ghostos/prototypes/streamlitapp/resources.py +++ b/ghostos/prototypes/streamlitapp/resources.py @@ -1,24 +1,79 @@ +from typing import Optional, Dict + +from enum import Enum +from pydantic import Field import streamlit as st from ghostos.container import Container -from ghostos.contracts.translation import Translation from ghostos.prototypes.streamlitapp.utils.session import Singleton -from ghostos.prototypes.streamlitapp.configs import AppConf +from ghostos.contracts.configs import YamlConfig, Configs +from ghostos.contracts.documents import DocumentRegistry, Documents +from ghostos.helpers import GHOSTOS_DOMAIN @st.cache_resource -def container() -> Container: +def get_container() -> Container: return Singleton.get(Container, st.session_state) +class AppConf(YamlConfig): + relative_path = "streamlit_app.yml" + + domain: str = GHOSTOS_DOMAIN + lang: str = Field("zh", description="lang of the app") + help_mode: bool = False + debug_mode: bool = False + + bool_options: Dict[str, bool] = Field( + default_factory=dict, + ) + + class BoolOpts(str, Enum): + HELP_MODE = "ghostos.streamlit.app.help_mode" + """global help mode""" + + DEBUG_MODE = "ghostos.streamlit.app.debug_mode" + + def get(self) -> bool: + return get_app_conf().bool_options.get(self.name, True) + + def render_toggle( + self, + label: str, *, + tips: Optional[str] = None, + disabled: bool = False, + ) -> None: + key = self.value + val = self.get() + + def on_change(): + """ + change the config + """ + value = st.session_state[key] + conf = get_app_conf() + conf.bool_options[self.name] = value + configs = get_container().force_fetch(Configs) + configs.save(conf) + + st.toggle( + label, + key=self.value, + value=val, + disabled=disabled, + help=tips, + on_change=on_change, + ) + + @st.cache_resource -def translator(): - conf = AppConf.get_or_bind(st.session_state) - translation = container().force_fetch(Translation) - return translation.get_domain(conf.name).get_translator(conf.default_lang) +def get_app_conf() -> AppConf: + from ghostos.contracts.configs import Configs + configs = get_container().force_fetch(Configs) + return configs.get(AppConf) -def trans(text: str, **kwargs) -> str: - """ - i18n trans - """ - return translator().gettext(text, **kwargs) +@st.cache_resource +def get_app_docs() -> Documents: + conf = get_app_conf() + registry = get_container().force_fetch(DocumentRegistry) + return registry.get_domain(conf.domain, conf.lang) diff --git a/ghostos/prototypes/streamlitapp/utils/route.py b/ghostos/prototypes/streamlitapp/utils/route.py index d1614f9b..583b3f4a 100644 --- a/ghostos/prototypes/streamlitapp/utils/route.py +++ b/ghostos/prototypes/streamlitapp/utils/route.py @@ -7,8 +7,7 @@ from ghostos.helpers import generate_import_path, import_from_path from pydantic import BaseModel, Field import streamlit as st -from pathlib import Path -from ghostos.prototypes.streamlitapp.resources import trans as _ +from ghostos.helpers import gettext as _ import streamlit_antd_components as sac __all__ = ["Router", 'Route', 'Link'] @@ -36,7 +35,7 @@ def __init__( self.antd_icon = antd_icon self.button_help = button_help self.menu_desc = menu_desc - self.url_path = url_path if url_path else name + self.url_path = url_path if url_path else name.lower().replace(" ", '_') def st_page( self, *, @@ -152,6 +151,15 @@ def default(cls) -> Self: def bind(self, session_state: MutableMapping) -> None: key = self.session_state_key() session_state[key] = self + current = generate_import_path(Route) + session_state[current] = self.label() + + @classmethod + def current_page_label(cls) -> str: + current = generate_import_path(Route) + if current in st.session_state: + return st.session_state[current] + return "" class Router: diff --git a/ghostos/prototypes/streamlitapp/widgets.py b/ghostos/prototypes/streamlitapp/widgets.py index 8bb93ad4..38979b1c 100644 --- a/ghostos/prototypes/streamlitapp/widgets.py +++ b/ghostos/prototypes/streamlitapp/widgets.py @@ -1,12 +1,32 @@ import streamlit as st -from ghostos.prototypes.streamlitapp.resources import trans as _ from ghostos.prototypes.streamlitapp.utils.route import Router from ghostos.prototypes.streamlitapp.utils.session import Singleton +from ghostos.prototypes.streamlitapp.resources import get_app_docs, get_app_conf def application_navigator_menu(): router = Singleton.get(Router, st.session_state) menu = router.default_antd_menu_items() route = router.render_antd_menu(menu) - if route is not None: + if route and route.label() != route.current_page_label(): route.switch_page() + + +@st.dialog(title="Code", width="large") +def open_code_dialog(title: str, code: str): + st.subheader(title) + st.code(code, line_numbers=True, wrap_lines=True) + + +def help_document(doc_name: str, label="help"): + is_helping = get_app_conf().BoolOpts.HELP_MODE.get() + with st.expander(label=label, expanded=is_helping): + doc = get_app_docs().read(doc_name) + st.markdown(doc, unsafe_allow_html=True) + + +def markdown_document(doc_name: str, **kwargs): + doc = get_app_docs().read(doc_name) + if kwargs: + doc = doc.format(**kwargs) + st.markdown(doc, unsafe_allow_html=True) diff --git a/pyproject.toml b/pyproject.toml index ce6ec11d..14d9bee2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ pydantic-settings = "^2.5.2" streamlit-antd-components = "^0.3.2" streamlit-react-jsonschema = "^0.1.3" python-dotenv = "^1.0.1" +babel = "^2.16.0" [tool.poetry.scripts] init = "ghostos.scripts.init:main" diff --git a/tests/python/test_pkg.py b/tests/python/test_pkg.py new file mode 100644 index 00000000..d2481890 --- /dev/null +++ b/tests/python/test_pkg.py @@ -0,0 +1,7 @@ +import pkgutil + + +def test_iter_modules(): + from ghostos.core import ghosts + values = pkgutil.iter_modules(ghosts.__path__, prefix=ghosts.__name__ + '.') + assert len(list(values)) > 1 From 0dff7ac0423cf0c7f309d823716d1742029ae476 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 21 Oct 2024 02:16:41 +0800 Subject: [PATCH 050/148] feat: develop aifunc streamlit pages, in purpose of working through a lot of implementations --- app/configs/llms_conf.yml | 8 +- ghostos/core/aifunc/driver.py | 4 +- ghostos/core/aifunc/executor.py | 54 ++-- ghostos/core/aifunc/interfaces.py | 9 +- ghostos/core/moss/lifecycle.py | 8 +- ghostos/core/moss/pycontext.py | 2 +- ghostos/framework/taskflow/basic.py | 2 +- ghostos/prototypes/ghostfunc/driver.py | 4 +- ghostos/prototypes/streamlitapp/navigation.py | 32 +-- .../prototypes/streamlitapp/pages/__init__.py | 0 .../streamlitapp/pages/aifuncs/__init__.py | 0 .../streamlitapp/pages/aifuncs/detail.py | 245 ++++++++++++++++++ .../pages/{aifuncs.py => aifuncs/index.py} | 87 +------ .../tests/chat_render_by_messages.py | 8 +- .../prototypes/streamlitapp/tests/srj_test.py | 15 ++ .../prototypes/streamlitapp/utils/route.py | 4 + ghostos/prototypes/streamlitapp/widgets.py | 211 ++++++++++++++- tests/core/aifuncs/test_exec_frame.py | 39 +++ 18 files changed, 600 insertions(+), 132 deletions(-) create mode 100644 ghostos/prototypes/streamlitapp/pages/__init__.py create mode 100644 ghostos/prototypes/streamlitapp/pages/aifuncs/__init__.py create mode 100644 ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py rename ghostos/prototypes/streamlitapp/pages/{aifuncs.py => aifuncs/index.py} (50%) create mode 100644 ghostos/prototypes/streamlitapp/tests/srj_test.py create mode 100644 tests/core/aifuncs/test_exec_frame.py diff --git a/app/configs/llms_conf.yml b/app/configs/llms_conf.yml index 1c9aa466..ac8db2f7 100644 --- a/app/configs/llms_conf.yml +++ b/app/configs/llms_conf.yml @@ -17,10 +17,10 @@ services: # proxy: $OPENAI_PROXY # Configure default LLM API here. default: - # service: moonshot - # model: moonshot-v1-32k - service: openai - model: gpt-4o + service: moonshot + model: moonshot-v1-32k +# service: openai +# model: gpt-4o # The models below can be edited as you want, see details: ghostos.core.llms.configs:ModelConf # the key of models is a `llm_api_name`, value is a ModelConf instance. models: diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index b33264f8..9bb03386 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -234,13 +234,13 @@ def think( step.std_output = output if output: output_message = Role.new_assistant_system( - content=f"## Observation\n\nmoss executed main, std output is: \n{output}" + content=f"Observation:\n\nmoss executed main, std output is: \n{output}" ) messages = [output_message] self.on_message(output_message, step, upstream) else: output_message = Role.new_assistant_system( - content=f"## Observation\n\nhave not printed anything" + content=f"Observation:\n\nhave not printed anything" ) messages = [output_message] pycontext = executed.pycontext diff --git a/ghostos/core/aifunc/executor.py b/ghostos/core/aifunc/executor.py index 81b42a3b..f4078415 100644 --- a/ghostos/core/aifunc/executor.py +++ b/ghostos/core/aifunc/executor.py @@ -92,31 +92,35 @@ def execute( frame: Optional[ExecFrame] = None, upstream: Optional[Stream] = None, ) -> AIFuncResult: - if frame is None: - frame = ExecFrame.from_func(fn) - driver = self.get_driver(fn) - thread = driver.initialize(self.container(), frame) - step = 0 - finished = False - result = None - while not finished: - step += 1 - # each step generate a new exec step - exec_step = frame.new_step() - if self._max_step != 0 and step > self._max_step: - raise RuntimeError(f"exceeded max step {self._max_step}") - thread, result, finished = driver.think(self, thread, exec_step, upstream=upstream) - driver.on_save(self.container(), frame, exec_step, thread) - - if finished: - break - if result is not None and not isinstance(result, AIFuncResult): - result_type = get_aifunc_result_type(type(fn)) - raise RuntimeError(f"result is invalid AIFuncResult {type(result)}, expecting {result_type}") - - frame.set_result(result) - # if frame is the root, send final message as protocol - return result + try: + if frame is None: + frame = ExecFrame.from_func(fn) + driver = self.get_driver(fn) + thread = driver.initialize(self.container(), frame) + step = 0 + finished = False + result = None + while not finished: + step += 1 + # each step generate a new exec step + exec_step = frame.new_step() + if self._max_step != 0 and step > self._max_step: + raise RuntimeError(f"exceeded max step {self._max_step}") + thread, result, finished = driver.think(self, thread, exec_step, upstream=upstream) + driver.on_save(self.container(), frame, exec_step, thread) + + if finished: + break + if result is not None and not isinstance(result, AIFuncResult): + result_type = get_aifunc_result_type(type(fn)) + raise RuntimeError(f"result is invalid AIFuncResult {type(result)}, expecting {result_type}") + + frame.set_result(result) + # if frame is the root, send final message as protocol + return result + except Exception as e: + frame.error = DefaultMessageTypes.ERROR.new(content=str(e)) + raise def get_driver( self, diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index 5b2f1605..e50c5faf 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -95,13 +95,19 @@ class ExecStep(BaseModel): step_id: str = Field(default_factory=uuid, description="step id") chat: Optional[Chat] = Field(default=None, description="llm chat") generate: Optional[Message] = Field(default=None, description="AI generate message") - code: str = Field(default="", description="the generated code of the AIFunc") messages: List[Message] = Field(default_factory=list, description="list of messages") std_output: str = Field(default="", description="the std output of the AIFunc step") pycontext: Optional[PyContext] = Field(default=None, description="pycontext of the step") error: Optional[Message] = Field(default=None, description="the error message") frames: List = Field(default_factory=list, description="list of ExecFrame") + def iter_messages(self) -> Iterable[Message]: + if self.generate: + yield self.generate + if self.error: + yield self.error + yield from self.messages + def new_frame(self, fn: AIFunc) -> "ExecFrame": frame = ExecFrame.from_func( fn, @@ -133,6 +139,7 @@ class ExecFrame(BaseModel): result: Optional[EntityMeta] = Field(None, description="AIFunc response, model to entity") depth: int = Field(default=0, description="the depth of the stack") steps: List[ExecStep] = Field(default_factory=list, description="the execution steps") + error: Optional[Message] = Field(default=None, description="the error message") @classmethod def from_func(cls, fn: AIFunc, depth: int = 0, parent_step_id: Optional[str] = None) -> "ExecFrame": diff --git a/ghostos/core/moss/lifecycle.py b/ghostos/core/moss/lifecycle.py index 2c59836d..b8c64fa9 100644 --- a/ghostos/core/moss/lifecycle.py +++ b/ghostos/core/moss/lifecycle.py @@ -127,11 +127,16 @@ def __moss_exec__( """ from typing import Callable from ghostos.core.moss.abc import MossResult + pycontext = runtime.dump_pycontext() + pycontext.execute_code = code + pycontext.executed = False + local_values = runtime.locals() # 注意使用 runtime.exec_ctx 包裹有副作用的调用. with runtime.runtime_ctx(): if code: - compiled = compile(code, filename='', mode='exec') + filename = pycontext.module if pycontext.module is not None else "" + compiled = compile(code, filename=filename, mode='exec') exec(compiled, local_values) if target not in local_values: @@ -168,4 +173,5 @@ def __moss_exec__( returns = target_module_attr std_output = runtime.dump_std_output() pycontext = runtime.dump_pycontext() + pycontext.executed = True return MossResult(returns, std_output, pycontext) diff --git a/ghostos/core/moss/pycontext.py b/ghostos/core/moss/pycontext.py index 452f86e3..e2222c5f 100644 --- a/ghostos/core/moss/pycontext.py +++ b/ghostos/core/moss/pycontext.py @@ -37,7 +37,7 @@ class PyContext(BaseModel): description="在上下文中定义的变量. 会注入到 MOSS 上. 修改后也会保存到 pycontext 里. ", ) - generated: Optional[str] = Field( + execute_code: Optional[str] = Field( default=None, description="the generated python code on this context", ) diff --git a/ghostos/framework/taskflow/basic.py b/ghostos/framework/taskflow/basic.py index 9b965594..4da0e6c1 100644 --- a/ghostos/framework/taskflow/basic.py +++ b/ghostos/framework/taskflow/basic.py @@ -27,7 +27,7 @@ def observe(self, objects: Dict[str, Any], reason: str = "", instruction: str = content = yaml_pretty_dump(values) # 用什么协议没想明白, function ? tool? system ? - content = "# observe values: \n" + content + content = "observe values: \n" + content msg = DefaultMessageTypes.DEFAULT.new_system( content=content, ) diff --git a/ghostos/prototypes/ghostfunc/driver.py b/ghostos/prototypes/ghostfunc/driver.py index f9099257..3c9a4a3f 100644 --- a/ghostos/prototypes/ghostfunc/driver.py +++ b/ghostos/prototypes/ghostfunc/driver.py @@ -140,7 +140,7 @@ def _run(self, thread: MsgThread, args: List[Any], kwargs: Dict[str, Any]) -> An """ # get generated code from history, run it. pycontext = thread.last_turn().pycontext - generated = pycontext.generated + generated = pycontext.execute_code if self._caching and generated and pycontext.executed: thread, result = self._start_with_generated_code(generated, thread, pycontext, args, kwargs) else: @@ -241,7 +241,7 @@ def _run_code( kwargs: Dict[str, Any], ) -> Tuple[Any, bool]: runtime = self._moss_runtime(pycontext) - pycontext.generated = code + pycontext.execute_code = code pycontext.executed = True executed = None try: diff --git a/ghostos/prototypes/streamlitapp/navigation.py b/ghostos/prototypes/streamlitapp/navigation.py index 07b61123..17994578 100644 --- a/ghostos/prototypes/streamlitapp/navigation.py +++ b/ghostos/prototypes/streamlitapp/navigation.py @@ -1,19 +1,17 @@ from typing import Optional, List from ghostos.prototypes.streamlitapp.utils.route import Route, Router, Link -from ghostos.prototypes.streamlitapp.pages import homepage from ghostos.core.messages import Message from ghostos.core.aifunc import ExecFrame from enum import Enum from pydantic import Field -from ghostos.entity import EntityMeta class PagePath(str, Enum): HOMEPAGE = "ghostos.prototypes.streamlitapp.pages.homepage" AIFUNCS = "ghostos.prototypes.streamlitapp.pages.aifuncs" - def spec(self, attr_name: str): - return self.value + ':' + attr_name + def suffix(self, attr_name: str): + return self.value + attr_name # --- home --- # @@ -21,7 +19,7 @@ def spec(self, attr_name: str): class Home(Route): link = Link( name="Home", - import_path=PagePath.HOMEPAGE.spec("home"), + import_path=PagePath.HOMEPAGE.suffix(":home"), streamlit_icon=":material/home:", button_help="help", antd_icon="house-fill", @@ -31,7 +29,7 @@ class Home(Route): class Navigator(Route): link = Link( name="Navigator", - import_path=PagePath.HOMEPAGE.spec("navigator"), + import_path=PagePath.HOMEPAGE.suffix(":navigator"), streamlit_icon=":material/home:", antd_icon="box-fill", ) @@ -40,7 +38,7 @@ class Navigator(Route): class GhostOSHost(Route): link = Link( name="GhostOS Host", - import_path=PagePath.HOMEPAGE.spec("ghostos_host"), + import_path=PagePath.HOMEPAGE.suffix(":ghostos_host"), streamlit_icon=":material/smart_toy:", ) @@ -51,7 +49,7 @@ class Helloworld(Route): """ link = Link( name="Hello World", - import_path=PagePath.HOMEPAGE.spec("helloworld"), + import_path=PagePath.HOMEPAGE.suffix(":helloworld"), streamlit_icon=":material/home:", ) @@ -61,7 +59,7 @@ class Helloworld(Route): class AIFuncListRoute(Route): link = Link( name="AIFunc List", - import_path=PagePath.AIFUNCS.spec("aifuncs_list"), + import_path=PagePath.AIFUNCS.suffix(".index:main"), streamlit_icon=":material/functions:", ) search: str = Field( @@ -73,25 +71,29 @@ class AIFuncListRoute(Route): class AIFuncDetailRoute(Route): link = Link( name="AIFunc Detail", - import_path=PagePath.AIFUNCS.spec("aifunc_detail"), + import_path=PagePath.AIFUNCS.suffix(".detail:main"), streamlit_icon=":material/functions:", ) aifunc_id: str = Field( default="", description="AIFunc ID, which is import path of it", ) - aifunc_meta: Optional[EntityMeta] = Field( - default=None, - description="aifuncs metadata", - ) frame: Optional[ExecFrame] = Field( default=None, description="current execution frame", ) - messages: List[Message] = Field( + executed: bool = False + received: List[Message] = Field( default_factory=list, description="list of execution messages", ) + timeout: float = 40 + exec_idle: float = 0.2 + + def clear_execution(self): + self.executed = False + self.received = [] + self.frame = None # --- routers --- # diff --git a/ghostos/prototypes/streamlitapp/pages/__init__.py b/ghostos/prototypes/streamlitapp/pages/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs/__init__.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py new file mode 100644 index 00000000..f7a7383f --- /dev/null +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py @@ -0,0 +1,245 @@ +import inspect +from typing import List, Iterable, Tuple, Type, Optional +import streamlit as st +import streamlit_react_jsonschema as srj +from ghostos.prototypes.streamlitapp.navigation import AIFuncListRoute, AIFuncDetailRoute +from ghostos.prototypes.streamlitapp.resources import ( + get_container, +) +from ghostos.prototypes.streamlitapp.widgets import ( + help_document, markdown_document, + render_message, + render_exec_frame_tree, + render_pycontext, + flatten_exec_frame_tree, + render_messages, +) +from ghostos.core.messages import new_arr_connection +from ghostos.core.aifunc import ( + AIFunc, + AIFuncExecutor, + get_aifunc_result_type, + ExecFrame, ExecStep, +) +from ghostos.common import Identifier, identify_class +from ghostos.helpers import ( + uuid, + gettext as _, + import_from_path, + import_class_from_path, generate_import_path, + parse_import_module_and_spec, + Timeleft, +) +import inspect +import webbrowser +from threading import Thread + + +def render_sidebar(): + with st.sidebar: + AIFuncListRoute().render_page_link(use_container_width=True) + + +def render_header(fn: Type[AIFunc]) -> Identifier: + idt = identify_class(fn) + # 渲染全局信息. + st.title(idt.name) + st.caption(idt.id) + st.markdown(idt.description) + return idt + + +def render_source(route: AIFuncDetailRoute, fn: Type[AIFunc]): + # prepare + module_name, attr_name = parse_import_module_and_spec(route.aifunc_id) + mod = import_from_path(module_name) + result_type = get_aifunc_result_type(fn) + idt = identify_class(fn) + + # open source code + if st.button("Open The Source File"): + webbrowser.open(f"file://{mod.__file__}") + + # func code panel + st.subheader(_("Func Request")) + st.caption(idt.id) + source = inspect.getsource(fn) + st.code(source, line_numbers=True, wrap_lines=True) + help_document("aifunc/request_info") + st.divider() + + # result code panel + st.subheader(_("Func Result")) + st.caption(generate_import_path(result_type)) + source = inspect.getsource(result_type) + st.code(source, line_numbers=True, wrap_lines=True) + st.divider() + + # run + st.subheader(_("Usage Example")) + markdown_document("aifunc/usage_example") + + # full context + st.subheader(_("AIFunc Full Context")) + with st.expander(module_name): + source = inspect.getsource(mod) + st.code(source, line_numbers=True, wrap_lines=True) + + +def render_aifunc_execute_stream(route: AIFuncDetailRoute, fn: Type[AIFunc]): + route.clear_execution() + # render form + with st.expander(_("Request"), expanded=True): + args, submitted = srj.pydantic_form(fn, key=route.aifunc_id) + if not submitted or not args: + return + if not isinstance(args, AIFunc): + st.error(f"Expected an AIFunc instance, got {type(args)}") + return + + executor = get_container().force_fetch(AIFuncExecutor) + stream, receiver = new_arr_connection(timeout=route.timeout, idle=route.exec_idle, complete_only=True) + frame, caller = executor.new_exec_frame(args, stream) + # save status + route.frame = frame + route.executed = True + route.bind(st.session_state) + + # render + timeleft = Timeleft(route.timeout) + t = Thread(target=caller) + t.start() + with st.status(_("executing...")): + with receiver: + for item in receiver.recv(): + if not item.is_complete(): + continue + route.received.append(item) + render_message(item, debug=False) + st.write(f"executed in {round(timeleft.passed(), 2)} seconds") + + +def render_aifunc_frame_tail(frame: ExecFrame): + if not frame: + return + # error + if frame.error: + st.error(frame.error.get_content()) + + result = frame.get_result() + if result: + with st.expander("Exec Result", expanded=True): + key = "AIFuncResult_" + uuid() + srj.pydantic_instance_form(result, readonly=True, key=key) + + if frame.steps: + st.caption(f"{len(frame.steps)} steps ran") + + with st.expander("origin data", expanded=False): + st.json(frame.model_dump_json(indent=2, exclude_defaults=True)) + + +def render_aifunc_frame_stack(frame: ExecFrame): + if not frame: + return + selected = render_exec_frame_tree(_("Exec Stack"), frame) + # open dialog + if selected: + mapping = flatten_exec_frame_tree(frame) + selected_item = mapping.get(selected, None) + if isinstance(selected_item, ExecFrame): + open_exec_frame_dialog(selected_item) + elif isinstance(selected_item, ExecStep): + open_exec_step_dialog(selected_item) + + +def render_aifunc_executed_frame_head(frame: ExecFrame): + if not frame: + return + args = frame.get_args() + # render form + with st.expander(_("Request"), expanded=True): + key = "AIFuncRequest_" + uuid() + srj.pydantic_instance_form(args, readonly=True, key=key) + + +def render_aifunc_exec_step(step: ExecStep): + if step.pycontext: + render_pycontext(step.pycontext) + + if step.error: + with st.expander(_("Error"), expanded=True): + st.error(step.error.get_content()) + + with st.expander(label=_("History"), expanded=True): + render_messages(step.iter_messages()) + + if step.frames: + st.caption(f"{len(step.frames)} frames called") + + with st.expander("origin data", expanded=False): + st.json(step.model_dump_json(indent=2, exclude_defaults=True)) + + +@st.dialog(title=_("ExecStep"), width="large") +def open_exec_step_dialog(step: ExecStep): + st.subheader(step.func_name()) + st.caption(f"step_id: {step.step_id}") + render_aifunc_exec_step(step) + + +@st.dialog(title=_("ExecFrame"), width="large") +def open_exec_frame_dialog(exec_frame: ExecFrame): + st.subheader(exec_frame.func_name()) + st.caption(f"frame_id: {exec_frame.frame_id}") + render_aifunc_executed_frame_head(exec_frame) + + with st.expander(label=_("History"), expanded=False): + idx = 0 + for step in exec_frame.steps: + st.caption(f"step {idx}") + render_messages(step.iter_messages()) + idx = idx + 1 + + render_aifunc_frame_tail(exec_frame) + + +def main(): + route = AIFuncDetailRoute().get_or_bind(st.session_state) + render_sidebar() + + if not route.aifunc_id: + st.error("No AI Functions found") + return + try: + fn = import_class_from_path(route.aifunc_id, AIFunc) + except TypeError as e: + st.error(e) + return + + # render header + render_header(fn) + + tab_exec, tab_source = st.tabs([_("Execute AIFuncs"), _("Source Code")]) + with tab_exec: + if not route.executed: + render_aifunc_execute_stream(route, fn) + render_aifunc_frame_tail(route.frame) + render_aifunc_frame_stack(route.frame) + elif route.frame: + render_aifunc_executed_frame_head(route.frame) + with st.expander(label=_("messages"), expanded=True): + render_messages(route.received) + + if route.frame: + render_aifunc_frame_tail(route.frame) + render_aifunc_frame_stack(route.frame) + + if route.executed: + if st.button(_("rerun?")): + route.clear_execution() + route.bind(st.session_state) + st.rerun() + + with tab_source: + render_source(route, fn) diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py similarity index 50% rename from ghostos/prototypes/streamlitapp/pages/aifuncs.py rename to ghostos/prototypes/streamlitapp/pages/aifuncs/index.py index bf66ee56..01710364 100644 --- a/ghostos/prototypes/streamlitapp/pages/aifuncs.py +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py @@ -1,25 +1,24 @@ -import inspect -from typing import List, Iterable -import streamlit as st +from typing import Iterable from ghostos.prototypes.streamlitapp.navigation import AIFuncListRoute, AIFuncDetailRoute from ghostos.prototypes.streamlitapp.resources import ( get_container, get_app_conf, get_app_docs, ) -from ghostos.prototypes.streamlitapp.widgets import open_code_dialog, help_document, markdown_document -from ghostos.core.aifunc import AIFuncRepository, AIFunc, get_aifunc_result_type -from ghostos.core.aifunc import func, interfaces -from ghostos.common import Identifier, identify_class +from ghostos.prototypes.streamlitapp.widgets import ( + open_code_dialog +) +from ghostos.core.aifunc import ( + AIFuncRepository +) +import ghostos.core.aifunc.func as func +import ghostos.core.aifunc.interfaces as interfaces +from ghostos.common import Identifier from ghostos.helpers import ( gettext as _, reflect_module_code, - import_from_path, - import_class_from_path, generate_import_path, - parse_import_module_and_spec, ) -import inspect -import webbrowser +import streamlit as st def render_aifuncs(items: Iterable[Identifier], keyword: str = "") -> None: @@ -38,7 +37,7 @@ def render_aifuncs(items: Iterable[Identifier], keyword: str = "") -> None: route.switch_page() -def aifuncs_list(): +def main(): # bind if route value not bind before route = AIFuncListRoute().get_or_bind(st.session_state) app_conf = get_app_conf() @@ -81,65 +80,3 @@ def aifuncs_list(): value=route.search, ) render_aifuncs(funcs, keyword) - - -def aifunc_detail(): - route = AIFuncDetailRoute().get_or_bind(st.session_state) - with st.sidebar: - AIFuncListRoute().render_page_link(use_container_width=True) - - if not route.aifunc_id: - st.error("No AI Functions found") - return - try: - fn = import_class_from_path(route.aifunc_id, AIFunc) - except TypeError as e: - st.error(e) - return - - idt = identify_class(fn) - - # 渲染全局信息. - st.title(idt.name) - st.caption(idt.id) - st.markdown(idt.description) - - tab_exec, tab_source = st.tabs([_("Execute AIFuncs"), _("Source Code")]) - with tab_exec: - st.write("hello") - - with tab_source: - # prepare - module_name, attr_name = parse_import_module_and_spec(idt.id) - mod = import_from_path(module_name) - result_type = get_aifunc_result_type(fn) - - # open source code - if st.button("Open The Source File"): - webbrowser.open(f"file://{mod.__file__}") - - # func code panel - st.subheader(_("Func Request")) - st.caption(idt.id) - source = inspect.getsource(fn) - st.code(source, line_numbers=True, wrap_lines=True) - help_document("aifunc/request_info") - st.divider() - - # result code panel - st.subheader(_("Func Result")) - st.caption(generate_import_path(result_type)) - source = inspect.getsource(result_type) - st.code(source, line_numbers=True, wrap_lines=True) - st.divider() - - # run - st.subheader(_("Usage Example")) - markdown_document("aifunc/usage_example") - - # full context - st.subheader(_("AIFunc Full Context")) - with st.expander(module_name): - source = inspect.getsource(mod) - st.code(source, line_numbers=True, wrap_lines=True) - st.divider() diff --git a/ghostos/prototypes/streamlitapp/tests/chat_render_by_messages.py b/ghostos/prototypes/streamlitapp/tests/chat_render_by_messages.py index 8b874f90..a4c341cd 100644 --- a/ghostos/prototypes/streamlitapp/tests/chat_render_by_messages.py +++ b/ghostos/prototypes/streamlitapp/tests/chat_render_by_messages.py @@ -4,7 +4,7 @@ import numpy as np from pydantic import BaseModel, Field -st.session_state.messages = [] +st.session_state.received = [] chart_data = pd.DataFrame( { @@ -21,18 +21,18 @@ class Item(BaseModel): kwargs: Dict[str, Any] = Field(default_factory=dict) -st.session_state.messages.append(Item( +st.session_state.received.append(Item( method="write", args=["hello world!"], )) -st.session_state.messages.append(Item( +st.session_state.received.append(Item( method="area_chart", args=[chart_data], kwargs=dict(x="col1", y="col2", color="col3"), )) -messages: List[Item] = st.session_state.messages +messages: List[Item] = st.session_state.received for item in messages: with st.chat_message("assistant"): diff --git a/ghostos/prototypes/streamlitapp/tests/srj_test.py b/ghostos/prototypes/streamlitapp/tests/srj_test.py new file mode 100644 index 00000000..8654f49a --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/srj_test.py @@ -0,0 +1,15 @@ +import streamlit as st +import streamlit_react_jsonschema as srj +from pydantic import BaseModel + + +class Foo(BaseModel): + foo: str = "foo" + + +args, submitted = srj.pydantic_instance_form(Foo(), key="hello") + +st.write(submitted) + +if value := st.button("hello"): + st.write(value) diff --git a/ghostos/prototypes/streamlitapp/utils/route.py b/ghostos/prototypes/streamlitapp/utils/route.py index 583b3f4a..6c59d1b9 100644 --- a/ghostos/prototypes/streamlitapp/utils/route.py +++ b/ghostos/prototypes/streamlitapp/utils/route.py @@ -98,6 +98,10 @@ def switch_page(self) -> None: self.bind(st.session_state) self.link.switch_page(url_path=url_path) + def rerun(self) -> None: + self.bind(st.session_state) + st.rerun() + def render_page_link( self, *, disabled: bool = False, diff --git a/ghostos/prototypes/streamlitapp/widgets.py b/ghostos/prototypes/streamlitapp/widgets.py index 38979b1c..e6edb9d3 100644 --- a/ghostos/prototypes/streamlitapp/widgets.py +++ b/ghostos/prototypes/streamlitapp/widgets.py @@ -1,7 +1,13 @@ import streamlit as st +from typing import List, Union, Dict, Iterable, Tuple, Optional +import streamlit_antd_components as sac +from ghostos.core.aifunc import ExecFrame, ExecStep +from ghostos.core.messages import Message, Role, DefaultMessageTypes +from ghostos.core.moss import PyContext from ghostos.prototypes.streamlitapp.utils.route import Router from ghostos.prototypes.streamlitapp.utils.session import Singleton from ghostos.prototypes.streamlitapp.resources import get_app_docs, get_app_conf +from ghostos.helpers import gettext as _ def application_navigator_menu(): @@ -12,7 +18,7 @@ def application_navigator_menu(): route.switch_page() -@st.dialog(title="Code", width="large") +@st.dialog(title=_("Code"), width="large") def open_code_dialog(title: str, code: str): st.subheader(title) st.code(code, line_numbers=True, wrap_lines=True) @@ -30,3 +36,206 @@ def markdown_document(doc_name: str, **kwargs): if kwargs: doc = doc.format(**kwargs) st.markdown(doc, unsafe_allow_html=True) + + +def get_exec_label_bloodline(label: str) -> List[int]: + splits = label.split("|", 2) + if len(splits) == 1: + return [] + return [int(c) for c in splits[1].split("_")] + + +def render_messages(messages: Iterable[Message]): + debug = get_app_conf().BoolOpts.DEBUG_MODE.get() + for msg in messages: + render_message(msg, debug=debug) + + +def render_message(msg: Message, debug: bool): + if not msg.is_complete(): + return + if DefaultMessageTypes.ERROR.match(msg): + with st.chat_message("user"): + st.caption(_("Error")) + st.error(msg.get_content()) + return + if msg.role == Role.ASSISTANT.value: + render_ai_message(msg, debug) + elif msg.role == Role.USER.value: + render_user_message(msg, debug) + elif msg.role == Role.SYSTEM.value: + render_sys_message(msg, debug) + elif msg.role == Role.FUNCTION.value: + render_func_message(msg, debug) + else: + render_other_message(msg, debug) + + +def render_ai_message(msg: Message, debug: bool): + content = msg.content + if not content: + return + replacements = { + "": "\n```python\n", + "": "\n```\n", + "": "\n```python\n", + "": "\n```\n", + } + for key, value in replacements.items(): + content = content.replace(key, value) + + with st.chat_message("ai"): + if msg.type: + st.caption(msg.type) + if msg.name: + st.caption(msg.name) + st.markdown(content, unsafe_allow_html=True) + if debug: + render_msg_debug(msg) + + +def render_msg_debug(msg: Message): + with st.expander(label=_("debug"), expanded=False): + st.json(msg.model_dump_json(exclude_defaults=True, indent=2)) + + +def render_user_message(msg: Message, debug: bool): + content = msg.get_content() + with st.chat_message("user"): + if msg.name: + st.caption(msg.name) + if msg.type: + st.caption(msg.type) + st.markdown(content, unsafe_allow_html=True) + + +def render_sys_message(msg: Message, debug: bool): + content = msg.content + with st.chat_message("user"): + st.caption("system message") + st.markdown(content, unsafe_allow_html=True) + if debug: + render_msg_debug(msg) + + +def render_func_message(msg: Message, debug: bool): + content = msg.content + with st.expander(_("function"), expanded=False): + if msg.name: + st.caption(msg.name) + st.markdown(content, unsafe_allow_html=True) + if debug: + render_msg_debug(msg) + + +def render_other_message(msg: Message, debug: bool): + content = msg.content + with st.expander(_("other"), expanded=False): + if msg.name: + st.caption(msg.name) + st.markdown(content, unsafe_allow_html=True) + if debug: + render_msg_debug(msg) + + +def flatten_exec_frame_tree(frame: ExecFrame) -> Dict[str, Union[ExecFrame, ExecStep]]: + def iter_frame(fr: ExecFrame, bloodline: List[int]) -> Iterable[Tuple[str, Union[ExecFrame, ExecStep]]]: + yield __frame_label(fr, bloodline), fr + idx = 0 + for step in fr.steps: + idx += 1 + next_bloodline = bloodline.copy() + next_bloodline.append(idx) + yield from iter_step(step, next_bloodline) + + def iter_step(step: ExecStep, bloodline: List[int]) -> Iterable[Tuple[str, Union[ExecStep, ExecFrame]]]: + yield __step_label(step, bloodline), step + idx = 0 + for fra in step.frames: + idx += 1 + next_bloodline = bloodline.copy() + next_bloodline.append(idx) + yield from iter_frame(fra, next_bloodline) + + result = {} + for key, value in iter_frame(frame.model_copy(), []): + result[key] = value + return result + + +def render_exec_frame_tree(label: str, frame: ExecFrame): + root = build_exec_frame_tree_node(frame.model_copy(), []) + return sac.tree( + [root], + label=label, + size="lg", + open_all=True, + show_line=True, + ) + + +def build_exec_frame_tree_node(frame: ExecFrame, bloodline: List[int]) -> sac.TreeItem: + children = [] + if len(bloodline) < 20: + steps = frame.steps + idx = 0 + for step in steps: + idx += 1 + next_bloodline = bloodline.copy() + next_bloodline.append(idx) + step_node = build_exec_step_tree_node(step, next_bloodline) + children.append(step_node) + return sac.TreeItem( + label=__frame_label(frame, bloodline), + icon="stack", + tooltip=f"click to see the frame details", + children=children, + ) + + +def build_exec_step_tree_node(step: ExecStep, bloodline: List[int]) -> sac.TreeItem: + children = [] + if len(bloodline) < 20: + idx = 0 + for frame in step.frames: + idx += 1 + next_bloodline = bloodline.copy() + next_bloodline.append(idx) + frame_node = build_exec_frame_tree_node(frame, next_bloodline) + children.append(frame_node) + return sac.TreeItem( + __step_label(step, bloodline), + icon="circle" if len(children) == 0 else "plus-circle", + tooltip=f"click to see the step details", + children=children, + ) + + +def __frame_label(frame: ExecFrame, bloodline: List[int]) -> str: + suffix = "" + if len(bloodline) > 0: + suffix = "__" + "_".join([str(c) for c in bloodline]) + return frame.func_name() + suffix + + +def __step_label(step: ExecStep, bloodline: List[int]) -> str: + suffix = "" + if len(bloodline) > 0: + suffix = "__" + "_".join([str(c) for c in bloodline]) + return step.func_name() + suffix + + +def render_pycontext(pycontext: PyContext): + if not pycontext: + return + st.subheader("PyContext") + if pycontext.module: + st.caption(f"module: {pycontext.module}") + if pycontext.code: + with st.expander(_("Code"), expanded=True): + st.code(pycontext.code) + if pycontext.execute_code: + with st.expander(_("Execute"), expanded=True): + st.code(pycontext.execute_code) + st.write(f"executed: {pycontext.executed}") + st.divider() diff --git a/tests/core/aifuncs/test_exec_frame.py b/tests/core/aifuncs/test_exec_frame.py new file mode 100644 index 00000000..0206b8a9 --- /dev/null +++ b/tests/core/aifuncs/test_exec_frame.py @@ -0,0 +1,39 @@ +from ghostos.core.aifunc import ExecFrame, ExecStep, AIFunc, AIFuncResult +from threading import Thread + + +class Tool(AIFunc): + foo: str = "foo" + + +class ToolResult(AIFuncResult): + err: str = "" + + +def test_exec_frame(): + def next_step(f: ExecFrame, depth: int): + if depth > 3: + return + for i in range(3): + st = f.new_step() + threads = [] + for k in range(3): + sub_frame = st.new_frame(Tool()) + th = Thread(target=next_step, args=(sub_frame, depth + 1)) + th.start() + threads.append(th) + for th in threads: + th.join() + + t = Tool() + fr = ExecFrame.from_func(t) + next_step(fr, 0) + + assert len(fr.steps) == 3 + for step in fr.steps: + assert step.depth == 0 + assert len(step.frames) == 3 + assert fr.depth == 0 + assert fr.steps[0].frames[0].steps[0].frames[0].depth == 2 + + From da064154b5db4f50e49d61e5e0d79e3506dde381 Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 24 Oct 2024 17:49:23 +0800 Subject: [PATCH 051/148] test: test about asyncio --- .../streamlitapp/tests/messages_in_empty.py | 18 ++++++ .../streamlitapp/tests/write_stream_char.py | 15 +++++ tests/python/test_asyncio.py | 56 +++++++++++++++++++ tests/python/test_pydantic.py | 8 +++ 4 files changed, 97 insertions(+) create mode 100644 ghostos/prototypes/streamlitapp/tests/messages_in_empty.py create mode 100644 ghostos/prototypes/streamlitapp/tests/write_stream_char.py create mode 100644 tests/python/test_asyncio.py diff --git a/ghostos/prototypes/streamlitapp/tests/messages_in_empty.py b/ghostos/prototypes/streamlitapp/tests/messages_in_empty.py new file mode 100644 index 00000000..eff95e79 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/messages_in_empty.py @@ -0,0 +1,18 @@ +from typing import Iterable +import streamlit as st +import time + + +def get_content() -> Iterable[str]: + content = "hello world" + for c in content: + yield c + time.sleep(0.05) + + +for i in range(5): + with st.empty(): + with st.chat_message("ai"): + st.write_stream(get_content()) + with st.chat_message("ai"): + st.write("hello world!!") diff --git a/ghostos/prototypes/streamlitapp/tests/write_stream_char.py b/ghostos/prototypes/streamlitapp/tests/write_stream_char.py new file mode 100644 index 00000000..2e1cd6f0 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/write_stream_char.py @@ -0,0 +1,15 @@ +from typing import Iterable +import streamlit as st +import time + + +def get_content() -> Iterable[str]: + content = "hello world" + for c in content: + yield c + time.sleep(0.05) + + +with st.chat_message("ai"): + for c in get_content(): + st.write_stream([c]) diff --git a/tests/python/test_asyncio.py b/tests/python/test_asyncio.py new file mode 100644 index 00000000..5ad91473 --- /dev/null +++ b/tests/python/test_asyncio.py @@ -0,0 +1,56 @@ +import asyncio + + +def test_loop_run_until_complete(): + async def foo(): + return 123 + + loop = asyncio.new_event_loop() + loop.run_until_complete(foo()) + + +def test_gather(): + async def bar(): + await asyncio.sleep(0.1) + # print("bar") + return 123 + + async def baz(): + # print("baz") + return 123 + + async def foo(): + await asyncio.gather(bar(), baz()) + + lp = asyncio.new_event_loop() + lp.run_until_complete(foo()) + + +def test_producer_and_consumer(): + class Main: + stop = False + + got = [] + + async def _producer(self, q: asyncio.Queue): + count = 0 + while not self.stop: + q.put_nowait(count) + count += 1 + await asyncio.sleep(0.1) + + async def _consumer(self, q: asyncio.Queue): + while not self.stop: + v = await q.get() + self.got.append(v) + self.stop = v > 2 + + async def run(self): + async with asyncio.TaskGroup() as tg: + q = asyncio.Queue() + tg.create_task(self._producer(q)) + tg.create_task(self._consumer(q)) + + main = Main() + asyncio.run(main.run()) + assert main.got == [0, 1, 2, 3] diff --git a/tests/python/test_pydantic.py b/tests/python/test_pydantic.py index 5dc0e8d8..56f939e0 100644 --- a/tests/python/test_pydantic.py +++ b/tests/python/test_pydantic.py @@ -138,3 +138,11 @@ class Baz(Bar): bar_data = bar.model_dump(exclude_defaults=True) assert len(bar_data) == 0 assert not hasattr(bar, 'c') + + +def test_bytes_in_model(): + class Foo(BaseModel): + foo: bytes + + f = Foo(foo="test".encode()) + assert f.foo.decode() == "test" From 47c3e47a8037e31128b7308c2766b67feafbca3c Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 24 Oct 2024 23:48:39 +0800 Subject: [PATCH 052/148] dev: use dqueue for transport receiver --- ghostos/core/messages/transport.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index 3b707dba..d14b5c6e 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -1,6 +1,7 @@ from typing import Iterable, Optional, Callable, List, Tuple from typing_extensions import Protocol +from collections import deque from abc import abstractmethod from ghostos.core.messages.message import Message, DefaultMessageTypes from ghostos.core.messages.pipeline import SequencePipe @@ -118,7 +119,7 @@ class ArrayReceiver(Receiver): def __init__(self, alive: Callable[[], bool], idle: float = 0.1, complete_only: bool = False): self._check_alive = alive self._idle = idle - self._chunks: List[Message] = [] + self._chunks = deque() self._closed = False self._done = False self._error: Optional[Message] = None @@ -127,12 +128,11 @@ def __init__(self, alive: Callable[[], bool], idle: float = 0.1, complete_only: def recv(self) -> Iterable[Message]: if self._closed: raise RuntimeError("Receiver is closed") - idx = 0 alive = self._check_alive while not self._done: - if idx < len(self._chunks): - yield self._chunks[idx] - idx += 1 + if len(self._chunks) > 0: + item = self._chunks.popleft() + yield item continue is_alive = alive() if not is_alive: @@ -141,10 +141,9 @@ def recv(self) -> Iterable[Message]: break if self._idle: time.sleep(self._idle) - if idx < len(self._chunks): - while idx < len(self._chunks): - yield self._chunks[idx] - idx += 1 + if len(self._chunks) > 0: + yield from self._chunks + self._chunks = [] if self._error is not None: yield self._error From 4516451b019cfc558f0d7d385c0be34a616ba18f Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 26 Oct 2024 01:17:04 +0800 Subject: [PATCH 053/148] dev: save current work about realtime agent, in case reset --hard HEAD yet again T_T --- ghostos/contracts/logger.py | 61 +----- ghostos/core/llms/__init__.py | 3 +- ghostos/core/llms/chat.py | 85 +------- ghostos/core/llms/tools.py | 88 ++++++++ ghostos/framework/ghosts/basic.py | 4 +- ghostos/framework/logger/fake.py | 5 - ghostos/framework/logger/named.py | 5 +- ghostos/prototypes/realtime/README.md | 16 ++ ghostos/prototypes/realtime/__init__.py | 0 ghostos/prototypes/realtime/abcd.py | 200 ++++++++++++++++++ .../prototypes/realtime/openai/__init__.py | 0 ghostos/prototypes/realtime/openai/agent.py | 38 ++++ .../prototypes/realtime/openai/protocols.py | 39 ++++ ghostos/prototypes/realtime/openai/ws.py | 191 +++++++++++++++++ ghostos/prototypes/realtime/shells.py | 58 +++++ pyproject.toml | 2 + tests/python/test_asyncio.py | 1 + 17 files changed, 645 insertions(+), 151 deletions(-) create mode 100644 ghostos/core/llms/tools.py create mode 100644 ghostos/prototypes/realtime/README.md create mode 100644 ghostos/prototypes/realtime/__init__.py create mode 100644 ghostos/prototypes/realtime/abcd.py create mode 100644 ghostos/prototypes/realtime/openai/__init__.py create mode 100644 ghostos/prototypes/realtime/openai/agent.py create mode 100644 ghostos/prototypes/realtime/openai/protocols.py create mode 100644 ghostos/prototypes/realtime/openai/ws.py create mode 100644 ghostos/prototypes/realtime/shells.py diff --git a/ghostos/contracts/logger.py b/ghostos/contracts/logger.py index dee8bb58..947ad1f4 100644 --- a/ghostos/contracts/logger.py +++ b/ghostos/contracts/logger.py @@ -1,13 +1,12 @@ -from abc import ABC, abstractmethod -from logging import LoggerAdapter, Logger, getLogger +from abc import abstractmethod from logging.config import dictConfig -from typing import Union, Dict +from typing import Protocol import yaml -__all__ = ['LoggerItf', 'LoggerAdapter', 'LoggerType', 'LoggerWrapper', 'config_logging'] +__all__ = ['LoggerItf', 'config_logging'] -class LoggerItf(ABC): +class LoggerItf(Protocol): """ """ @@ -78,13 +77,6 @@ def critical(self, msg, *args, **kwargs): """ pass - @abstractmethod - def fatal(self, msg, *args, **kwargs): - """ - Don't use this method, use critical() instead. - """ - pass - @abstractmethod def log(self, level, msg, *args, **kwargs): """ @@ -97,51 +89,6 @@ def log(self, level, msg, *args, **kwargs): """ pass - @abstractmethod - def with_trace(self, trace: Dict) -> "LoggerItf": - pass - - -LoggerType = Union[LoggerAdapter, Logger] - - -class LoggerWrapper(LoggerItf): - - def __init__(self, logger: LoggerType): - self.logger = logger - - def debug(self, msg, *args, **kwargs): - return self.logger.debug(msg, *args, **kwargs) - - def info(self, msg, *args, **kwargs): - return self.logger.info(msg, *args, **kwargs) - - def warning(self, msg, *args, **kwargs): - return self.logger.warning(msg, *args, **kwargs) - - def error(self, msg, *args, **kwargs): - return self.logger.error(msg, *args, **kwargs) - - def exception(self, msg, *args, exc_info=True, **kwargs): - return self.logger.exception(msg, *args, **kwargs) - - def critical(self, msg, *args, **kwargs): - return self.logger.critical(msg, *args, **kwargs) - - def fatal(self, msg, *args, **kwargs): - return self.logger.fatal(msg, *args, **kwargs) - - def log(self, level, msg, *args, **kwargs): - return self.logger.log(level, msg, *args, **kwargs) - - def with_trace(self, trace: Dict) -> "LoggerItf": - # todo: add trace - return LoggerWrapper(LoggerAdapter(self.logger, extra=dict(trace=trace))) - - -def get_logger(logger_name: str) -> LoggerItf: - return LoggerWrapper(getLogger(logger_name)) - def config_logging(conf_path: str) -> None: """ diff --git a/ghostos/core/llms/__init__.py b/ghostos/core/llms/__init__.py index e3cc6335..503cfe5e 100644 --- a/ghostos/core/llms/__init__.py +++ b/ghostos/core/llms/__init__.py @@ -1,8 +1,9 @@ from __future__ import annotations from ghostos.core.llms.configs import ModelConf, ServiceConf, LLMsConfig, OPENAI_DRIVER_NAME from ghostos.core.llms.llm import LLMs, LLMDriver, LLMApi -from ghostos.core.llms.chat import Chat, ChatPreparer, prepare_chat, LLMTool, FunctionalToken +from ghostos.core.llms.chat import Chat, ChatPreparer, prepare_chat from ghostos.core.llms.embedding import Embeddings, EmbedApi, Embedding +from ghostos.core.llms.tools import LLMTool, FunctionalToken __all__ = [ 'Chat', 'ChatPreparer', 'prepare_chat', diff --git a/ghostos/core/llms/chat.py b/ghostos/core/llms/chat.py index 68c37549..75d3c1e4 100644 --- a/ghostos/core/llms/chat.py +++ b/ghostos/core/llms/chat.py @@ -1,18 +1,17 @@ from __future__ import annotations -from enum import Enum from abc import ABC, abstractmethod -from typing import List, Iterable, Dict, Optional, Union, Callable +from typing import List, Iterable, Optional, Union, Callable from openai.types.chat.completion_create_params import Function, FunctionCall from openai import NotGiven, NOT_GIVEN from openai.types.chat.chat_completion_function_call_option_param import ChatCompletionFunctionCallOptionParam from openai.types.chat.chat_completion_tool_param import ChatCompletionToolParam from pydantic import BaseModel, Field -from ghostos.common import Identifiable, Identifier from ghostos import helpers -from ghostos.core.messages import Message, Role, Caller +from ghostos.core.messages import Message, Role +from .tools import LLMTool, FunctionalToken __all__ = [ 'LLMTool', 'FunctionalToken', @@ -21,84 +20,6 @@ ] -# ---- tool and function ---- # - -class LLMTool(BaseModel): - """ - a common wrapper for JSONSchema LLM tool. - Compatible to OpenAI Tool. - We need this because OpenAI Tool definition is too dynamic, we need strong typehints. - """ - id: Optional[str] = Field(default=None, description="The id of the LLM tool.") - name: str = Field(description="function name") - description: str = Field(default="", description="function description") - parameters: Optional[Dict] = Field(default=None, description="function parameters") - - @classmethod - def new(cls, name: str, desc: Optional[str] = None, parameters: Optional[Dict] = None): - if parameters is None: - parameters = {"type": "object", "properties": {}} - properties = parameters.get("properties", {}) - params_properties = {} - for key in properties: - _property = properties[key] - if "title" in _property: - del _property["title"] - params_properties[key] = _property - parameters["properties"] = params_properties - if "title" in parameters: - del parameters["title"] - return cls(name=name, description=desc, parameters=parameters) - - -class FunctionalTokenMode(str, Enum): - XML = "xml" - """ xml 模式, 使用 包起来的是内容. """ - TOOL = "tool" - """ tool mod, 使用 llm tool 进行封装. """ - TOKEN = "token" - """ token mod. use single token to parse content. """ - - -class FunctionalToken(Identifiable, BaseModel): - """ - functional token means to provide function ability to LLM not by JsonSchema, but by token. - LLM generates special tokens (such as XML marks) to indicate further tokens are the content of the function. - LLMDriver shall define which way to prompt the functional token usage such as xml. - """ - - token: str = Field(description="token that start the function content output") - end_token: str = Field(default="", description="end token that close the function content output") - name: str = Field(description="name of the function") - description: str = Field(default="", description="description of the function") - visible: bool = Field(default=False, description="if the functional token and the parameters are visible to user") - parameters: Optional[Dict] = Field(default=None, description="functional token parameters") - - def new_caller(self, arguments: str) -> "Caller": - """ - generate new caller by functional token, usually used in tests. - """ - return Caller( - name=self.name, - arguments=arguments, - functional_token=True, - ) - - def identifier(self) -> Identifier: - """ - identifier of the functional token. - """ - return Identifier( - name=self.name, - description=self.description, - ) - - def as_tool(self) -> LLMTool: - """ - all functional token are compatible to a llm tool. - """ - return LLMTool.new(name=self.name, desc=self.description, parameters=self.parameters) - # ---- api objects ---- # diff --git a/ghostos/core/llms/tools.py b/ghostos/core/llms/tools.py new file mode 100644 index 00000000..ace0cb95 --- /dev/null +++ b/ghostos/core/llms/tools.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from enum import Enum + +from typing import Dict, Optional + +from pydantic import BaseModel, Field +from ghostos.common import Identifiable, Identifier +from ghostos.core.messages import Caller + + +# ---- tool and function ---- # + +class LLMTool(BaseModel): + """ + a common wrapper for JSONSchema LLM tool. + Compatible to OpenAI Tool. + We need this because OpenAI Tool definition is too dynamic, we need strong typehints. + """ + id: Optional[str] = Field(default=None, description="The id of the LLM tool.") + name: str = Field(description="function name") + description: str = Field(default="", description="function description") + parameters: Optional[Dict] = Field(default=None, description="function parameters") + + @classmethod + def new(cls, name: str, desc: Optional[str] = None, parameters: Optional[Dict] = None): + if parameters is None: + parameters = {"type": "object", "properties": {}} + properties = parameters.get("properties", {}) + params_properties = {} + for key in properties: + _property = properties[key] + if "title" in _property: + del _property["title"] + params_properties[key] = _property + parameters["properties"] = params_properties + if "title" in parameters: + del parameters["title"] + return cls(name=name, description=desc, parameters=parameters) + + +class FunctionalTokenMode(str, Enum): + XML = "xml" + """ xml 模式, 使用 包起来的是内容. """ + TOOL = "tool" + """ tool mod, 使用 llm tool 进行封装. """ + TOKEN = "token" + """ token mod. use single token to parse content. """ + + +class FunctionalToken(Identifiable, BaseModel): + """ + functional token means to provide function ability to LLM not by JsonSchema, but by token. + LLM generates special tokens (such as XML marks) to indicate further tokens are the content of the function. + LLMDriver shall define which way to prompt the functional token usage such as xml. + """ + + token: str = Field(description="token that start the function content output") + end_token: str = Field(default="", description="end token that close the function content output") + name: str = Field(description="name of the function") + description: str = Field(default="", description="description of the function") + visible: bool = Field(default=False, description="if the functional token and the parameters are visible to user") + parameters: Optional[Dict] = Field(default=None, description="functional token parameters") + + def new_caller(self, arguments: str) -> "Caller": + """ + generate new caller by functional token, usually used in tests. + """ + return Caller( + name=self.name, + arguments=arguments, + functional_token=True, + ) + + def identifier(self) -> Identifier: + """ + identifier of the functional token. + """ + return Identifier( + name=self.name, + description=self.description, + ) + + def as_tool(self) -> LLMTool: + """ + all functional token are compatible to a llm tool. + """ + return LLMTool.new(name=self.name, desc=self.description, parameters=self.parameters) diff --git a/ghostos/framework/ghosts/basic.py b/ghostos/framework/ghosts/basic.py index 12a2ae54..97158f71 100644 --- a/ghostos/framework/ghosts/basic.py +++ b/ghostos/framework/ghosts/basic.py @@ -129,9 +129,7 @@ def __init__( logger=logger, ) # prepare ghost logger - trace = self.trace() - ghost_logger = logger.with_trace(trace) - self._logger = ghost_logger + self._logger = logger # 初始化 container 的相关绑定. self._bootstrap_ghost_container() # 检查所有必须绑定的对象. diff --git a/ghostos/framework/logger/fake.py b/ghostos/framework/logger/fake.py index a7feeb11..46b50587 100644 --- a/ghostos/framework/logger/fake.py +++ b/ghostos/framework/logger/fake.py @@ -22,11 +22,6 @@ def exception(self, msg, *args, exc_info=True, **kwargs): def critical(self, msg, *args, **kwargs): pass - def fatal(self, msg, *args, **kwargs): - pass - def log(self, level, msg, *args, **kwargs): pass - def with_trace(self, trace: Dict) -> "LoggerItf": - return self diff --git a/ghostos/framework/logger/named.py b/ghostos/framework/logger/named.py index a978da7a..c02e1b3a 100644 --- a/ghostos/framework/logger/named.py +++ b/ghostos/framework/logger/named.py @@ -27,6 +27,5 @@ def contract(self) -> Type[LoggerItf]: def factory(self, con: Container) -> Optional[LoggerItf]: logging.captureWarnings(True) - origin = logging.getLogger(self.logger_name) - adapter = LoggerWrapper(origin) - return adapter + origin = logging.LoggerAdapter(logging.getLogger(self.logger_name)) + return origin diff --git a/ghostos/prototypes/realtime/README.md b/ghostos/prototypes/realtime/README.md new file mode 100644 index 00000000..f8f2aaad --- /dev/null +++ b/ghostos/prototypes/realtime/README.md @@ -0,0 +1,16 @@ +# test about openai realtime-api + +## abcd + +1. agent is not application itself +2. + + + + +## test production steps + +1. websocket connection. +2. console chatter +3. streamlit chatter: with conversation history +4. \ No newline at end of file diff --git a/ghostos/prototypes/realtime/__init__.py b/ghostos/prototypes/realtime/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/realtime/abcd.py b/ghostos/prototypes/realtime/abcd.py new file mode 100644 index 00000000..613e53c5 --- /dev/null +++ b/ghostos/prototypes/realtime/abcd.py @@ -0,0 +1,200 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import ( + Protocol, Literal, List, ClassVar, Iterable, Tuple, TypeVar, Optional, Dict, Callable, Type, + Union, +) +from enum import Enum +from pydantic import BaseModel +from queue import Queue + + +class Message(Protocol): + msg_id: str + type: str + role: str + seq: Literal["head", "chunk", "complete"] + + +class SessionProtocol(Protocol): + instructions: str + shell_funcs: Dict[str, List[Function]] + + +S = TypeVar("S", bound=SessionProtocol) +M = TypeVar("M", bound=Message) + + +class RealtimeAgent(Protocol[S, M]): + """ + realtime agent in multi-threading programming pattern. + it will develop several threads during runtime to exchange parallel events and actions. + + furthermore this agent programming model allow parallel `body parts` (shells). for examples: + - an OS-based listen-speak channel + - a website that shows the conversation items, and allow to input text message. + - an async channel to call function like multi-agent based-on different models + - a real material body that controlled by a running robot OS + + all the cases are running concurrently in a single agent. + otherwise the realtime api is no more than a block mode chat agent, why the complexities? + + although the shells are parallel running, event sending and receiving are concurrent, + but the agent itself is still stateful, or facing brain split failures. + + the operations to the agent may be: + 1. act immediately + 2. illegal for current state + 3. allowed but pending + 4. allowed but blocking, need retry later + 5. etc... + + the safest way to develop the agent FSM is frame-based model. broadly used in the realtime games. + the agent tick a frame to do an operation or recv an event, if None then idle a while before next tick. + operations are prior to events. + + in the other hand the shell (bodies) has its own state machine in the same way, + but could use success-or-failure pattern, much simpler. + + although the OpenAI realtime session itself is stateful, but it does not keep a long-time session, + so the client side agent implementation need to do all the state work itself, + make sure the session is aligned to server session in the duplex way. + that why we need a full-functional state machine in our agent's implementation. + so the client side agent is the real central state machine in the long-term, + but yield its privilege to server side session during runtime. + """ + + @abstractmethod + def run_util_stop( + self, + session: S, + conversation: Optional[ConversationProtocol] = None, + *shells: Shell, + ) -> None: + pass + + +class ConversationProtocol(Protocol[M]): + + @abstractmethod + def id(self) -> str: + pass + + @abstractmethod + def messages(self) -> Iterable[M]: + pass + + @abstractmethod + def append(self, message: M) -> None: + pass + + @abstractmethod + def save(self): + """ + save to local storage or do nothing. + """ + pass + + +class Function(Protocol): + name: str + description: str + parameters: Dict + + +class Status(str, Enum): + """ + state of the agent. + realtime agent is basically a state machine. + each state has it's policy for operations. + like: + - stopped: means no operation allowed anymore + - connecting: is not ready yet, can not send event or receive message + - available: is ready for most operations. + """ + AVAILABLE = "available" + CONNECTING = "connecting" + STOPPED = "stopped" + + +T = TypeVar("T", bound=Status) + + +class Ghost(Protocol[S, M, T]): + + @abstractmethod + def alive(self) -> bool: + pass + + @abstractmethod + def status(self) -> T: + pass + + @abstractmethod + def operate(self, op: Operator) -> Tuple[str, bool]: + pass + + @abstractmethod + def allow(self, op: Type[Operator]) -> bool: + pass + + @abstractmethod + def session(self) -> S: + pass + + @abstractmethod + def messages(self) -> Iterable[M]: + pass + + +class Shell(Protocol): + id: str + name: str + description: str + + @abstractmethod + def functions(self) -> Iterable[Function]: + pass + + @abstractmethod + def subscribing(self) -> List[str]: + pass + + @abstractmethod + def on_sync(self, ghost: Ghost) -> Queue: + """ + sync the ghost with shell, + and return a channel that the agent publish the subscribed event to the shell. + :param ghost: + :return: Queue[event: dict, None]. None means the agent is stopped. + """ + pass + + +class FunctionCall(Protocol): + id: Optional[str] + name: str + arguments: str + + +class Operator(ABC): + """ + to operate the agent's ghost state machine + is a protocol defined by the agent. + + """ + pass + + +class Stop(Operator): + pass + + +class Reconnect(Operator): + pass + + +class FunctionOutput(BaseModel, Operator): + id: Optional[str] + name: str + content: str diff --git a/ghostos/prototypes/realtime/openai/__init__.py b/ghostos/prototypes/realtime/openai/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/realtime/openai/agent.py b/ghostos/prototypes/realtime/openai/agent.py new file mode 100644 index 00000000..bd46417d --- /dev/null +++ b/ghostos/prototypes/realtime/openai/agent.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from typing import Self, Literal, List, Optional, Union, Dict +from enum import Enum +from ghostos.prototypes.realtime.abcd import SessionProtocol, Function, RealtimeAgent, S, ConversationProtocol, Shell, \ + Runtime +from ghostos.helpers import uuid +from pydantic import BaseModel, Field +from .protocols import SessionObj, SessionObjBase + + +class Session(BaseModel, SessionProtocol): + instructions: str = Field(description="Instructions") + shell_funcs: Dict[str, List[Function]] = Field(default_factory=dict) + temperature: float = Field(default=0.8) + max_response_output_tokens: Union[int, Literal['inf']] = Field(default='inf') + + def to_session_obj(self) -> SessionObj: + raise NotImplementedError("todo") + + +class Conf(BaseModel): + """ + conf of the openai realtime agent + """ + name: str = Field() + instructions: str = Field() + session: SessionObjBase + + +class Agent(RealtimeAgent[Session]): + + def run_util_stop( + self, + session: Session, + conversation: Optional[ConversationProtocol] = None, + *shells: Shell, + ) -> Runtime: + pass diff --git a/ghostos/prototypes/realtime/openai/protocols.py b/ghostos/prototypes/realtime/openai/protocols.py new file mode 100644 index 00000000..e154324a --- /dev/null +++ b/ghostos/prototypes/realtime/openai/protocols.py @@ -0,0 +1,39 @@ +from typing import Self, Literal, List, Optional, Union, Dict +from enum import Enum +from ghostos.prototypes.realtime.abcd import SessionProtocol, Function +from pydantic import BaseModel, Field +from ghostos.helpers import uuid + + +class ServerEventType(str, Enum): + error = "error" + session_created = "session.created" + + @classmethod + def get_type(cls, event: dict) -> Self: + return cls(event["type"]) + + +class SessionObjBase(BaseModel): + """ + immutable configuration for the openai session object + """ + model: str = Field("gpt-4o-realtime-preview-2024-10-01") + modalities: List[str] = Field(default_factory=list, enum={"text", "audio"}) + voice: str = Field(default="alloy", enum={"alloy", "echo", "shimmer"}) + input_audio_format: str = Field(default="pcm16", enum={"pcm16", "g711_ulaw", "g711_alaw"}) + output_audio_format: str = Field(default="pcm16", enum={"pcm16", "g711_ulaw", "g711_alaw"}) + turn_detection: Union[Dict, None] = Field(None) + + +class SessionObj(SessionObjBase): + """ + full data model for openai realtime-api session object + """ + id: str = Field(default_factory=uuid) + object: Literal["realtime.session"] = "realtime.session" + tools: List[dict] = Field(default_factory=list) + tool_choice: str = Field(default="auto") + temperature: float = Field(default=0.8) + max_response_output_tokens: Union[int, Literal['inf']] = Field(default='inf') + diff --git a/ghostos/prototypes/realtime/openai/ws.py b/ghostos/prototypes/realtime/openai/ws.py new file mode 100644 index 00000000..748181f8 --- /dev/null +++ b/ghostos/prototypes/realtime/openai/ws.py @@ -0,0 +1,191 @@ +from __future__ import annotations +import time +import socks +from typing import Union + +import websockets +import json +import logging +from websockets.sync.client import connect as ws_connect, ClientConnection +from threading import Thread +from queue import Queue, Empty +from pydantic import BaseModel, Field +from ghostos.contracts.logger import LoggerItf + + +# 拆一个 base model 方便未来做成表单. +class OpenAIWebsocketsConf(BaseModel): + token: str = Field() + uri: str = Field("wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01") + close_check: float = Field( + default=0.5, + description="check if the connection is still going while sending event to server", + ) + + +class OpenAIWSConnection: + """ + + """ + + def __init__( + self, + conf: OpenAIWebsocketsConf, + recv_from_server: Queue, + *, + sock=None, + logger: LoggerItf = None, + ): + """ + + :param conf: + :param recv_from_server: + :param sock: + :param logger: + """ + self._running = False + self._stopped = False + self._send_queue = Queue() + self._ws = None + self._logger = logger if logger else logging.getLogger() + self._conf = conf + self._main_thread = Thread(target=self._connect, args=(recv_from_server, sock)) + self._main_thread.start() + + def send(self, event: Union[dict, None]) -> None: + self._send_queue.put(event) + + def close(self): + if self._stopped: + return + self._stopped = True + if self._ws is not None: + self._ws.close() + self._ws = None + self._logger.info("[OpenAIWSConnection] stop the connection") + + def join(self): + self._main_thread.join() + + def _connect( + self, + recv_queue: Queue, + sock=None, + ): + with ws_connect( + uri=self._conf.uri, + additional_headers={ + "Authorization": "Bearer " + self._conf.token, + "OpenAI-Beta": "realtime=v1", + }, + sock=sock, + ) as ws: + self._logger.info("[OpenAIWSConnection] connected") + self._ws = ws + t1 = Thread(target=self._send_when_available, args=(ws,)) + t2 = Thread(target=self._recv_until_closed, args=(ws, recv_queue)) + t1.start() + t2.start() + self._logger.info("[OpenAIWSConnection] connection closed, recycle resources.") + t1.join() + t2.join() + self._stopped = True + # inform the connection is closed + recv_queue.put(None) + recv_queue.task_done() + while not self._send_queue.empty(): + # seems not necessary + self._send_queue.get_nowait() + self._send_queue = None + self._logger.info("[OpenAIWSConnection] connection fully closed") + + def _recv_until_closed(self, ws: ClientConnection, recv_queue: Queue): + try: + while not self._stopped: + try: + data = ws.recv() + if not data: + self._logger.error(f"[OpenAIWSConnection] receive empty data: {data}") + return + if data: + self._logger.debug(f"[OpenAIWSConnection] receive data: {data}") + event = json.loads(data) + recv_queue.put(event) + self._logger.debug("[OpenAIWSConnection] send data as event: %s", event) + except websockets.exceptions.ConnectionClosed: + if not self._stopped: + self._logger.error(f"[OpenAIWSConnection] receive while connection closed but not stopped") + raise + self._stopped = True + self._logger.info(f"[OpenAIWSConnection] receive while connection closed") + finally: + self.close() + + def _send_when_available(self, ws: ClientConnection): + try: + while not self._stopped: + try: + # use timeout to check alive + event = self._send_queue.get(timeout=self._conf.close_check) + except Empty: + time.sleep(self._conf.close_check) + continue + except TimeoutError: + continue + if event is None: + # if receive None from agent queue, means agent stop the connection. + self._stopped = True + self._logger.info(f"[OpenAIWSConnection] got none event which means connection shall close") + break + try: + data = json.dumps(event) + ws.send(data) + self._logger.debug(f"[OpenAIWSConnection] send data to server: %s", data) + except websockets.exceptions.ConnectionClosedOK: + break + finally: + self.close() + + +connect = OpenAIWSConnection + +if __name__ == "__main__": + import os + from ghostos.helpers import Timeleft + + _on_recv = Queue() + + s = socks.socksocket() + s.set_proxy(socks.SOCKS5, "localhost", 1080) + s.connect(("api.openai.com", 443)) + socket = s + + _token = os.environ["OPENAI_API_KEY"] + print("+++++ token", _token) + _conf = OpenAIWebsocketsConf(token=_token) + _connection = connect( + _conf, + _on_recv, + sock=socket, + ) + + + def output(q: Queue, left: Timeleft): + while left.alive(): + try: + data = q.get_nowait() + print("+++++", data) + if data is None: + break + except Empty: + time.sleep(0.2) + print("+++++ timeleft", left.left()) + + + _left = Timeleft(10) + output_t = Thread(target=output, args=(_on_recv, _left)) + output_t.start() + output_t.join() + _connection.close() + _connection.join() + print("done") diff --git a/ghostos/prototypes/realtime/shells.py b/ghostos/prototypes/realtime/shells.py new file mode 100644 index 00000000..f7c77ee2 --- /dev/null +++ b/ghostos/prototypes/realtime/shells.py @@ -0,0 +1,58 @@ +from abc import abstractmethod +from typing import Protocol, Iterable, Union, Literal +from .abcd import Shell, Message + + +class Chat(Shell, Protocol): + + @abstractmethod + def messages(self) -> Iterable[Message]: + pass + + @abstractmethod + def pop_message_head(self, timeout: float = 0.0) -> Union[Message, None]: + pass + + @abstractmethod + def read_message_chunks(self, msg_id: str) -> Iterable[Message]: + pass + + +class TextInput(Shell, Protocol): + + @abstractmethod + def send(self, text: str): + pass + + +class PushOnTalk(Shell, Protocol): + + @abstractmethod + def state(self) -> Literal["", "recording", "playing", "stopped"]: + pass + + @abstractmethod + def start_record(self): + pass + + @abstractmethod + def commit(self): + pass + + @abstractmethod + def clear(self): + pass + + @abstractmethod + def halt(self): + pass + + +class AudioOutput(Shell, Protocol): + @abstractmethod + def state(self) -> Literal["", "playing"]: + pass + + @abstractmethod + def cancel(self): + pass diff --git a/pyproject.toml b/pyproject.toml index 14d9bee2..57790fda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,8 @@ streamlit-antd-components = "^0.3.2" streamlit-react-jsonschema = "^0.1.3" python-dotenv = "^1.0.1" babel = "^2.16.0" +websockets = "^13.1" +pysocks = "^1.7.1" [tool.poetry.scripts] init = "ghostos.scripts.init:main" diff --git a/tests/python/test_asyncio.py b/tests/python/test_asyncio.py index 5ad91473..686dbbc9 100644 --- a/tests/python/test_asyncio.py +++ b/tests/python/test_asyncio.py @@ -1,4 +1,5 @@ import asyncio +import concurrent def test_loop_run_until_complete(): From df9c87c7123b7e1d3edbcb273c85a697486f71f1 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 26 Oct 2024 18:30:14 +0800 Subject: [PATCH 054/148] dev: add some logger helper for me --- ghostos/contracts/logger.py | 58 +++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/ghostos/contracts/logger.py b/ghostos/contracts/logger.py index 947ad1f4..2214b361 100644 --- a/ghostos/contracts/logger.py +++ b/ghostos/contracts/logger.py @@ -1,9 +1,11 @@ +import logging from abc import abstractmethod from logging.config import dictConfig -from typing import Protocol +from logging import getLogger, LoggerAdapter +from typing import Protocol, Optional import yaml -__all__ = ['LoggerItf', 'config_logging'] +__all__ = ['LoggerItf', 'config_logging', 'get_logger', 'get_console_logger'] class LoggerItf(Protocol): @@ -90,6 +92,10 @@ def log(self, level, msg, *args, **kwargs): pass +def get_logger(name: Optional[str] = None, extra: Optional[dict] = None) -> LoggerItf: + return LoggerAdapter(getLogger(name), extra=extra) + + def config_logging(conf_path: str) -> None: """ configurate logging by yaml config @@ -99,3 +105,51 @@ def config_logging(conf_path: str) -> None: content = f.read() data = yaml.safe_load(content) dictConfig(data) + + +def get_console_logger( + name: str = "__console__", + extra: Optional[dict] = None, +) -> LoggerItf: + logger = getLogger(name) + if not logger.hasHandlers(): + logger.setLevel(logging.DEBUG) + _console_handler = logging.StreamHandler() + _console_handler.setLevel(logging.DEBUG) + _console_formatter = PleshakovFormatter() + _console_handler.setFormatter(_console_formatter) + logger.addHandler(_console_handler) + return LoggerAdapter(logger, extra=extra) + + +class PleshakovFormatter(logging.Formatter): + # copy from + # https://stackoverflow.com/questions/384076/how-can-i-color-python-logging-output + grey = "\x1b[37;20m" + yellow = "\x1b[33;20m" + red = "\x1b[31;20m" + green = "\x1b[32;20m" + bold_red = "\x1b[31;1m" + reset = "\x1b[0m" + format = "%(asctime)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)" + + FORMATS = { + logging.DEBUG: grey + format + reset, + logging.INFO: green + format + reset, + logging.WARNING: yellow + format + reset, + logging.ERROR: red + format + reset, + logging.CRITICAL: bold_red + format + reset + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + + +if __name__ == '__main__': + get_console_logger().debug("hello world") + get_console_logger().info("hello world") + get_console_logger().error("hello world") + get_console_logger().warning("hello world") + get_console_logger().critical("hello world") From e8f7769131a94849bbffa1303d3ee56e41c5fa6e Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 27 Oct 2024 02:12:00 +0800 Subject: [PATCH 055/148] dev: save realtime agent implementations --- ghostos/contracts/logger.py | 6 +- ghostos/helpers/__init__.py | 1 + ghostos/helpers/openai.py | 3 + ghostos/prototypes/realtime/abcd.py | 158 +++++++++---- .../prototypes/realtime/openai/broadcast.py | 95 ++++++++ ghostos/prototypes/realtime/openai/configs.py | 23 ++ .../realtime/openai/conversation.py | 18 ++ .../prototypes/realtime/openai/protocols.py | 127 +++++++++- ghostos/prototypes/realtime/openai/ws.py | 218 ++++++++---------- tests/python/test_collection.py | 23 ++ 10 files changed, 503 insertions(+), 169 deletions(-) create mode 100644 ghostos/helpers/openai.py create mode 100644 ghostos/prototypes/realtime/openai/broadcast.py create mode 100644 ghostos/prototypes/realtime/openai/configs.py create mode 100644 ghostos/prototypes/realtime/openai/conversation.py create mode 100644 tests/python/test_collection.py diff --git a/ghostos/contracts/logger.py b/ghostos/contracts/logger.py index 2214b361..3c02ea01 100644 --- a/ghostos/contracts/logger.py +++ b/ghostos/contracts/logger.py @@ -108,14 +108,16 @@ def config_logging(conf_path: str) -> None: def get_console_logger( - name: str = "__console__", + name: str = "__ghostos_console__", extra: Optional[dict] = None, + debug: bool = False, ) -> LoggerItf: logger = getLogger(name) if not logger.hasHandlers(): logger.setLevel(logging.DEBUG) _console_handler = logging.StreamHandler() - _console_handler.setLevel(logging.DEBUG) + if debug: + _console_handler.setLevel(logging.DEBUG) _console_formatter = PleshakovFormatter() _console_handler.setFormatter(_console_formatter) logger.addHandler(_console_handler) diff --git a/ghostos/helpers/__init__.py b/ghostos/helpers/__init__.py index ac87b586..dc1c5a8f 100644 --- a/ghostos/helpers/__init__.py +++ b/ghostos/helpers/__init__.py @@ -22,6 +22,7 @@ from ghostos.helpers.trans import gettext, ngettext, get_current_locale, GHOSTOS_DOMAIN from ghostos.helpers.coding import reflect_module_code +from ghostos.helpers.openai import get_openai_key if TYPE_CHECKING: from typing import Callable diff --git a/ghostos/helpers/openai.py b/ghostos/helpers/openai.py new file mode 100644 index 00000000..d5b6eaf6 --- /dev/null +++ b/ghostos/helpers/openai.py @@ -0,0 +1,3 @@ +def get_openai_key() -> str: + import os + return os.environ.get("OPENAI_API_KEY", "") diff --git a/ghostos/prototypes/realtime/abcd.py b/ghostos/prototypes/realtime/abcd.py index 613e53c5..cdf95dc0 100644 --- a/ghostos/prototypes/realtime/abcd.py +++ b/ghostos/prototypes/realtime/abcd.py @@ -2,11 +2,14 @@ from abc import ABC, abstractmethod from typing import ( Protocol, Literal, List, ClassVar, Iterable, Tuple, TypeVar, Optional, Dict, Callable, Type, + Self, Union, ) +import time from enum import Enum from pydantic import BaseModel from queue import Queue +from contextlib import contextmanager class Message(Protocol): @@ -16,16 +19,69 @@ class Message(Protocol): seq: Literal["head", "chunk", "complete"] -class SessionProtocol(Protocol): - instructions: str - shell_funcs: Dict[str, List[Function]] +class State(ABC): + state_name: ClassVar[str] + @abstractmethod + def conversation(self) -> ConversationProtocol: + pass + + @abstractmethod + def status(self) -> str: + """ + if there are sub statuses of this State + :return: + """ + pass + + @abstractmethod + def operate(self, op: Operator) -> Tuple[Literal["", "queued", "blocked", "illegal"], str | None]: + """ + :param op: + :return: accept level | error message + """ + pass + + @abstractmethod + def run_operator(self) -> Union["State", None]: + """ + :return: None means no operation, go on handle event + """ + pass + + @abstractmethod + def run_server_event(self) -> Union[State, None]: + """ + :return: a new state, or continue + """ + pass + + def tick(self) -> Union[State, None]: + """ + :return: if not none, means a new state is returned. and: + 1. replace current state with new state + 2. put the current state to a recycling queue, join it without blocking. + """ + new_state = self.run_operator() + if new_state: + return new_state + new_state = self.run_server_event() + if new_state: + return new_state + return None + + @abstractmethod + def join(self): + """ + clear/recycle/release resources when a state is overdue + """ + pass -S = TypeVar("S", bound=SessionProtocol) -M = TypeVar("M", bound=Message) +S = TypeVar("S", bound=State) -class RealtimeAgent(Protocol[S, M]): + +class RealtimeAgent(Protocol[S]): """ realtime agent in multi-threading programming pattern. it will develop several threads during runtime to exchange parallel events and actions. @@ -67,25 +123,24 @@ class RealtimeAgent(Protocol[S, M]): @abstractmethod def run_util_stop( self, - session: S, conversation: Optional[ConversationProtocol] = None, *shells: Shell, ) -> None: pass -class ConversationProtocol(Protocol[M]): +class ConversationProtocol(Protocol): @abstractmethod def id(self) -> str: pass @abstractmethod - def messages(self) -> Iterable[M]: + def messages(self) -> Iterable[Message]: pass @abstractmethod - def append(self, message: M) -> None: + def append(self, message: Message) -> None: pass @abstractmethod @@ -96,38 +151,23 @@ def save(self): pass -class Function(Protocol): +class Function(BaseModel): name: str description: str parameters: Dict - -class Status(str, Enum): - """ - state of the agent. - realtime agent is basically a state machine. - each state has it's policy for operations. - like: - - stopped: means no operation allowed anymore - - connecting: is not ready yet, can not send event or receive message - - available: is ready for most operations. - """ - AVAILABLE = "available" - CONNECTING = "connecting" - STOPPED = "stopped" - - -T = TypeVar("T", bound=Status) + def with_name(self, name: str) -> Self: + return self.model_copy(update={"name": name}, deep=True) -class Ghost(Protocol[S, M, T]): +class Ghost(Protocol): @abstractmethod def alive(self) -> bool: pass @abstractmethod - def status(self) -> T: + def status(self) -> str: pass @abstractmethod @@ -143,7 +183,7 @@ def session(self) -> S: pass @abstractmethod - def messages(self) -> Iterable[M]: + def messages(self) -> Iterable[Message]: pass @@ -161,7 +201,7 @@ def subscribing(self) -> List[str]: pass @abstractmethod - def on_sync(self, ghost: Ghost) -> Queue: + def on_sync(self, ghost: Ghost) -> ChanOut[Union[dict, None]]: """ sync the ghost with shell, and return a channel that the agent publish the subscribed event to the shell. @@ -177,24 +217,52 @@ class FunctionCall(Protocol): arguments: str -class Operator(ABC): - """ - to operate the agent's ghost state machine - is a protocol defined by the agent. +# --- channels --- # + +E = TypeVar("E") + +class ChanIn(Protocol[E]): + """ + the receiver create the put chan + compatible to queue.Queue + but not necessary? """ - pass + + @abstractmethod + def put(self, item: E, block=True, timeout=None) -> None: + """ + :param item: dict | None + :param block: boolean + :param timeout: float | None + """ + pass + + @abstractmethod + def task_done(self) -> None: + """ + notify task is done. + """ + pass -class Stop(Operator): - pass +class ChanOut(Protocol[E]): + @abstractmethod + def get(self, block=True, timeout=None) -> E: + pass + + @abstractmethod + def get_nowait(self): + pass -class Reconnect(Operator): - pass +# --- basic operators --- # -class FunctionOutput(BaseModel, Operator): - id: Optional[str] - name: str - content: str +class Operator(BaseModel, ABC): + """ + to operate the agent's ghost state machine + is a protocol defined by the agent. + """ + type: str + shell_id: str = "" diff --git a/ghostos/prototypes/realtime/openai/broadcast.py b/ghostos/prototypes/realtime/openai/broadcast.py new file mode 100644 index 00000000..0f37a01f --- /dev/null +++ b/ghostos/prototypes/realtime/openai/broadcast.py @@ -0,0 +1,95 @@ +from typing import Dict, List, Optional +from abc import ABC, abstractmethod +from copy import deepcopy +from ghostos.prototypes.realtime.abcd import ChanIn +from ghostos.contracts.logger import LoggerItf, get_logger + +__all__ = ['Broadcaster', 'SimpleBroadcaster'] + + +class Broadcaster(ABC): + """ + broadcast event to all channels + """ + + @abstractmethod + def subscribe( + self, + subscriber: str, + chan: ChanIn, + topics: List[str], + ) -> None: + pass + + @abstractmethod + def publish(self, topic: str, data: dict): + pass + + @abstractmethod + def close(self): + pass + + +class SimpleBroadcaster(Broadcaster): + + def __init__(self, logger: Optional[LoggerItf] = None): + self.subscriber_channels: Dict[str, ChanIn] = {} + self.topic_to_subscribers: Dict[str, List[str]] = {} + self._closed = False + self._start_join = False + self._logger = logger if logger else get_logger() + + def subscribe( + self, + subscriber: str, + chan: ChanIn, + topics: List[str], + ) -> None: + if self._closed: + raise RuntimeError("Broadcaster already closed") + if subscriber in self.subscriber_channels: + raise ValueError(f"Subscriber {subscriber} already subscribed") + self.subscriber_channels[subscriber] = chan + for topic in topics: + if topic not in self.topic_to_subscribers: + self.topic_to_subscribers[topic] = [] + subscribers = self.topic_to_subscribers[topic] + subscribers.append(subscriber) + self.topic_to_subscribers[topic] = subscribers + return None + + def publish(self, topic: str, data: dict): + if self._closed: + raise RuntimeError("Broadcaster already closed") + if topic not in self.topic_to_subscribers: + return + subscribers = self.topic_to_subscribers[topic] + if not subscribers: + return + for subscriber in subscribers: + if self._closed: + break + chan = self.subscriber_channels[subscriber] + copied = deepcopy(data) + try: + chan.put(copied, block=False, timeout=0.5) + except TimeoutError as e: + raise RuntimeError(f"Failed to publish because subscriber {subscriber} chan timed out: {e}") + except Exception as e: + self._logger.error( + "put topic %s event to subscriber %s failed", + topic, subscriber, + exc_info=e, + ) + continue + + def close(self): + if self._closed: + return + self._logger.info("%s is closing", self.__class__.__name__) + self._closed = True + for chan in self.subscriber_channels.values(): + chan.put(None) + chan.task_done() + self.topic_to_subscribers = {} + self.subscriber_channels = {} diff --git a/ghostos/prototypes/realtime/openai/configs.py b/ghostos/prototypes/realtime/openai/configs.py new file mode 100644 index 00000000..a1369051 --- /dev/null +++ b/ghostos/prototypes/realtime/openai/configs.py @@ -0,0 +1,23 @@ +from typing import Optional +from pydantic import BaseModel, Field +from .ws import OpenAIWebsocketsConf +from .protocols import OpenAISessionObj + + +class AgentConf(BaseModel): + name: str = Field( + description="Name of the agent", + ) + description: str = Field( + description="Description of the agent", + ) + ws_conf: OpenAIWebsocketsConf = Field( + default_factory=OpenAIWebsocketsConf, + description="OpenAI Websockets configuration", + ) + session: Optional[OpenAISessionObj] = Field( + default=None, + description="basic session settings, if None, use openai default session", + ) + + diff --git a/ghostos/prototypes/realtime/openai/conversation.py b/ghostos/prototypes/realtime/openai/conversation.py new file mode 100644 index 00000000..ff245cc3 --- /dev/null +++ b/ghostos/prototypes/realtime/openai/conversation.py @@ -0,0 +1,18 @@ +from typing import Iterable + +from ghostos.prototypes.realtime.abcd import ConversationProtocol, Message + + +class Conversation(ConversationProtocol): + + def id(self) -> str: + pass + + def messages(self) -> Iterable[Message]: + pass + + def append(self, message: Message) -> None: + pass + + def save(self): + pass \ No newline at end of file diff --git a/ghostos/prototypes/realtime/openai/protocols.py b/ghostos/prototypes/realtime/openai/protocols.py index e154324a..3e91d4f9 100644 --- a/ghostos/prototypes/realtime/openai/protocols.py +++ b/ghostos/prototypes/realtime/openai/protocols.py @@ -1,20 +1,91 @@ -from typing import Self, Literal, List, Optional, Union, Dict +from typing import Self, Literal, List, Optional, Union, Dict, Protocol, ClassVar +from abc import ABC, abstractmethod from enum import Enum -from ghostos.prototypes.realtime.abcd import SessionProtocol, Function +from ghostos.prototypes.realtime.abcd import Function from pydantic import BaseModel, Field from ghostos.helpers import uuid +class StateName(str, Enum): + # --- can not change + stopped = "stopped" + + # --- blocking + connected = "connected" + session_updating = "session_updating" + + # --- interruptible + responding = "responding" + input_audio = "audio_input" + listening = "listening" # vad + + idle = "idle" + + # --- special operations allowed + failed = "failed" # failed but reconnect-able + + # I think this state is parallel, need test + # creating_conversation_item = "creating_conversation_item" + + +class OperatorName(str, Enum): + # --- idempotent or immediately + stop = "stop" # highest priority + reconnect = "reconnect" # second to stop + + # --- parallel actions + text_input = "text_input" + function_output = "function_output" + + # --- blocking + session_updating = "session_updating" + + # --- idempotent or illegal + create_response = "create_response" + input_audio = "input_audio" # push-to-talk mode + start_listening = "start_listening" # start vad listening + + # --- immediately or illegal + truncate_listening = "truncate_listening" # vad only + response_cancel = "response_cancel" + + class ServerEventType(str, Enum): + # recover-able error error = "error" + + # non-block inform session_created = "session.created" + session_updated = "session.updated" + conversation_created = "conversation.created" + + # streaming items + + # complete message item alignments + conversation_item_created = "conversation.item.created" + conversation_item_deleted = "conversation.item.deleted" + audio_transcript_created = "conversation.item.input_audio_transcription.completed" + audio_transcript_failed = "conversation.item.input_audio_transcription.failed" + response_output_item_done = "response.output_item.done" + + # system + rate_limits_updated = "rate_limits.updated" @classmethod def get_type(cls, event: dict) -> Self: return cls(event["type"]) + def match(self, event: dict) -> bool: + return "type" in event and event["type"] == self.value + -class SessionObjBase(BaseModel): +class ClientEventType(str, Enum): + session_update = "session.updated" + + +# ---- configs ---- # + +class OpenAISessionObjBase(BaseModel): """ immutable configuration for the openai session object """ @@ -26,14 +97,60 @@ class SessionObjBase(BaseModel): turn_detection: Union[Dict, None] = Field(None) -class SessionObj(SessionObjBase): +class OpenAISessionObj(OpenAISessionObjBase): """ full data model for openai realtime-api session object """ - id: str = Field(default_factory=uuid) + id: str = Field(default="", description="id of the session") object: Literal["realtime.session"] = "realtime.session" tools: List[dict] = Field(default_factory=list) tool_choice: str = Field(default="auto") temperature: float = Field(default=0.8) max_response_output_tokens: Union[int, Literal['inf']] = Field(default='inf') + def get_update_event(self) -> dict: + data = self.model_dump(exclude={'id'}) + return data + + +# ---- server side events ---- # + +class ServerEvent(BaseModel, ABC): + event_id: str = Field(description="Optional client-generated ID used to identify this event.") + type: str = Field(description="server event type") + + +class ServerSessionCreated(ServerEvent): + session: OpenAISessionObj + + +# ---- client side events ---- # + + +class ClientEvent(BaseModel, ABC): + type: ClassVar[str] + event_id: Optional[str] = Field( + default=None, + description="Optional client-generated ID used to identify this event.", + ) + + def to_openai_event(self) -> dict: + event_id = self.event_id + type_ = self.type + data = {"type": type_} + if event_id: + data["event_id"] = event_id + return self._to_openai_event(data) + + @abstractmethod + def _to_openai_event(self, data: dict) -> dict: + pass + + +class ClientSessionUpdate(ClientEvent): + type = ClientEventType.session_update.value + shell_funcs: Dict[str, List[Function]] = Field(default_factory=dict) + session: OpenAISessionObj + + def _to_openai_event(self, data: dict) -> dict: + return data diff --git a/ghostos/prototypes/realtime/openai/ws.py b/ghostos/prototypes/realtime/openai/ws.py index 748181f8..c7f3ad9f 100644 --- a/ghostos/prototypes/realtime/openai/ws.py +++ b/ghostos/prototypes/realtime/openai/ws.py @@ -1,5 +1,7 @@ from __future__ import annotations import time + +import requests import socks from typing import Union @@ -9,13 +11,19 @@ from websockets.sync.client import connect as ws_connect, ClientConnection from threading import Thread from queue import Queue, Empty +from collections import deque from pydantic import BaseModel, Field -from ghostos.contracts.logger import LoggerItf +from ghostos.prototypes.realtime.abcd import ChanIn +from ghostos.contracts.logger import LoggerItf, get_console_logger +from ghostos.helpers import get_openai_key # 拆一个 base model 方便未来做成表单. class OpenAIWebsocketsConf(BaseModel): - token: str = Field() + api_key: str = Field( + default_factory=get_openai_key, + description="The OpenAI key used to authenticate with WebSockets.", + ) uri: str = Field("wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01") close_check: float = Field( default=0.5, @@ -25,13 +33,15 @@ class OpenAIWebsocketsConf(BaseModel): class OpenAIWSConnection: """ - + websocket adapter, provides: + 1. connect config adaption + 2. event marshal and unmarshal + 3. exception catch """ def __init__( self, conf: OpenAIWebsocketsConf, - recv_from_server: Queue, *, sock=None, logger: LoggerItf = None, @@ -39,112 +49,78 @@ def __init__( """ :param conf: - :param recv_from_server: :param sock: :param logger: """ self._running = False - self._stopped = False - self._send_queue = Queue() - self._ws = None + self._closed = False self._logger = logger if logger else logging.getLogger() self._conf = conf - self._main_thread = Thread(target=self._connect, args=(recv_from_server, sock)) - self._main_thread.start() + # 同步创建 connection. + self._ws = ws_connect( + uri=self._conf.uri, + additional_headers={ + "Authorization": "Bearer " + self._conf.api_key, + "OpenAI-Beta": "realtime=v1", + }, + sock=sock, + ) + + def client(self) -> ClientConnection: + if self._closed: + raise RuntimeError("Connection was already stopped") + return self._ws + + def send(self, event: dict) -> None: + if self._closed: + raise RuntimeError("Connection was already stopped") + try: + data = json.dumps(event) + # last check + if self._closed: + return + self._ws.send(data) + self._logger.debug(f"[OpenAIWSConnection] send data to server: %s", data) + except websockets.exceptions.ConnectionClosedOK: + self.close() - def send(self, event: Union[dict, None]) -> None: - self._send_queue.put(event) + def recv(self, timeout: Union[float, None] = None, timeout_error: bool = False) -> Union[dict, None]: + if self._closed: + return None + try: + data = self._ws.recv(timeout=timeout) + self._logger.debug(f"[OpenAIWSConnection] receive data") + if not data: + self._logger.error(f"[OpenAIWSConnection] receive empty data: {data}") + return None + if data: + event = json.loads(data) + self._logger.debug(f"[OpenAIWSConnection] receive event %s", event["type"]) + if not data: + return event + except websockets.exceptions.ConnectionClosed: + self.close() + return None + except TimeoutError: + if timeout == 0: + # return None as expected + return None + if timeout_error: + raise + self._logger.debug(f"[OpenAIWSConnection] receive data timeout, but expected") + return None def close(self): - if self._stopped: + if self._closed: return - self._stopped = True + self._closed = True if self._ws is not None: self._ws.close() self._ws = None self._logger.info("[OpenAIWSConnection] stop the connection") - def join(self): - self._main_thread.join() - - def _connect( - self, - recv_queue: Queue, - sock=None, - ): - with ws_connect( - uri=self._conf.uri, - additional_headers={ - "Authorization": "Bearer " + self._conf.token, - "OpenAI-Beta": "realtime=v1", - }, - sock=sock, - ) as ws: - self._logger.info("[OpenAIWSConnection] connected") - self._ws = ws - t1 = Thread(target=self._send_when_available, args=(ws,)) - t2 = Thread(target=self._recv_until_closed, args=(ws, recv_queue)) - t1.start() - t2.start() - self._logger.info("[OpenAIWSConnection] connection closed, recycle resources.") - t1.join() - t2.join() - self._stopped = True - # inform the connection is closed - recv_queue.put(None) - recv_queue.task_done() - while not self._send_queue.empty(): - # seems not necessary - self._send_queue.get_nowait() - self._send_queue = None - self._logger.info("[OpenAIWSConnection] connection fully closed") - - def _recv_until_closed(self, ws: ClientConnection, recv_queue: Queue): - try: - while not self._stopped: - try: - data = ws.recv() - if not data: - self._logger.error(f"[OpenAIWSConnection] receive empty data: {data}") - return - if data: - self._logger.debug(f"[OpenAIWSConnection] receive data: {data}") - event = json.loads(data) - recv_queue.put(event) - self._logger.debug("[OpenAIWSConnection] send data as event: %s", event) - except websockets.exceptions.ConnectionClosed: - if not self._stopped: - self._logger.error(f"[OpenAIWSConnection] receive while connection closed but not stopped") - raise - self._stopped = True - self._logger.info(f"[OpenAIWSConnection] receive while connection closed") - finally: - self.close() - - def _send_when_available(self, ws: ClientConnection): - try: - while not self._stopped: - try: - # use timeout to check alive - event = self._send_queue.get(timeout=self._conf.close_check) - except Empty: - time.sleep(self._conf.close_check) - continue - except TimeoutError: - continue - if event is None: - # if receive None from agent queue, means agent stop the connection. - self._stopped = True - self._logger.info(f"[OpenAIWSConnection] got none event which means connection shall close") - break - try: - data = json.dumps(event) - ws.send(data) - self._logger.debug(f"[OpenAIWSConnection] send data to server: %s", data) - except websockets.exceptions.ConnectionClosedOK: - break - finally: - self.close() + def closed(self) -> bool: + return self._closed connect = OpenAIWSConnection @@ -153,8 +129,6 @@ def _send_when_available(self, ws: ClientConnection): import os from ghostos.helpers import Timeleft - _on_recv = Queue() - s = socks.socksocket() s.set_proxy(socks.SOCKS5, "localhost", 1080) s.connect(("api.openai.com", 443)) @@ -163,29 +137,39 @@ def _send_when_available(self, ws: ClientConnection): _token = os.environ["OPENAI_API_KEY"] print("+++++ token", _token) _conf = OpenAIWebsocketsConf(token=_token) - _connection = connect( + _c = connect( _conf, - _on_recv, sock=socket, + logger=get_console_logger(debug=True), ) + # test parallel actions + _c.send({ + "type": "conversation.item.create", + "previous_item_id": None, + "item": { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Hello, how are you?" + } + ] + } + }) + _c.send({ + "type": "response.create", + "response": {}, + }) + + left = Timeleft(10) + while left.alive(): + _data = _c.recv(timeout=0) + if _data: + print("+++++", _data) + time.sleep(0.2) + print("+++++ timeleft", left.left()) + _c.close() - def output(q: Queue, left: Timeleft): - while left.alive(): - try: - data = q.get_nowait() - print("+++++", data) - if data is None: - break - except Empty: - time.sleep(0.2) - print("+++++ timeleft", left.left()) - - - _left = Timeleft(10) - output_t = Thread(target=output, args=(_on_recv, _left)) - output_t.start() - output_t.join() - _connection.close() - _connection.join() print("done") diff --git a/tests/python/test_collection.py b/tests/python/test_collection.py new file mode 100644 index 00000000..2dc61d9d --- /dev/null +++ b/tests/python/test_collection.py @@ -0,0 +1,23 @@ +from collections import deque + + +def test_deque(): + d = deque([1, 2, 3, 4, 5]) + assert 5 == len(d) + assert 1 == d.popleft() + assert 5 == d.pop() + assert d.count(5) == 0 + assert d.count(2) == 1 + d.pop() + d.pop() + d.pop() + + e = None + assert len(d) == 0 + try: + d.popleft() + except IndexError as err: + e = err + assert e is not None + + From 9ea4765c0177e2ad0c679ff41a31d42280d262dc Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 27 Oct 2024 02:13:22 +0800 Subject: [PATCH 056/148] dev: save new ghost pattern, not affordable lose it --- ghostos/core/ghost_dev_pattern/__init__.py | 0 ghostos/core/ghost_dev_pattern/concepts.py | 208 ++++++++++++++++++++ ghostos/core/ghost_dev_pattern/ghost.py | 105 ++++++++++ ghostos/core/ghost_dev_pattern/runtime.py | 62 ++++++ ghostos/core/ghost_dev_pattern/template.py | 28 +++ ghostos/core/ghost_dev_pattern/think.py | 2 + ghostos/core/ghost_dev_pattern/thoughts.py | 59 ++++++ ghostos/core/ghost_dev_pattern/variables.py | 22 +++ 8 files changed, 486 insertions(+) create mode 100644 ghostos/core/ghost_dev_pattern/__init__.py create mode 100644 ghostos/core/ghost_dev_pattern/concepts.py create mode 100644 ghostos/core/ghost_dev_pattern/ghost.py create mode 100644 ghostos/core/ghost_dev_pattern/runtime.py create mode 100644 ghostos/core/ghost_dev_pattern/template.py create mode 100644 ghostos/core/ghost_dev_pattern/think.py create mode 100644 ghostos/core/ghost_dev_pattern/thoughts.py create mode 100644 ghostos/core/ghost_dev_pattern/variables.py diff --git a/ghostos/core/ghost_dev_pattern/__init__.py b/ghostos/core/ghost_dev_pattern/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/core/ghost_dev_pattern/concepts.py b/ghostos/core/ghost_dev_pattern/concepts.py new file mode 100644 index 00000000..d2125c2e --- /dev/null +++ b/ghostos/core/ghost_dev_pattern/concepts.py @@ -0,0 +1,208 @@ +from abc import ABC, abstractmethod +from typing import ( + Protocol, TypeVar, Optional, Union, Literal, List, Type, Dict, TypedDict, Required, Any +) +from ghostos.core.moss import Moss +from pydantic import BaseModel + + +class Func(Protocol): + """ + AI Function definition in data-driven pattern. + """ + + class Args(BaseModel): + """ + the Arguments model of the function. + """ + pass + + class Returns(BaseModel): + """ + the return values model of the function. + """ + pass + + # __meta__: ClassVar[dict] = {} + # meta is convention of the func, optional field + + +F = TypeVar("F", bound=Func) + + +class Task(Protocol[F]): + """ + is the state instance of a Func execution + """ + args: F.Args + """ the calling arguments, which is changeable during execution""" + + returns: Union[F.Returns, None] + """ the return values, which is altering during execution""" + + status: Literal["new", "waiting", "running", "done", "pending", "cancelled", "aborted"] + """ the status of the execution """ + + description: str + """ describe the execution status""" + + +class State(BaseModel, ABC): + """ + the runtime private state properties model of the Func + """ + pass + + +S = TypeVar("S", bound=State) + + +class VarPtr(TypedDict): + """ + refer a global accessible variable to the pointer. + compatible to many type like int, str, boolean, float, or other identifiable types. + """ + + vid: Required[str] + """ unique id of the variable""" + + type: Required[str] + """ origin type of the variable, like int, str, or import path in `[module]:[attr]` pattern""" + + desc: Optional[str] + """ description of the variable""" + + +M = TypeVar("M", bound=Moss) + + +class OP(ABC): + """ + runtime operator of a Func. + can only be pre-defined by outer Operating System. + """ + + @abstractmethod + def run(self): + pass + + +class Context(Protocol[F, S, M]): + """ + the runtime context for an AI Entity-driven Func. + """ + + state: S + """ mutate self state properties """ + + task: Task[F] + """ self task instance """ + + moss: M + """ + the operating system for the AI Entity who driven this Func. + provide instance of libraries and tools. + """ + + subtasks: Dict[str, Task] + """ + the other Func state instances, that have been created by this Context. + """ + + @abstractmethod + def send(self, *messages: Union[str, VarPtr, Any]) -> None: + """ + send messages to the caller. + :param messages: str, var pointer or any value that can be converted to VarPtr. + :exception TypeError: if message can not convert to VarPtr + """ + pass + + @abstractmethod + def set_var(self, value: Any, vid: Optional[str] = None) -> VarPtr: + """ + + :param value: + :param vid: + :return: + """ + pass + + @abstractmethod + def get_var(self, vid: str, type_: Optional[Type[Any]] = None) -> Union[Any, None]: + """ + get a global variable by vid. + :param vid: id from VarPtr + :param type_: the expected type of the variable. + :return: None if the variable is not found, otherwise unpack the variable to it origin type + """ + pass + + # the functions below are the primitive operators of this functions. + + @abstractmethod + def done(self, returns: Union[F.Returns, None] = None, *messages: str) -> OP: + """ + end the task with confirmed return values. + :param returns: if not None, update the return value of self task + :param messages: if given, inform the caller with the messages. + """ + pass + + @abstractmethod + def abort(self, reason: str) -> OP: + """ + abort the task with given reason. + """ + pass + + @abstractmethod + def await_answer(self, question: str, suggestions: Optional[List[str]] = None) -> OP: + """ + ask a question to the caller, and wait for the answer. + :param question: ask for confirmation, choice, selection, clarification, etc. + :param suggestions: if you have any + """ + pass + + @abstractmethod + def ack(self) -> OP: + """ + acknowledge the messages and do nothing. + """ + pass + + @abstractmethod + def observe(self, **kwargs) -> OP: + """ + start an observation on the outputs before it is called. + :param kwargs: if given, repr each arg for observation. + """ + pass + + # the methods below can interact with other funcs. + + @abstractmethod + def create_subtask(self, name: str, args: Func.Args) -> None: + """ + call another Func with subtask name. + :param name: key to find the subtask in self subtasks + :param args: arguments instance of the calling Func + """ + pass + + @abstractmethod + def send_to_subtask(self, name: str, *messages: Union[str, VarPtr, Any]) -> None: + """ + send information to the subtask. + :param name: specify a subtask by name + :param messages: if not str or VarPtr, then must be some value that can be converted to VarPtr. + """ + pass + + @abstractmethod + def cancel_subtask(self, name: str, reason: str) -> None: + """ + cancel specified subtask by name with reason. + """ + pass diff --git a/ghostos/core/ghost_dev_pattern/ghost.py b/ghostos/core/ghost_dev_pattern/ghost.py new file mode 100644 index 00000000..17d10b19 --- /dev/null +++ b/ghostos/core/ghost_dev_pattern/ghost.py @@ -0,0 +1,105 @@ +from abc import ABC, abstractmethod +from typing import ( + Protocol, Self, Generic, Type, TypeVar, Tuple, Callable, Union, Optional, List, Literal, Dict, + Iterable, ClassVar, +) +from .concepts import State, Func, Context, Task, OP +from .runtime import Runtime +from ghostos.container import Container, Contracts +from ghostos.core.moss import PyContext, Moss, MossCompiler +from ghostos.core.session import Session, Event +from ghostos.core.messages import Message +from ghostos.common import IdentifiableClass, identify_class, Identifier, Identifiable +from ghostos.entity import EntityMeta +from pydantic import BaseModel, Field +from contextlib import contextmanager + +""" +Ghost 是面向开发者的抽象设计. +它是 Agent 运行时中最小有状态的思维单元. 开发者可以通过定义 Ghost, 来定义 Agent 可分形嵌套的思维能力. + +Ghost 的定位类似于前端 React 框架的 ReactComponent, 或是 MVC 开发框架里的 Controller. 包含的核心功能: +1. 响应一个 Task, 并且最终管理 Task 的输出, task.result +2. 管理 Task 运行时的状态, 也就是 Thought. 包含创建, 修改, 完成, 失败等. +3. 管理有状态的子任务, 也就是 Thought. 包含创建, 取消, 发送消息. +4. 响应运行过程中接受到的的事件. 作出行动. +5. 调用 LLM 作为资深的驱动. 由 Ghost 子类实现. 子类的任务包括: + - 提供 prompt + - 提供上下文. + - 提供工具, 通常是用 moss 提供的代码交互界面. + - 运行大模型. + - 执行大模型生成的 actions, 将操作反馈到 runtime. + - 保存自身状态, 等待下一轮. 或结束, 终止当前任务. + +类似React 框架中, Component 通过一个 JSX Element 被调用; Web API 中, controller 通过 URL 请求被调用; +在 GhostOS 中, Ghost 可以通过 GhostFunc 的形式, 以函数的姿态被调用. +""" + + +class Ghost(BaseModel, Identifiable, ABC): + + @abstractmethod + def identifier(self) -> Identifier: + pass + + @classmethod + def default(cls) -> Self: + pass + + __fsm__: ClassVar[str] = None + + +G = TypeVar("G", bound=Ghost) +F = TypeVar("F", bound=Func) +S = TypeVar("S", bound=State) +M = TypeVar("M", bound=Moss) +""" G.F.S.M. => ghost finite state machine""" + + +class GhostFSM(Protocol[G, F, S, M]): + state: S + task: Task[F] + + def __init__(self, ghost: G, task: Task[F], state: S): + self.ghost = ghost + self.task = task + self.state = state + + def identifier(self) -> Identifier: + return self.ghost.identifier() + + @contextmanager + def container(self, container: Container) -> Container: + container = Container(parent=container) + # bind state + container.set(State, self.state) + container.set(self.state.__class__, self.state) + # bind task + container.set(Task, self.task) + container.set(Ghost, self.ghost) + # bind ghost + container.set(self.ghost.__class__, self.ghost) + container.set(Ghost, self.ghost) + # bind fsm + container.set(GhostFSM, self) + container.set(self.__class__, self) + yield container + container.destroy() + + @classmethod + @abstractmethod + def on_create(cls, runtime: Runtime, ctx: Context[F, S, M]) -> OP: + pass + + @abstractmethod + def on_event(self, runtime: Runtime, ctx: Context[F, S, M], event: Event) -> OP: + pass + + @abstractmethod + def run( + self, + ctx: Context[F, S, M], + history: List[Message], + inputs: Iterable[Message], + ) -> Iterable[Message]: + pass diff --git a/ghostos/core/ghost_dev_pattern/runtime.py b/ghostos/core/ghost_dev_pattern/runtime.py new file mode 100644 index 00000000..4c29f30e --- /dev/null +++ b/ghostos/core/ghost_dev_pattern/runtime.py @@ -0,0 +1,62 @@ +from abc import ABC, abstractmethod +from typing import ( + Protocol, Self, Generic, Type, TypeVar, Tuple, Callable, Union, Optional, List, Literal, Dict +) +from .concepts import Context, Task, Func +from ghostos.container import Container +from ghostos.core.session import Session, Event +from ghostos.entity import EntityMeta +from pydantic import BaseModel, Field + + +class StackFrame(BaseModel): + frame_id: str = Field( + description="Ghost Stack Frame ID", + ) + process_id: str = Field( + description="Ghost always run in a certain process" + ) + args: EntityMeta = Field( + description="the arguments passed to the ghost function" + ) + depth: int = Field( + default=0, + description="the depth of the stack" + ) + state: EntityMeta = Field( + description="the state data of the current ghost function stack frame", + ) + returns: Optional[EntityMeta] = Field( + default=None, + description="the return values of the ghost function destination", + ) + parent_id: Optional[str] = Field( + default=None, + description="parent stack frame ID which create this frame. None for root frame", + ) + event_id: Optional[str] = Field( + default=None, + description="the event id which create this frame. None for root frame", + ) + status: Literal["done", "running", "waiting", "pending", "aborted", "cancelled", "new"] = Field( + default="new", + description="the status of the stack frame", + ) + description: str = Field( + default="", + description="description of the stack frame current status" + ) + children: Dict[str, str] = Field( + default_factory=dict, + description="sub stack frame index. from sub task name to frame ids", + ) + + +class Runtime(Protocol): + container: Container + session: Session + + @abstractmethod + def get_frame(self, frame_id: str) -> Optional[StackFrame]: + pass + diff --git a/ghostos/core/ghost_dev_pattern/template.py b/ghostos/core/ghost_dev_pattern/template.py new file mode 100644 index 00000000..8916e8a4 --- /dev/null +++ b/ghostos/core/ghost_dev_pattern/template.py @@ -0,0 +1,28 @@ +from .concepts import Func, Context, State, OP +from typing import List, Union +from pydantic import BaseModel, Field +from ghostos.core.moss import Moss + + +class Chat(Func): + class Args(BaseModel): + instruction: str + + class Returns(BaseModel): + summary: str + + +class ChatState(State): + who_is_talking: str + notes: List[str] + + +class ChatMoss(Moss): + pass + + +def main(ctx: Context[Chat, ChatState, ChatMoss]) -> OP: + """ + instructions + """ + pass diff --git a/ghostos/core/ghost_dev_pattern/think.py b/ghostos/core/ghost_dev_pattern/think.py new file mode 100644 index 00000000..795fc2c0 --- /dev/null +++ b/ghostos/core/ghost_dev_pattern/think.py @@ -0,0 +1,2 @@ +from typing import Generic +from abc import ABC, abstractmethod diff --git a/ghostos/core/ghost_dev_pattern/thoughts.py b/ghostos/core/ghost_dev_pattern/thoughts.py new file mode 100644 index 00000000..4d82c7b2 --- /dev/null +++ b/ghostos/core/ghost_dev_pattern/thoughts.py @@ -0,0 +1,59 @@ +from abc import ABC, abstractmethod +from typing import Protocol, Self, Optional, Union, Type, TypeVar, Any, Dict, TypedDict +from typing_extensions import Required +from enum import Enum +from ghostos.common import Serializable +from ghostos.core.moss import Moss + + +class Thought(Serializable, Protocol): + """ + Thought describe a stateful + """ + name: str + purpose: str + task: Task + status: str + status_reason: str + created: float + updated: float + + +class ThoughtStatus(str, Enum): + NEW = "new" + PENDING = "pending" + RUNNING = "running" + SLEEPING = "sleeping" + DONE = "done" + CANCELLED = "cancelled" + ABORTED = "aborted" + + +T = TypeVar("T") + + +class Thoughts(ABC): + + @abstractmethod + def get(self, name: str) -> Thought: + pass + + @abstractmethod + def create(self, *task: Task) -> Thought: + pass + + @abstractmethod + def update(self, *task: Task) -> Thought: + pass + + @abstractmethod + def delete(self, *task: Task) -> None: + pass + + @abstractmethod + def cancel(self, name: str, reason: str) -> None: + pass + + @abstractmethod + def send(self, name: str, *messages: str) -> None: + pass diff --git a/ghostos/core/ghost_dev_pattern/variables.py b/ghostos/core/ghost_dev_pattern/variables.py new file mode 100644 index 00000000..c49c5542 --- /dev/null +++ b/ghostos/core/ghost_dev_pattern/variables.py @@ -0,0 +1,22 @@ +from abc import ABC, abstractmethod +from typing import Protocol, Self, Optional, Union, Type, TypeVar, Any, Dict, TypedDict +from typing_extensions import Required + +T = TypeVar("T") + + +class VarPtr(TypedDict): + vid: Required[str] + type: Required[str] + desc: Optional[str] + + +class Variables(ABC): + + @abstractmethod + def get(self, vid: str, expect: Optional[Type[T]] = None, force: bool = False) -> Optional[T]: + pass + + @abstractmethod + def save(self, value: Any, vid: Optional[str] = None) -> VarPtr: + pass From 9d42ac73265972e7919df33725be4220bd23e01a Mon Sep 17 00:00:00 2001 From: zhuming Date: Wed, 30 Oct 2024 00:08:28 +0800 Subject: [PATCH 057/148] dev: map my thoughts and reduce them --- ghostos/core/abcd/actor.py | 2 +- ghostos/core/abcd/agent.py | 0 ghostos/core/abcd/aifunc.py | 9 ++ ghostos/core/abcd/ghost.py | 9 ++ ghostos/core/abcd/ghostos.py | 87 ++++++++++++++ ghostos/core/abcd/ghostos_data_objects.py | 44 ++++++++ ghostos/core/abcd/ghostos_for_ai.py | 33 ++++++ ghostos/core/abcd/ghostos_for_app.py | 11 ++ ghostos/core/abcd/ghostos_for_developer.py | 125 +++++++++++++++++++++ ghostos/core/abcd/ghostos_for_user.py | 14 +++ ghostos/core/abcd/kernel.py | 96 ++++++++++++++++ 11 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 ghostos/core/abcd/agent.py create mode 100644 ghostos/core/abcd/aifunc.py create mode 100644 ghostos/core/abcd/ghost.py create mode 100644 ghostos/core/abcd/ghostos.py create mode 100644 ghostos/core/abcd/ghostos_data_objects.py create mode 100644 ghostos/core/abcd/ghostos_for_ai.py create mode 100644 ghostos/core/abcd/ghostos_for_app.py create mode 100644 ghostos/core/abcd/ghostos_for_developer.py create mode 100644 ghostos/core/abcd/ghostos_for_user.py create mode 100644 ghostos/core/abcd/kernel.py diff --git a/ghostos/core/abcd/actor.py b/ghostos/core/abcd/actor.py index d3ee5dec..a88fdab0 100644 --- a/ghostos/core/abcd/actor.py +++ b/ghostos/core/abcd/actor.py @@ -4,7 +4,7 @@ from .transport import Message from ghostos.common import Identifier -__all__ = ("Actor", "Address", "Topic", "Mail") +__all__ = ("Actor", "Address", "Topic", "Mail", "Message") class Address(Protocol): diff --git a/ghostos/core/abcd/agent.py b/ghostos/core/abcd/agent.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/core/abcd/aifunc.py b/ghostos/core/abcd/aifunc.py new file mode 100644 index 00000000..882b90f0 --- /dev/null +++ b/ghostos/core/abcd/aifunc.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod +from .ghostos import Conversable + + + +class AIFunc(ABC): + pass + + diff --git a/ghostos/core/abcd/ghost.py b/ghostos/core/abcd/ghost.py new file mode 100644 index 00000000..18ff7678 --- /dev/null +++ b/ghostos/core/abcd/ghost.py @@ -0,0 +1,9 @@ +from typing import Optional + +from .ghostos import Conversable, Runtime, Event, Operator + + +class Ghost(Conversable): + + def on_event(self, runtime: Runtime, event: Event) -> Optional[Operator]: + pass diff --git a/ghostos/core/abcd/ghostos.py b/ghostos/core/abcd/ghostos.py new file mode 100644 index 00000000..c0578203 --- /dev/null +++ b/ghostos/core/abcd/ghostos.py @@ -0,0 +1,87 @@ +from __future__ import annotations +from typing import Protocol, Optional, Iterable, List +from abc import ABC, abstractmethod +from ghostos.container import Container +from ghostos.entity import EntityMeta + + +class GhostOS(ABC): + + @abstractmethod + def converse( + self, + conversable: Conversable, + process_id: Optional[str] = None, + ) -> Conversation: + pass + + +class Conversable(Protocol): + + @abstractmethod + def on_event(self, runtime: Runtime, event: Event) -> Optional[Operator]: + pass + + +class Event(Protocol): + pass + + +class Message(Protocol): + pass + + +class Runtime(Protocol): + + @abstractmethod + def container(self) -> Container: + pass + + @abstractmethod + def frame(self) -> Frame: + pass + + @abstractmethod + def save_frame(self, frame: Frame) -> None: + pass + + +class Frame(Protocol): + frame_id: str + args: dict + state: dict + result: Optional[dict] + status: str + children: List[str] + conversable: EntityMeta + + +class Operator(Protocol): + + @abstractmethod + def run(self, runtime: Runtime) -> Optional[Operator]: + pass + + +class Conversation(Protocol): + id: str + args: dict + state: dict + result: Optional[dict] + status: str + + @abstractmethod + def messages(self) -> Iterable[Message]: + pass + + @abstractmethod + def handle_event(self, event: Event) -> Iterable[Message]: + pass + + @abstractmethod + def send_event(self, event: Event) -> None: + pass + + @abstractmethod + def pop_event(self, event: Event) -> Optional[Event]: + pass diff --git a/ghostos/core/abcd/ghostos_data_objects.py b/ghostos/core/abcd/ghostos_data_objects.py new file mode 100644 index 00000000..6422bb75 --- /dev/null +++ b/ghostos/core/abcd/ghostos_data_objects.py @@ -0,0 +1,44 @@ +from __future__ import annotations +from typing import Protocol, Optional, Iterable, List, Dict + + +class Task(Protocol): + class Args(Protocol): + pass + + class Result(Protocol): + pass + + +class Function(Protocol): + pass + + +class Message(Protocol): + pass + + +class Prompt(Protocol[Function]): + id: str + system: List[Message] + history: List[Message] + inputs: Iterable[Message] + thinks: Iterable[Message] + instructions: Iterable[Message] + tools: Dict[str, Function] + + +class Event(Protocol): + pass + + +class Turn(Protocol): + id: str + event: Optional[Event] + thinks: List[Message] + actions: List[Message] + + +class Conversation(Protocol[Function]): + id: str + turns: List[Turn] diff --git a/ghostos/core/abcd/ghostos_for_ai.py b/ghostos/core/abcd/ghostos_for_ai.py new file mode 100644 index 00000000..6fc81eae --- /dev/null +++ b/ghostos/core/abcd/ghostos_for_ai.py @@ -0,0 +1,33 @@ +from typing import Protocol, TypeVar, Optional +from abc import ABC, abstractmethod +from .ghostos_data_objects import Task + +T = TypeVar("T", bound=Task) + + +class State(Protocol[T]): + + @abstractmethod + def on_args(self, args: T.Args): + pass + + @abstractmethod + def result(self) -> Optional[T.Result]: + pass + + +S = TypeVar("S", bound=State) + + +class Moss(Protocol): + state: State + + +def think(moss: Moss) -> None: + """ + :param moss: + """ + pass + +def action(moss: Moss) -> None: + pass diff --git a/ghostos/core/abcd/ghostos_for_app.py b/ghostos/core/abcd/ghostos_for_app.py new file mode 100644 index 00000000..4f5d08bd --- /dev/null +++ b/ghostos/core/abcd/ghostos_for_app.py @@ -0,0 +1,11 @@ +from typing import Optional, Type +from .ghostos_for_ai import Task, State, Moss +from .ghostos_for_user import Agent +from .kernel import Ghost, Shell + +__task__: Optional[Type[Task]] = None +__state__: Optional[Type[State]] = None +__moss__: Optional[Type[Moss]] = None +__agent__: Optional[Agent] = None +__ghost__: Optional[Ghost] = None +__shell__: Optional[Shell] = None diff --git a/ghostos/core/abcd/ghostos_for_developer.py b/ghostos/core/abcd/ghostos_for_developer.py new file mode 100644 index 00000000..9f95a427 --- /dev/null +++ b/ghostos/core/abcd/ghostos_for_developer.py @@ -0,0 +1,125 @@ +from __future__ import annotations +from typing import Protocol, Callable, Optional, Literal, Union, Iterable, Type, TypeVar, ClassVar, Generic +from abc import ABC, abstractmethod +from pydantic import BaseModel +from ghostos.container import Container +from ghostos.common import Identifiable, Identifier +from .kernel import Ghost +from .ghostos_for_ai import Task + +MessageTypes = Union[str,] + +G = TypeVar("G", bound=Ghost) + + +class GhostOS(Protocol): + @abstractmethod + def container(self) -> Container: + pass + + @abstractmethod + def get_agent(self, name: str = "") -> Agent: + pass + + @abstractmethod + def set_agent(self, name: str, agent: Agent) -> None: + pass + + @abstractmethod + def get_ghost(self, route: str) -> GhostFunc: + pass + + @abstractmethod + def converse( + self, + agent: Union[Agent, str, None] = None, + ghost: Union[GhostFunc, str, None] = None, + args: Union[dict, BaseModel] = None, + session_id: Optional[str] = None, + parent_id: Optional[str] = None, + ) -> Conversation: + pass + + @abstractmethod + def call( + self, + ghost: G, + args: Union[dict, G.Args], + run_until_complete: bool = True, + state: Optional[State[G]] = None + ) -> State[G]: + pass + + +class Event(Protocol): + event_id: str + type: str + + +class Agent(Identifiable, ABC): + + def meta_instruction(self) -> str: + pass + + def ghost_id(self) -> Union[str, None]: + pass + + +class Turn(BaseModel): + id: str + event: Optional[Event] + messages: list[Message] + + +class Thread(Protocol): + turns: list[Turn] + + +class State(Generic[G]): + id: str + status: Literal[''] + args: G.Args + ghost: G + returns: G.Returns + + +class Conversation(Protocol[Ghost]): + @abstractmethod + def state(self) -> State: + pass + + @abstractmethod + def thread(self) -> Thread: + pass + + @abstractmethod + def update(self, args: Union[Ghost.Args, dict]) -> State: + pass + + @abstractmethod + def chat(self, *messages: MessageTypes, chunks: bool = True) -> Iterable[Message]: + pass + + @abstractmethod + def handle_event(self, event: Event, chunks: bool = True) -> Iterable[Message]: + pass + + @abstractmethod + def recv_event(self, event: Event, peek: bool = True) -> Optional[Event]: + pass + + @abstractmethod + def close(self): + pass + + @abstractmethod + def closed(self) -> bool: + pass + + @abstractmethod + def fail(self, error: Exception) -> bool: + pass + + @abstractmethod + def save(self): + pass diff --git a/ghostos/core/abcd/ghostos_for_user.py b/ghostos/core/abcd/ghostos_for_user.py new file mode 100644 index 00000000..1d721414 --- /dev/null +++ b/ghostos/core/abcd/ghostos_for_user.py @@ -0,0 +1,14 @@ +from __future__ import annotations +from typing import Protocol, Optional, Iterable, List, Dict +from abc import ABC, abstractmethod +from ghostos.container import Container, Provider +from ghostos.common import IdentifierProtocol +from ghostos.contracts.logger import LoggerItf +from .transport import Message + + +class Agent(IdentifierProtocol, Protocol): + + @abstractmethod + def meta_instruction(self) -> str: + pass diff --git a/ghostos/core/abcd/kernel.py b/ghostos/core/abcd/kernel.py new file mode 100644 index 00000000..5cd0a365 --- /dev/null +++ b/ghostos/core/abcd/kernel.py @@ -0,0 +1,96 @@ +from __future__ import annotations +from typing import Protocol, Optional, Iterable, List, Dict, Tuple, Callable, TypeVar, Union, Literal +from abc import ABC, abstractmethod + +import urllib3.util + +from ghostos.container import Container, Provider, Any +from ghostos.common import IdentifierProtocol +from ghostos.contracts.logger import LoggerItf +from ghostos.core.moss import MossRuntime +from ghostos.core.messages import Message, DefaultMessageTypes +from .ghostos_data_objects import * + + +class Frame(Protocol): + id: str + parent_id: Union[str, None] + args: dict + state: dict + status: Literal["created", "running", "finished", ""] + result: Union[dict, None] + callback_id: str + callback_data: dict + + +class Function(Protocol): + @abstractmethod + def on_event(self, runtime: Runtime, frame: Frame, event: Event) -> Operator: + pass + + +class Future(Protocol): + id: str + name: str + arguments: dict + state: dict + returns: Any + + +class Operator(Protocol): + + @abstractmethod + def run(self, runtime: Runtime) -> Optional[Operator]: + pass + + +class Thread(Protocol): + pass + + +class Runtime(Protocol): + container: Container + frame: StackFrame + event: Event + thread: Thread + logger: LoggerItf + + @abstractmethod + def send(self, messages: Iterable[Message], actions: List[Action]) -> Operator: + pass + + @abstractmethod + def wait(self) -> Operator: + pass + + @abstractmethod + def submit(self, func: Callable, *args, **kwargs) -> None: + pass + + @abstractmethod + def destroy(self): + pass + + @abstractmethod + def save(self): + pass + + +class Kernel(Protocol): + + def get_stack(self, session_id: str) -> Run: + pass + + def create_runtime(self, session_id: str) -> Runtime: + pass + + def run(self, runtime: Runtime, max_times: int) -> None: + op = runtime.ghost.on_event(runtime, runtime.event) + count = 0 + while op is not None: + if count > max_times: + raise RuntimeError(f"Operator exceeds max times {max_times}") + next_op = op.run(runtime) + count += 1 + if next_op is not None: + op = next_op From 8319686647517c6bea14ee93aa4277881f3949d5 Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 5 Nov 2024 15:02:01 +0800 Subject: [PATCH 058/148] dev: map recent devlopment, reduce them later. due to personal issues, recent development is in chaos --- .../ai_funcs/exploration_project.py | 50 -- .../swe_bench_lite/ai_funcs/file_explorer.py | 43 -- .../ai_funcs/project_explorer.py | 49 -- .../swe_bench_lite/ai_funcs/swe_task.py | 120 ---- .../ai_funcs/swe_task_manager.py | 116 ---- .../swe_bench_lite/base/culprit_file_part.py | 11 - .../swe_bench_lite/debug_localization.py | 51 -- evaluation/swe_bench_lite/django_15347.json | 14 - evaluation/swe_bench_lite/localization.py | 49 -- .../tools/directory_explorer.py | 114 ---- .../tools/environment_prepare.py | 109 ---- .../tools/file_content_operations.py | 145 ----- .../swe_bench_lite/tools/file_reader.py | 55 -- .../tools/file_system_helper.py | 0 .../swe_bench_lite/tools/graph_searcher.py | 47 -- .../tools/repo_code_navigator.py | 307 --------- .../tools/repo_context_manager.py | 118 ---- evaluation/swe_bench_lite/tools/repo_graph.py | 586 ------------------ evaluation/swe_bench_lite/tools/utils.py | 102 --- {evaluation => ghostos}/__init__.py | 0 ghostos/bootstrap.py | 12 +- ghostos/common.py | 291 +++++++-- ghostos/container.py | 51 +- ghostos/contracts/documents.py | 4 +- ghostos/contracts/variables.py | 53 ++ ghostos/core/abcd/actor.py | 75 --- ghostos/core/abcd/agent.py | 0 ghostos/core/abcd/aifunc.py | 9 - ghostos/core/abcd/ghost.py | 9 - ghostos/core/abcd/ghostos.py | 320 ++++++++-- ghostos/core/abcd/ghostos_data_objects.py | 44 -- ghostos/core/abcd/ghostos_for_ai.py | 33 - ghostos/core/abcd/ghostos_for_app.py | 11 - ghostos/core/abcd/ghostos_for_developer.py | 125 ---- ghostos/core/abcd/ghostos_for_user.py | 14 - ghostos/core/abcd/ghosts.py | 78 +++ ghostos/core/abcd/kernel.py | 96 --- ghostos/core/abcd/moss_agent.py | 170 +++++ ghostos/core/abcd/transport.py | 132 ---- ghostos/core/abcd/utils.py | 63 ++ .../adas => ghostos/core/agents}/__init__.py | 0 ghostos/core/agents/agent.py | 62 ++ ghostos/core/aifunc/driver.py | 38 +- ghostos/core/aifunc/executor.py | 4 +- ghostos/core/aifunc/func.py | 6 +- ghostos/core/aifunc/interfaces.py | 14 +- ghostos/core/ghost_dev_pattern/concepts.py | 18 +- ghostos/core/ghost_dev_pattern/ghost.py | 4 +- ghostos/core/ghostos.py | 24 +- ghostos/core/ghostos2.py | 257 ++++++++ ghostos/core/ghosts/actions.py | 12 +- ghostos/core/ghosts/assistants.py | 4 +- ghostos/core/ghosts/ghost.py | 4 +- ghostos/core/ghosts/schedulers.py | 4 +- ghostos/core/ghosts/thoughts.py | 10 +- ghostos/core/ghosts/user.py | 15 - ghostos/core/ghosts/utils.py | 22 +- ghostos/core/llms/__init__.py | 8 +- ghostos/core/llms/configs.py | 9 +- ghostos/core/llms/embedding.py | 29 - ghostos/core/llms/llm.py | 15 +- ghostos/core/llms/{chat.py => prompt.py} | 100 ++- ghostos/core/llms/tools.py | 25 +- ghostos/core/messages/__init__.py | 10 +- ghostos/core/messages/message.py | 255 ++++---- ghostos/core/messages/message_classes.py | 63 ++ ghostos/core/messages/openai.py | 16 +- ghostos/core/messages/payload.py | 37 ++ ghostos/core/messages/pipeline.py | 8 +- ghostos/core/messages/stream.py | 2 + ghostos/core/messages/transport.py | 14 +- ghostos/core/moss/abc.py | 4 +- ghostos/core/moss/examples/baseline.py | 6 +- ghostos/core/moss/examples/mem_baseline.py | 4 +- ghostos/core/moss/examples/test_suite.py | 4 +- ghostos/core/moss/prompts.py | 6 +- ghostos/core/moss/utils.py | 17 +- ghostos/core/session/__init__.py | 12 +- ghostos/core/session/events.py | 76 ++- ghostos/core/session/messenger.py | 2 +- ghostos/core/session/processes.py | 18 +- ghostos/core/session/session.py | 154 +++-- ghostos/core/session/simple_thread.py | 4 +- ghostos/core/session/tasks.py | 249 +++----- ghostos/core/session/threads.py | 99 +-- .../__init__.py => ghostos/core/wall.py | 0 ghostos/demo/aifuncs/agentic.py | 4 +- ghostos/demo/aifuncs/news.py | 4 +- ghostos/demo/aifuncs/weather.py | 4 +- .../demo/src/examples/thoughts/hello_world.py | 4 +- ghostos/framework/actions/moss_action.py | 12 +- ghostos/framework/chatpreparers/__init__.py | 2 +- .../chatpreparers/assistant_preparer.py | 6 +- ghostos/framework/eventbuses/memimpl.py | 2 +- ghostos/framework/ghostos/basic.py | 6 +- ghostos/framework/ghostos/demo_os.py | 10 +- ghostos/framework/ghosts/basic.py | 40 +- ghostos/framework/ghosts/demo.py | 6 +- ghostos/framework/llms/openai_driver.py | 16 +- ghostos/framework/llms/test_case.py | 10 +- ghostos/framework/messages/buffers.py | 4 +- ghostos/framework/messengers/defaults.py | 20 +- ghostos/framework/multitasks/basic.py | 10 +- ghostos/framework/operators/action_ops.py | 24 +- ghostos/framework/operators/event_ops.py | 22 +- ghostos/framework/processes/__init__.py | 2 +- .../framework/processes/storage_processes.py | 36 +- .../framework/prompts}/__init__.py | 0 ghostos/framework/prompts/storage_impl.py | 45 ++ ghostos/framework/repliers/basic.py | 6 +- ghostos/framework/session/basic.py | 84 +-- ghostos/framework/streams/array.py | 14 +- ghostos/framework/streams/queuestream.py | 8 +- ghostos/framework/taskflow/basic.py | 4 +- ghostos/framework/tasks/__init__.py | 2 +- ghostos/framework/tasks/storage_tasks.py | 36 +- ghostos/framework/threads/__init__.py | 2 +- ghostos/framework/threads/storage_threads.py | 34 +- ghostos/helpers/__init__.py | 2 +- ghostos/helpers/coding.py | 10 +- ghostos/helpers/hashes.py | 14 + .../base => ghostos/prototypes}/__init__.py | 0 ghostos/prototypes/aifunc/app.py | 8 +- ghostos/prototypes/console/app.py | 12 +- ghostos/prototypes/ghostfunc/driver.py | 32 +- ghostos/prototypes/mosstemp/template.py | 4 +- ghostos/prototypes/realtime/abcd.py | 66 +- ghostos/prototypes/realtime/openai/agent.py | 178 +++++- .../prototypes/realtime/openai/broadcast.py | 10 +- .../realtime/openai/conversation.py | 42 +- .../prototypes/realtime/openai/protocols.py | 32 +- ghostos/prototypes/realtime/openai/states.py | 458 ++++++++++++++ ghostos/prototypes/realtime/openai/utils.py | 15 + .../prototypes/realtime_console}/__init__.py | 0 ghostos/prototypes/streamlitapp/widgets.py | 4 +- ghostos/scripts/aifunc_test.py | 8 +- ghostos/scripts/swe_test.py | 8 +- ghostos/thoughts/basic.py | 16 +- ghostos/thoughts/directory_editor_thought.py | 8 +- ghostos/thoughts/file_editor_thought.py | 4 +- ghostos/thoughts/magic_moss_thought.py | 10 +- ghostos/thoughts/moss_thought.py | 4 +- ghostos/thoughts/pymodule_editor.py | 4 +- pyproject.toml | 1 + tests/framework/eventbuses/test_mem_impl.py | 4 +- tests/framework/messenger/test_messenger.py | 18 +- .../framework/streams/test_arr_connection.py | 10 +- tests/framework/tasks/test_storage_impl.py | 8 +- tests/python/test_class.py | 18 + tests/python/test_func.py | 8 + tests/python/test_inspect.py | 18 + tests/python/test_pydantic.py | 18 + tests/test_abc.py | 6 +- 153 files changed, 3105 insertions(+), 3852 deletions(-) delete mode 100644 evaluation/swe_bench_lite/ai_funcs/exploration_project.py delete mode 100644 evaluation/swe_bench_lite/ai_funcs/file_explorer.py delete mode 100644 evaluation/swe_bench_lite/ai_funcs/project_explorer.py delete mode 100644 evaluation/swe_bench_lite/ai_funcs/swe_task.py delete mode 100644 evaluation/swe_bench_lite/ai_funcs/swe_task_manager.py delete mode 100644 evaluation/swe_bench_lite/base/culprit_file_part.py delete mode 100644 evaluation/swe_bench_lite/debug_localization.py delete mode 100644 evaluation/swe_bench_lite/django_15347.json delete mode 100644 evaluation/swe_bench_lite/localization.py delete mode 100644 evaluation/swe_bench_lite/tools/directory_explorer.py delete mode 100644 evaluation/swe_bench_lite/tools/environment_prepare.py delete mode 100644 evaluation/swe_bench_lite/tools/file_content_operations.py delete mode 100644 evaluation/swe_bench_lite/tools/file_reader.py delete mode 100644 evaluation/swe_bench_lite/tools/file_system_helper.py delete mode 100644 evaluation/swe_bench_lite/tools/graph_searcher.py delete mode 100644 evaluation/swe_bench_lite/tools/repo_code_navigator.py delete mode 100644 evaluation/swe_bench_lite/tools/repo_context_manager.py delete mode 100644 evaluation/swe_bench_lite/tools/repo_graph.py delete mode 100644 evaluation/swe_bench_lite/tools/utils.py rename {evaluation => ghostos}/__init__.py (100%) create mode 100644 ghostos/contracts/variables.py delete mode 100644 ghostos/core/abcd/actor.py delete mode 100644 ghostos/core/abcd/agent.py delete mode 100644 ghostos/core/abcd/aifunc.py delete mode 100644 ghostos/core/abcd/ghost.py delete mode 100644 ghostos/core/abcd/ghostos_data_objects.py delete mode 100644 ghostos/core/abcd/ghostos_for_ai.py delete mode 100644 ghostos/core/abcd/ghostos_for_app.py delete mode 100644 ghostos/core/abcd/ghostos_for_developer.py delete mode 100644 ghostos/core/abcd/ghostos_for_user.py create mode 100644 ghostos/core/abcd/ghosts.py delete mode 100644 ghostos/core/abcd/kernel.py create mode 100644 ghostos/core/abcd/moss_agent.py delete mode 100644 ghostos/core/abcd/transport.py create mode 100644 ghostos/core/abcd/utils.py rename {evaluation/adas => ghostos/core/agents}/__init__.py (100%) create mode 100644 ghostos/core/agents/agent.py create mode 100644 ghostos/core/ghostos2.py delete mode 100644 ghostos/core/ghosts/user.py delete mode 100644 ghostos/core/llms/embedding.py rename ghostos/core/llms/{chat.py => prompt.py} (62%) create mode 100644 ghostos/core/messages/message_classes.py create mode 100644 ghostos/core/messages/payload.py rename evaluation/swe_bench_lite/__init__.py => ghostos/core/wall.py (100%) rename {evaluation/swe_bench_lite/ai_funcs => ghostos/framework/prompts}/__init__.py (100%) create mode 100644 ghostos/framework/prompts/storage_impl.py rename {evaluation/swe_bench_lite/base => ghostos/prototypes}/__init__.py (100%) create mode 100644 ghostos/prototypes/realtime/openai/states.py create mode 100644 ghostos/prototypes/realtime/openai/utils.py rename {evaluation/swe_bench_lite/tools => ghostos/prototypes/realtime_console}/__init__.py (100%) diff --git a/evaluation/swe_bench_lite/ai_funcs/exploration_project.py b/evaluation/swe_bench_lite/ai_funcs/exploration_project.py deleted file mode 100644 index c956953d..00000000 --- a/evaluation/swe_bench_lite/ai_funcs/exploration_project.py +++ /dev/null @@ -1,50 +0,0 @@ -from typing import Optional, List, Any - -from ghostos.core.aifunc import AIFunc, AIFuncResult -from pydantic import BaseModel, Field -from evaluation.swe_bench_lite.tools.file_reader import FileReader -from evaluation.swe_bench_lite.ai_funcs.swe_task import SWEDebugTaskCtx - -from ghostos.core.moss.decorators import cls_source_code - -@cls_source_code() -class CulpritFilePart(BaseModel): - file_path: str = Field(..., description="The path of the culprit file") - culprit_reason: str = Field(..., description="The reason why the part is the culprit") - culprit_line_start: int = Field(..., description="The start line of the culprit") - culprit_line_end: int = Field(..., description="The end line of the culprit") - confidence_percentage: int = Field(..., description="The confidence percentage of the culprit") - - -@cls_source_code() -class ExplorationProjectAIFuncResult(AIFuncResult): - found_culprits: Optional[List[CulpritFilePart]] = Field(..., description="The final culprit file parts, it can be empty if not found") - cost_steps: int = Field(..., description="The cost steps to find the culprit files") - confidence_percentage_requirement: int = Field(..., description="The requirement of least confidence percentage of the culprit file part") - confidence_percentage_of_current_plan: int = Field(..., description="The confidence percentage of the current plan") - - -__result_type__ = ExplorationProjectAIFuncResult - -file_read_func = FileReader.read_file - -class ExplorationProjectAIFunc(AIFunc): - """ - write a plan (contains AI functions) to explore & exploit the target repository, to localize the issue files - It should be a plan with loop or MCTS exploration algorithm that can be executed by AI functions - These variables must be filled with value - """ - max_steps: int = Field(default=20, description="the expectation max steps to localize the issue files") - cur_step: int = Field(default=0, description="the current step of the exploration") - thoughts: str = Field(default="", description="the brief plan of the exploration") - debug_task_ctx: Any = Field(..., description="the debug task context") - # parent_plan: Optional["ExplorationProjectAIFunc"] = Field(default=None, description="the parent plan of the exploration") - -# - - -def __aifunc_instruction__(fn: ExplorationProjectAIFunc) -> str: - return (f"ExplorationProjectAIFunc input vals: max_steps: {fn.max_steps}, cur_step: {fn.cur_step}, " - f"thoughts: {fn.thoughts}, debug_task_ctx: {fn.debug_task_ctx}") - -# diff --git a/evaluation/swe_bench_lite/ai_funcs/file_explorer.py b/evaluation/swe_bench_lite/ai_funcs/file_explorer.py deleted file mode 100644 index c194c39a..00000000 --- a/evaluation/swe_bench_lite/ai_funcs/file_explorer.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import Optional, List, Any - -from ghostos.core.aifunc import AIFuncCtx -from ghostos.core.moss import Moss as Parent -from ghostos.core.aifunc import AIFunc, AIFuncResult -from pydantic import BaseModel, Field -from evaluation.swe_bench_lite.tools.file_content_operations import FileContentOperations -from evaluation.swe_bench_lite.ai_funcs.swe_task_manager import SWEDebugTaskCtx -from evaluation.swe_bench_lite.base.culprit_file_part import CulpritFilePart - -from ghostos.core.moss.decorators import cls_source_code - - -@cls_source_code() -class FileExplorerAIFuncResult(AIFuncResult): - found_culprits: Optional[List[CulpritFilePart]] = Field(..., description="The final culprit file parts, it can be empty if not found") - confidence_percentage: Optional[int] = Field(..., description="The confidence percentage of the found_culprits is the true culprit") - file_outline: Optional[str] = Field(default="", description="the important key information or clue, or summarize of the file") - - -class FileExplorerAIFunc(AIFunc): - """ - explore & exploit the target file by reading and reasoning the file content - """ - cur_object: str = Field(default="", description="current object to explore") - - debug_task_ctx: Any = Field(..., description="the debug task context") - # parent_plan: Optional["ExplorationProjectAIFunc"] = Field(default=None, description="the parent plan of the exploration") - - file_content: str = Field(description="the content of the file") - - -class Moss(Parent): - - ai_func_ctx: AIFuncCtx - """useful to run AIFunc""" - -# - -def __aifunc_instruction__(fn: FileExplorerAIFunc) -> str: - return (f"Your current task is {fn.cur_object}, you should use the read_file method at first, culprit parts might exist in this then thought step by step to find clues about the issue. content of file: {fn.file_content}") - -# diff --git a/evaluation/swe_bench_lite/ai_funcs/project_explorer.py b/evaluation/swe_bench_lite/ai_funcs/project_explorer.py deleted file mode 100644 index 53272511..00000000 --- a/evaluation/swe_bench_lite/ai_funcs/project_explorer.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import Optional, List, Any - -from ghostos.core.aifunc import AIFuncCtx -from ghostos.core.moss import Moss as Parent -from ghostos.core.aifunc import AIFunc, AIFuncResult -from pydantic import BaseModel, Field -from evaluation.swe_bench_lite.tools.file_content_operations import FileContentOperations -from evaluation.swe_bench_lite.ai_funcs.swe_task_manager import SWEDebugTaskCtx -from evaluation.swe_bench_lite.ai_funcs.file_explorer import FileExplorerAIFunc, FileExplorerAIFuncResult -from evaluation.swe_bench_lite.base.culprit_file_part import CulpritFilePart - -from ghostos.core.moss.decorators import cls_source_code - - -@cls_source_code() -class ExplorationProjectAIFuncResult(AIFuncResult): - found_culprits: Optional[List[CulpritFilePart]] = Field(..., description="The final culprit file parts, it can be empty if not found") - cost_steps: int = Field(..., description="The cost steps to find the culprit files") - confidence_percentage_requirement: int = Field(..., description="The requirement of least confidence percentage of the culprit file part") - confidence_percentage_of_current_plan: int = Field(..., description="The confidence percentage of the current plan") - - -class ExplorationProjectAIFunc(AIFunc): - """ - write a plan (contains AI functions) to explore & exploit the target repository, to localize the issue files - It should be a plan with loop or MCTS exploration algorithm that can be executed by AI functions - These variables must be filled with value - """ - max_steps: int = Field(default=20, description="the expectation max steps to localize the issue files") - cur_step: int = Field(default=0, description="the current step of the exploration") - thoughts: str = Field(default="", description="the brief plan of the exploration") - debug_task_ctx: Any = Field(..., description="the debug task context") - # parent_plan: Optional["ExplorationProjectAIFunc"] = Field(default=None, description="the parent plan of the exploration") - - -class Moss(Parent): - - ai_func_ctx: AIFuncCtx - """useful to run AIFunc""" - - -# - -def __aifunc_instruction__(fn: ExplorationProjectAIFunc) -> str: - return (f"ExplorationProjectAIFunc input vals: max_steps: {fn.max_steps}, cur_step: {fn.cur_step}, " - f"thoughts: {fn.thoughts}, debug_task_ctx: {fn.debug_task_ctx}. You should return an ExplorationProjectAIFuncResult object。" - f"Before you return the culprit file parts, you should use the read_file method or FileExplorerAIFunc, you can also using multi-run or MCTS to explore the key directory. ") - -# diff --git a/evaluation/swe_bench_lite/ai_funcs/swe_task.py b/evaluation/swe_bench_lite/ai_funcs/swe_task.py deleted file mode 100644 index 5a6c8e8a..00000000 --- a/evaluation/swe_bench_lite/ai_funcs/swe_task.py +++ /dev/null @@ -1,120 +0,0 @@ -from typing import Optional, List -from ghostos.core.aifunc import AIFunc, AIFuncResult -from pydantic import BaseModel, Field -from ghostos.core.moss import Moss -from ghostos.core.moss import cls_source_code - -import logging -import json -import re - -# 定义正则表达式 -pattern = r'(\w+)\s\(([\w\.]+)\)' -# 编译正则表达式 -compiled_pattern = re.compile(pattern) - -def extract_info(text): - match = compiled_pattern.search(text) - if match: - method_name, class_path = match.groups() - return method_name, class_path - return None - - -class UnitTestInfo(BaseModel): - test_method_name: str = Field(..., description="The name of the test method") - test_class_name: str = Field(..., description="The name of the test class from repository content root") - -SEP = "\n====================================\n" - -@cls_source_code() -class SWEDebugTaskCtx(BaseModel): - workspace_path: str = Field(..., description="The path of the root workspace") - repo: str = Field(..., description="The target repository to debug") - instance_id: str = Field(..., description="The id of the debug task") - base_commit: str = Field(..., description="The base commit of the repository to debug") - issue_info: str = Field(..., description="The issue statement of the bug") - supplementary_issue_info: str = Field(..., description="The discussion and supplementary text of the bug") - passed_tests: List[UnitTestInfo] = Field(..., description="The list of passed unit tests before the fix") - # environment_setup_commit: str = Field(..., description="The commit used to environment setup") - - def __str__(self): - ret = (f"SWEDebugTaskCtx(workspace_path={self.workspace_path}, repo={self.repo}, instance_id={self.instance_id}, " - f"base_commit={self.base_commit}, passed_tests={self.passed_tests}, issue_info: {SEP}{repr(self.issue_info)} ") - if len(self.supplementary_issue_info) > 0: - ret += f", supplementary_issue_info: {SEP}{repr(self.supplementary_issue_info)} )" - else: - ret += ")" - return ret - - - -def _get_method_name_and_class_from_str(s: str) -> (str, str): - info = extract_info(s) - if info: - return info[0], info[1] - return "", "" - -def get_swe_debug_task_ctx(task_json_path: str="/home/llm/Project/PythonProjects/ghostos/evaluation/swe_bench_lite/django_15347.json", - workspace_path: str="/home/llm/Project/PythonProjects/workspace") -> SWEDebugTaskCtx: - with open(task_json_path, 'r') as f: - task_json = json.load(f) - - try: - # parse the task json to get the task context - repo_name = task_json['repo'].split('/')[-1] - logging.info(f"get swe debug task, repo_name: {repo_name}") - - instance_id = task_json['instance_id'] - logging.info(f"get swe debug task, instance_id: {instance_id}") - - passed_tests = [] - for test_info in task_json['PASS_TO_PASS']: - method_name, class_path = _get_method_name_and_class_from_str(test_info) - if len(method_name) > 0: - passed_tests.append(UnitTestInfo(test_method_name=method_name, test_class_name=class_path)) - logging.info(f"get swe debug task, passed_test: {method_name} in {class_path}") - - task_ctx = SWEDebugTaskCtx( - workspace_path=workspace_path, - repo=repo_name, - instance_id=instance_id, - base_commit=task_json['base_commit'], - issue_info=task_json['problem_statement'], - supplementary_issue_info=task_json['hints_text'], - passed_tests=passed_tests - ) - except Exception as e: - logging.error(f"Failed to get task context: {e}") - raise e - - return task_ctx - - -class SWETaskAIFuncResult(AIFuncResult): - """ - news result - """ - debug_task_ctx: SWEDebugTaskCtx = Field(..., description="the detailed information of the swe debug task") - - - -__result_type__ = SWETaskAIFuncResult - -class SWETaskAIFunc(AIFunc): - """ - get detailed information about the swe debug task - """ - instruction: str = Field(description="the instruction of the task, to get the detailed information of the swe debug task") - - -# - -def __aifunc_instruction__(fn: SWETaskAIFunc) -> str: - return fn.instruction - - -example = SWETaskAIFunc(instruction="Fetch the metadata of detailed swe debug task information") - -# - diff --git a/evaluation/swe_bench_lite/ai_funcs/swe_task_manager.py b/evaluation/swe_bench_lite/ai_funcs/swe_task_manager.py deleted file mode 100644 index de9ebd09..00000000 --- a/evaluation/swe_bench_lite/ai_funcs/swe_task_manager.py +++ /dev/null @@ -1,116 +0,0 @@ -from typing import Optional, List -from ghostos.core.aifunc import AIFunc, AIFuncResult -from pydantic import BaseModel, Field -from ghostos.core.moss import Moss -from ghostos.core.moss.decorators import cls_source_code - -import logging -import json -import re - -# 定义正则表达式 -pattern = r'(\w+)\s\(([\w\.]+)\)' -# 编译正则表达式 -compiled_pattern = re.compile(pattern) - -def extract_info(text): - match = compiled_pattern.search(text) - if match: - method_name, class_path = match.groups() - return method_name, class_path - return None - - -class UnitTestInfo(BaseModel): - test_method_name: str = Field(..., description="The name of the test method") - test_class_name: str = Field(..., description="The name of the test class from repository content root") - -SEP = "\n====================================\n" - -@cls_source_code() -class SWEDebugTaskCtx(BaseModel): - workspace_path: str = Field(..., description="The path of the root workspace") - repo: str = Field(..., description="The target repository to debug") - instance_id: str = Field(..., description="The id of the debug task") - base_commit: str = Field(..., description="The base commit of the repository to debug") - issue_info: str = Field(..., description="The issue statement of the bug") - supplementary_issue_info: str = Field(..., description="The discussion and supplementary text of the bug") - passed_tests: List[UnitTestInfo] = Field(..., description="The list of passed unit tests before the fix") - # environment_setup_commit: str = Field(..., description="The commit used to environment setup") - - def __str__(self): - ret = (f"SWEDebugTaskCtx(workspace_path={self.workspace_path}, repo={self.repo}, instance_id={self.instance_id}, " - f"base_commit={self.base_commit}, passed_tests={self.passed_tests}, issue_info: {SEP}{repr(self.issue_info)} ") - if len(self.supplementary_issue_info) > 0: - ret += f", supplementary_issue_info: {SEP}{repr(self.supplementary_issue_info)} )" - else: - ret += ")" - return ret - - - -def _get_method_name_and_class_from_str(s: str) -> (str, str): - info = extract_info(s) - if info: - return info[0], info[1] - return "", "" - -def get_swe_debug_task_ctx(task_json_path: str, workspace_path: str) -> SWEDebugTaskCtx: - with open(task_json_path, 'r') as f: - task_json = json.load(f) - - try: - # parse the task json to get the task context - repo_name = task_json['repo'].split('/')[-1] - logging.info(f"get swe debug task, repo_name: {repo_name}") - - instance_id = task_json['instance_id'] - logging.info(f"get swe debug task, instance_id: {instance_id}") - - passed_tests = [] - for test_info in task_json['PASS_TO_PASS']: - method_name, class_path = _get_method_name_and_class_from_str(test_info) - if len(method_name) > 0: - passed_tests.append(UnitTestInfo(test_method_name=method_name, test_class_name=class_path)) - logging.info(f"get swe debug task, passed_test: {method_name} in {class_path}") - - task_ctx = SWEDebugTaskCtx( - workspace_path=workspace_path, - repo=repo_name, - instance_id=instance_id, - base_commit=task_json['base_commit'], - issue_info=task_json['problem_statement'], - supplementary_issue_info=task_json['hints_text'], - passed_tests=passed_tests - ) - except Exception as e: - logging.error(f"Failed to get task context: {e}") - raise e - - return task_ctx - - -class SWETaskAIFuncResult(AIFuncResult): - """ - news result - """ - debug_task_ctx: SWEDebugTaskCtx = Field(..., description="the detailed information of the swe debug task") - - - -class SWETaskAIFunc(AIFunc): - """ - get detailed information about the swe debug task - """ - instruction: str = Field(description="the instruction of the task, to get the detailed information of the swe debug task") - task_json_path: str = Field(description="the path of the task json file") - workspace_path: str = Field(description="the path of the workspace") - - -# - -def __aifunc_instruction__(fn: SWETaskAIFunc) -> str: - return f"instruction: {fn.instruction} task_json_path: {fn.task_json_path}, workspace_path: {fn.workspace_path}" - -# - diff --git a/evaluation/swe_bench_lite/base/culprit_file_part.py b/evaluation/swe_bench_lite/base/culprit_file_part.py deleted file mode 100644 index 0580b794..00000000 --- a/evaluation/swe_bench_lite/base/culprit_file_part.py +++ /dev/null @@ -1,11 +0,0 @@ -from ghostos.core.moss.decorators import cls_source_code -from pydantic import BaseModel, Field - - -@cls_source_code() -class CulpritFilePart(BaseModel): - file_path: str = Field(..., description="The path of the culprit file") - culprit_reason: str = Field(..., description="The reason why the part is the culprit") - culprit_line_start: int = Field(..., description="The start line of the culprit") - culprit_line_end: int = Field(..., description="The end line of the culprit") - confidence_percentage: int = Field(..., description="The confidence percentage of the culprit") diff --git a/evaluation/swe_bench_lite/debug_localization.py b/evaluation/swe_bench_lite/debug_localization.py deleted file mode 100644 index 0e6e45e4..00000000 --- a/evaluation/swe_bench_lite/debug_localization.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import Optional, List -from ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx -from ghostos.core.moss import Moss as Parent -from pydantic import Field -from evaluation.swe_bench_lite.ai_funcs.swe_task_manager import SWETaskAIFunc, SWETaskAIFuncResult, SWEDebugTaskCtx -from evaluation.swe_bench_lite.ai_funcs.project_explorer import ExplorationProjectAIFunc, ExplorationProjectAIFuncResult -from evaluation.swe_bench_lite.tools.repo_context_manager import RepositoryContextManager, PrepareRepositoryResult -from evaluation.swe_bench_lite.tools.directory_explorer import DirectoryExplorer - - - -class AgentFn(AIFunc): - """ - AIFunc that act like an agent - """ - request: str = Field(default="", description="raw request for the agent") - - -class AgentFnResult(AIFuncResult): - """ - the result that follow the agent request - """ - issue_files: List[str] = Field(default=[], description="the file paths that caused the issue") - err: Optional[str] = Field(default=None, description="error message") - - -class Moss(Parent): - - ai_func_ctx: AIFuncCtx - """useful to run AIFunc""" - - -# - - -def __aifunc_instruction__(fn: AgentFn) -> str: - return fn.request - - -example = AgentFn( - request="Your task is localization issue files in a repository. " - "First get the information of the swe bench task" - "Then using prepare the environment to debug the repository. " - "Then localize the file caused the issue (not mock, it might be a Localization(exploration and exploitation) AIFunc). " - "If you realize some steps needs to utilizing AI to plan or implementation, utilize the AIFunc. " - "Task json file path: /home/llm/Project/PythonProjects/GhostOS/evaluation/swe_bench_lite/django_15347.json" - "workspace path: /home/llm/Project/PythonProjects/workspace/django" - # "You can create AIFunc by definition class outside of the `def main(moss)`" -) - -# diff --git a/evaluation/swe_bench_lite/django_15347.json b/evaluation/swe_bench_lite/django_15347.json deleted file mode 100644 index 4ba23db2..00000000 --- a/evaluation/swe_bench_lite/django_15347.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "repo": "django/django", - "instance_id": "django__django-15347", - "base_commit": "7c4f3965098baad2396e24501e09237425a7bd6f", - "patch": "diff --git a/django/contrib/messages/storage/cookie.py b/django/contrib/messages/storage/cookie.py\n--- a/django/contrib/messages/storage/cookie.py\n+++ b/django/contrib/messages/storage/cookie.py\n@@ -19,7 +19,7 @@ def default(self, obj):\n # Using 0/1 here instead of False/True to produce more compact json\n is_safedata = 1 if isinstance(obj.message, SafeData) else 0\n message = [self.message_key, is_safedata, obj.level, obj.message]\n- if obj.extra_tags:\n+ if obj.extra_tags is not None:\n message.append(obj.extra_tags)\n return message\n return super().default(obj)\n", - "test_patch": "diff --git a/tests/messages_tests/test_cookie.py b/tests/messages_tests/test_cookie.py\n--- a/tests/messages_tests/test_cookie.py\n+++ b/tests/messages_tests/test_cookie.py\n@@ -52,6 +52,12 @@ class CookieTests(BaseTests, SimpleTestCase):\n def stored_messages_count(self, storage, response):\n return stored_cookie_messages_count(storage, response)\n \n+ def encode_decode(self, *args, **kwargs):\n+ storage = self.get_storage()\n+ message = Message(constants.DEBUG, *args, **kwargs)\n+ encoded = storage._encode(message)\n+ return storage._decode(encoded)\n+\n def test_get(self):\n storage = self.storage_class(self.get_request())\n # Set initial data.\n@@ -168,12 +174,23 @@ def test_safedata(self):\n A message containing SafeData is keeping its safe status when\n retrieved from the message storage.\n \"\"\"\n- def encode_decode(data):\n- message = Message(constants.DEBUG, data)\n- encoded = storage._encode(message)\n- decoded = storage._decode(encoded)\n- return decoded.message\n+ self.assertIsInstance(\n+ self.encode_decode(mark_safe('Hello Django!')).message,\n+ SafeData,\n+ )\n+ self.assertNotIsInstance(\n+ self.encode_decode('Hello Django!').message,\n+ SafeData,\n+ )\n \n- storage = self.get_storage()\n- self.assertIsInstance(encode_decode(mark_safe(\"Hello Django!\")), SafeData)\n- self.assertNotIsInstance(encode_decode(\"Hello Django!\"), SafeData)\n+ def test_extra_tags(self):\n+ \"\"\"\n+ A message's extra_tags attribute is correctly preserved when retrieved\n+ from the message storage.\n+ \"\"\"\n+ for extra_tags in ['', None, 'some tags']:\n+ with self.subTest(extra_tags=extra_tags):\n+ self.assertEqual(\n+ self.encode_decode('message', extra_tags=extra_tags).extra_tags,\n+ extra_tags,\n+ )\n", - "problem_statement": "Messages framework incorrectly serializes/deserializes extra_tags when it's an empty string\nDescription\n\t\nWhen a message is serialised and then deserialised with any of the built in storage backends, then extra_tags==\"\" is converted to extra_tags==None. This is because MessageEncoder checks for the truthyness of extra_tags rather than checking it is not None.\nTo replicate this bug\n>>> from django.conf import settings\n>>> settings.configure() # Just to allow the following import\n>>> from django.contrib.messages.storage.base import Message\n>>> from django.contrib.messages.storage.cookie import MessageEncoder, MessageDecoder\n>>> original_message = Message(10, \"Here is a message\", extra_tags=\"\")\n>>> encoded_message = MessageEncoder().encode(original_message)\n>>> decoded_message = MessageDecoder().decode(encoded_message)\n>>> original_message.extra_tags == \"\"\nTrue\n>>> decoded_message.extra_tags is None\nTrue\nEffect of the bug in application behaviour\nThis error occurred in the wild with a template tag similar to the following:\n{% if x not in message.extra_tags %}\nWhen the message was displayed as part of a redirect, it had been serialised and deserialized which meant that extra_tags was None instead of the empty string. This caused an error.\nIt's important to note that this bug affects all of the standard API (messages.debug, messages.info etc. all have a default value of extra_tags equal to \"\").\n", - "hints_text": "", - "created_at": "2022-01-22T01:56:48Z", - "version": "4.1", - "FAIL_TO_PASS": "[\"A message's extra_tags attribute is correctly preserved when retrieved\"]", - "PASS_TO_PASS": "[\"test_add (messages_tests.test_cookie.CookieTests)\", \"test_add_lazy_translation (messages_tests.test_cookie.CookieTests)\", \"test_add_update (messages_tests.test_cookie.CookieTests)\", \"test_context_processor_message_levels (messages_tests.test_cookie.CookieTests)\", \"CookieStorage honors SESSION_COOKIE_DOMAIN, SESSION_COOKIE_SECURE, and\", \"test_custom_tags (messages_tests.test_cookie.CookieTests)\", \"test_default_level (messages_tests.test_cookie.CookieTests)\", \"test_existing_add (messages_tests.test_cookie.CookieTests)\", \"test_existing_add_read_update (messages_tests.test_cookie.CookieTests)\", \"Reading the existing storage doesn't cause the data to be lost.\", \"test_existing_read_add_update (messages_tests.test_cookie.CookieTests)\", \"With the message middleware enabled, messages are properly stored and\", \"test_get (messages_tests.test_cookie.CookieTests)\", \"test_get_bad_cookie (messages_tests.test_cookie.CookieTests)\", \"test_high_level (messages_tests.test_cookie.CookieTests)\", \"A complex nested data structure containing Message\", \"test_level_tag (messages_tests.test_cookie.CookieTests)\", \"test_low_level (messages_tests.test_cookie.CookieTests)\", \"If the data exceeds what is allowed in a cookie, older messages are\", \"test_message_rfc6265 (messages_tests.test_cookie.CookieTests)\", \"When the middleware is disabled, an exception is raised when one\", \"When the middleware is disabled, an exception is not raised\", \"Messages persist properly when multiple POSTs are made before a GET.\", \"test_no_update (messages_tests.test_cookie.CookieTests)\", \"test_repr (messages_tests.test_cookie.CookieTests)\", \"A message containing SafeData is keeping its safe status when\", \"test_settings_level (messages_tests.test_cookie.CookieTests)\", \"test_tags (messages_tests.test_cookie.CookieTests)\", \"test_with_template_response (messages_tests.test_cookie.CookieTests)\"]", - "environment_setup_commit": "647480166bfe7532e8c471fef0146e3a17e6c0c9" -} \ No newline at end of file diff --git a/evaluation/swe_bench_lite/localization.py b/evaluation/swe_bench_lite/localization.py deleted file mode 100644 index f9f5074e..00000000 --- a/evaluation/swe_bench_lite/localization.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import Optional, List -from ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx -from ghostos.core.moss import Moss as Parent -from pydantic import Field -from evaluation.swe_bench_lite.ai_funcs.swe_task import SWETaskAIFunc, SWETaskAIFuncResult, SWEDebugTaskCtx -from evaluation.swe_bench_lite.ai_funcs.exploration_project import ExplorationProjectAIFunc, ExplorationProjectAIFuncResult -from evaluation.swe_bench_lite.tools.environment_prepare import prepare_repository_for_debug, reset_repository_after_debug - -class AgentFn(AIFunc): - """ - AIFunc that act like an agent - """ - request: str = Field(description="raw request for the agent") - - -class AgentFnResult(AIFuncResult): - """ - the result that follow the agent request - """ - issue_files: List[str] = Field(description="the file paths that caused the issue") - err: Optional[str] = Field(default=None, description="error message") - - -__result_type__ = AgentFnResult - - -class Moss(Parent): - - ai_func_ctx: AIFuncCtx - """useful to run AIFunc""" - - -# - - -def __aifunc_instruction__(fn: AgentFn) -> str: - return fn.request - - -example = AgentFn( - request="Your task is localization issue files in a repository. " - "First get the information of the swe bench task" - "Then using prepare the environment to debug the repository. " - "Then localize the file caused the issue (not mock, it might be a Localization(exploration and exploitation) AIFunc). " - "If you realize some steps needs to utilizing AI to plan or implementation, utilize the AIFunc. " - # "You can create AIFunc by definition class outside of the `def main(moss)`" -) - -# diff --git a/evaluation/swe_bench_lite/tools/directory_explorer.py b/evaluation/swe_bench_lite/tools/directory_explorer.py deleted file mode 100644 index 18fd5760..00000000 --- a/evaluation/swe_bench_lite/tools/directory_explorer.py +++ /dev/null @@ -1,114 +0,0 @@ -import os -import logging -from typing import List -from ghostos.core.moss.decorators import cls_outline - - -@cls_outline() -class DirectoryExplorer: - """ - Must use this class when you want to explore directory in file system - """ - - def __init__(self, workspace_root: str): - """ - :param workspace_root: the root path of the workspace, all operations will be restricted within this root - """ - self.workspace_root = workspace_root - - - def is_path_within_workspace(self, abs_file_path: str) -> bool: - """ - Check if the given absolute file path is within the workspace root. - """ - ret = abs_file_path.startswith(self.workspace_root) - if not ret: - logging.warning(f"#DirectoryExplorer: The given absolute file path is not within the workspace root: {abs_file_path}") - return ret - - def tree(self, directory, expand_depth=1, max_show_items=10, file_extensions_whitelist=None) -> str: - """ - Efficient for explore directory structure. More token-efficient than 'tree' or 'os.listdir()' - - :param directory: The target directory path to explore - :param expand_depth: Controls the depth of directory expansion, -1 means expand all levels - :param max_show_items: Maximum number of items to display - :param file_extensions_whitelist: List of file extensions to display, '*' means display all files. directories are always displayed - :return: A string representation of the directory structure - """ - if not self.is_path_within_workspace(directory): - return f"Error: The directory {directory} is not within the workspace." - - result = [f"structure of {directory}:"] - total_items = [0] - - def tree_inner(directory, expand_depth, indent, max_show_items, file_extensions_whitelist, current_item_count=[0]): - if not self.is_path_within_workspace(directory): - return - - if expand_depth == 0 or current_item_count[0] >= max_show_items: - return - - if file_extensions_whitelist is None: - file_extensions_whitelist = ['*'] - - # Directories to exclude from the output - exclude_directories = {'.git', '.idea', '__pycache__', '.pytest_cache', '.github', '.gitignore', '.gitattributes', - '.tx', 'LICENSE', 'LICENSE.python', 'AUTHORS', 'CONTRIBUTING.rst'} - - try: - items = os.listdir(directory) - items.sort() - except Exception as e: - print(f"{indent}Error accessing directory {directory}: {e}") - return - - # Exclude the directories specified in exclude_directories - items = [item for item in items if item not in exclude_directories] - - for item in items: - total_items[0] += 1 - if current_item_count[0] >= max_show_items: - continue - - path = os.path.join(directory, item) - if not self.is_path_within_workspace(path): - continue - - if os.path.isdir(path): - result.append(f"{indent}{item}/") - current_item_count[0] += 1 - tree_inner(path, expand_depth - 1, indent + " ", max_show_items, file_extensions_whitelist, current_item_count) - else: - if '*' in file_extensions_whitelist or any(item.endswith(ext) for ext in file_extensions_whitelist): - result.append(f"{indent}{item}") - current_item_count[0] += 1 - - tree_inner(directory, expand_depth, "", max_show_items, file_extensions_whitelist) - - if total_items[0] > max_show_items: - result.append(f"... {total_items[0] - max_show_items} more items") - - return "\n".join(result) - - def list_files_in_dir(self, directory: str) -> List[str]: - """ - List all files in the specified directory (excluding subdirectories). - - :param directory: Path to the directory to list files from - :return: List of all files in the directory - """ - if not self.is_path_within_workspace(directory): - return [] - return [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f)) and self.is_path_within_workspace(os.path.join(directory, f))] - - def list_dirs_in_dir(self, directory: str) -> List[str]: - """ - List all subdirectories in the specified directory. - - :param directory: Path to the directory to list subdirectories from - :return: List of all subdirectories in the directory - """ - if not self.is_path_within_workspace(directory): - return [] - return [d for d in os.listdir(directory) if os.path.isdir(os.path.join(directory, d)) and self.is_path_within_workspace(os.path.join(directory, d))] diff --git a/evaluation/swe_bench_lite/tools/environment_prepare.py b/evaluation/swe_bench_lite/tools/environment_prepare.py deleted file mode 100644 index 8eb66886..00000000 --- a/evaluation/swe_bench_lite/tools/environment_prepare.py +++ /dev/null @@ -1,109 +0,0 @@ -import os -import logging - -from typing import Optional, List, Tuple, Dict -from enum import Enum - -from evaluation.swe_bench_lite.ai_funcs.swe_task import SWEDebugTaskCtx -from ghostos.core.moss.decorators import cls_definition, cls_source_code - - -# create a error reason enum for prepare_repository_for_debug -@cls_definition() -class PrepareRepositoryResult(Enum): - PREPARE_SUCCESS = "Prepare repository successfully" - REPO_NOT_FOUND = "Repository not found" - REPO_NOT_DIR = "Repository is not a directory" - REPO_CHECKOUT_FAILED = "Failed to checkout to base commit" - REPO_CHECKOUT_BRANCH_FAILED = "Failed to checkout to a new branch" - REPO_BRANCH_DEL_FAILED = "Failed to delete the new branch" - - -def prepare_repository_for_debug(swe_debug_task_ctx: SWEDebugTaskCtx) -> PrepareRepositoryResult: - """ - Prepare the repository for debugging (contains git operations) - But notice after debugging work, you MUST invoke reset_repository_after_debug to reset the repository - :param swe_debug_task_ctx: the debug task context - :return: the result of the preparation - """ - target_path = os.path.join(swe_debug_task_ctx.workspace_path, swe_debug_task_ctx.repo) - if not os.path.exists(target_path): - logging.error(f"Repository not found: {target_path}") - return PrepareRepositoryResult.REPO_NOT_FOUND - if not os.path.isdir(target_path): - logging.error(f"Repository is not a directory: {target_path}") - return PrepareRepositoryResult.REPO_NOT_DIR - - os.chdir(target_path) - logging.info(f"cd to the target repository: {target_path} succeed") - - # discard all changes - discard_cmd = "git checkout -- ." - if os.system(discard_cmd) != 0: - logging.error(f"Failed to discard all changes at initialize") - return PrepareRepositoryResult.REPO_CHECKOUT_FAILED - - # checkout to main branch - checkout_main_cmd = "git checkout main" - if os.system(checkout_main_cmd) != 0: - logging.error(f"Failed to checkout to main branch") - return PrepareRepositoryResult.REPO_CHECKOUT_FAILED - - # if target branch exist, delete it first - delete_branch_cmd = f"git branch -D {swe_debug_task_ctx.instance_id}" - if os.system(delete_branch_cmd) != 0: - logging.error(f"Failed to delete the target branch: {swe_debug_task_ctx.instance_id}") - return PrepareRepositoryResult.REPO_BRANCH_DEL_FAILED - - # checkout to a new branch - checkout_branch_cmd = f"git checkout -b {swe_debug_task_ctx.instance_id}" - if os.system(checkout_branch_cmd) != 0: - logging.error(f"Failed to checkout to a new branch: {swe_debug_task_ctx.instance_id}") - return PrepareRepositoryResult.REPO_CHECKOUT_BRANCH_FAILED - - # checkout to the base commit - checkout_cmd = f"git checkout {swe_debug_task_ctx.base_commit}" - if os.system(checkout_cmd) != 0: - logging.error(f"Failed to checkout to base commit: {swe_debug_task_ctx.base_commit}") - return PrepareRepositoryResult.REPO_CHECKOUT_FAILED - - logging.info(f"Prepare repository for debug succeed") - return PrepareRepositoryResult.PREPARE_SUCCESS - - - -def reset_repository_after_debug(swe_debug_task_ctx: SWEDebugTaskCtx) -> None: - """ - Reset the repository after debugging - :param swe_debug_task_ctx: the debug task context - """ - target_path = os.path.join(swe_debug_task_ctx.workspace_path, swe_debug_task_ctx.repo) - if not os.path.exists(target_path) or not os.path.isdir(target_path): - logging.error(f"reset_repository_after_debug Repository not found or not a directory: {target_path}") - return - - # cd the target repository - os.chdir(target_path) - logging.info(f"reset_repository_after_debug cd to the target repository: {target_path} succeed") - - # discard all changes - discard_cmd = "git checkout -- ." - if os.system(discard_cmd) != 0: - logging.error(f"Failed to discard all changes") - return - - # checkout back to main - checkout_main_cmd = "git checkout main" - if os.system(checkout_main_cmd) != 0: - logging.error(f"Failed to checkout back to main") - return - - # delete the new branch - delete_branch_cmd = f"git branch -D {swe_debug_task_ctx.instance_id}" - if os.system(delete_branch_cmd) != 0: - logging.error(f"Failed to delete the new branch: {swe_debug_task_ctx.instance_id}") - return - - logging.info(f"Reset repository after debug succeed") - - diff --git a/evaluation/swe_bench_lite/tools/file_content_operations.py b/evaluation/swe_bench_lite/tools/file_content_operations.py deleted file mode 100644 index eaa2c0bd..00000000 --- a/evaluation/swe_bench_lite/tools/file_content_operations.py +++ /dev/null @@ -1,145 +0,0 @@ -import os -import math -import logging -from typing import List -from ghostos.core.moss.decorators import cls_outline - - -@cls_outline() -class FileContentOperations: - """ - Must use this class when you want to read/find/write file in file system - """ - max_line_of_file = 20000 - - def __init__(self, workspace_root: str): - """ - :param workspace_root: the root path of the workspace, all operations will be restricted within this root - """ - self.workspace_root = workspace_root - - def is_path_within_workspace(self, abs_file_path: str) -> bool: - """ - Check if the given absolute file path is within the workspace root. - """ - ret = abs_file_path.startswith(self.workspace_root) - if not ret: - logging.warning(f"#FileContentOperations: The given absolute file path is not within the workspace root: {abs_file_path}") - return ret - - @staticmethod - def __get_digit_size(number: int) -> int: - ret = 0 - while number >= 1: - number /= 10 - ret += 1 - return ret - - def read_file(self, abs_path, page_number=1, page_size=500) -> str: - """ - Read the file content with page number and page size(page_number is from 1 to n) - """ - - if not self.is_path_within_workspace(abs_path): - return f"It must be a valid file path within the workspace: {self.workspace_root}" - - is_valid_file_path = os.path.exists(abs_path) and os.path.isfile(abs_path) - if not is_valid_file_path: - print(f"Path exists: {os.path.exists(abs_path)}") # Debug print - print(f"Is file: {os.path.isfile(abs_path)}") # Debug print - - return "It's not a valid file path" - - with open(abs_path, "r") as f: - lines = f.readlines() - - if len(lines) > FileContentOperations.max_line_of_file: - return f"The number of line {len(lines)} exceeded our limit: {FileContentOperations.max_line_of_file}" - - digit_size = FileContentOperations.__get_digit_size(len(lines)) - - page_numbers = math.ceil(len(lines) / page_size) - if page_numbers < page_number: - return f"page_number: {page_number} outbound the max page number ({page_numbers}) of this file " - output_lines = [] - for i in range((page_number - 1) * page_size, min(page_number * page_size, len(lines))): - output_lines.append(f'{i:0{digit_size}}|' + lines[i].rstrip()) - - last_sentence = f"[Showing page {page_number}/{page_numbers} , specify the page_number to see more content in this file]" - output_lines.append(last_sentence) - - return '\n'.join(output_lines) - - def write_file(self, abs_path: str, content: str) -> str: - if not self.is_path_within_workspace(abs_path): - return f"It must be a valid file path within the workspace: {self.workspace_root}" - - with open(abs_path, "w") as f: - f.write(content) - - return "" - - - def find_line_numbers_containing_string(self, abs_filepath: str, search_string: str) -> List[int]: - """ - Find the line numbers of the specific string in the file - """ - if not self.is_path_within_workspace(abs_filepath): - return [] - - with open(abs_filepath, 'r') as file: - lines = file.readlines() - return [i + 1 for i, line in enumerate(lines) if search_string in line] - - def find_files_containing_string_in_directory(self, directory: str, search_string: str) -> List[str]: - """ - Find the file paths that contain the specific string in the directory - """ - if not self.is_path_within_workspace(directory): - return [] - - file_paths = [] - for root, dirs, files in os.walk(directory): - for file in files: - abs_filepath = os.path.join(root, file) - if self.is_text_file(abs_filepath): - try: - with open(abs_filepath, 'r', encoding='utf-8') as f: - if search_string in f.read(): - file_paths.append(abs_filepath) - except UnicodeDecodeError: - # Skip files that can't be decoded with UTF-8 - continue - - return file_paths - - def is_text_file(self, filepath: str) -> bool: - """ - Check if a file is likely to be a text file based on its content and extension - """ - # Check file extension first - text_extensions = {'.txt', '.py', '.js', '.html', '.css', '.json', '.xml', '.yml', '.yaml', '.md', - '.go', '.java', '.c', '.cpp', '.h', '.hpp', '.rs', '.rb', '.php', '.ts', - '.scala', '.kt', '.swift', '.m', '.sh', '.bat', '.ps1', '.sql', '.r', '.pl', - '.cfg', '.ini', '.conf', '.toml', '.rst', '.tex', '.log', '.gitignore', - '.env', '.properties', '.gradle', '.pom', '.sbt', '.dockerfile', '.makefile'} - - if os.path.splitext(filepath)[1].lower() in text_extensions: - return True - - # Check if the file is a markdown or restructured text file without extension - if os.path.basename(filepath).lower() in {'readme', 'license', 'authors', 'contributing'}: - return True - - try: - with open(filepath, 'rb') as f: - return not self.is_binary_string(f.read(1024)) - except IOError: - return False - - def is_binary_string(self, bytes_to_check: bytes) -> bool: - """ - Check if a byte string is likely to be binary - """ - textchars = bytearray({7,8,9,10,12,13,27} | set(range(0x20, 0x100)) - {0x7f}) - return bool(bytes_to_check.translate(None, textchars)) diff --git a/evaluation/swe_bench_lite/tools/file_reader.py b/evaluation/swe_bench_lite/tools/file_reader.py deleted file mode 100644 index a2ea2c9c..00000000 --- a/evaluation/swe_bench_lite/tools/file_reader.py +++ /dev/null @@ -1,55 +0,0 @@ -import os -import math -from ghostos.core.moss.decorators import cls_definition - - -@cls_definition() -class FileReader: - """ - Must use this when you want to read source file in file system - """ - max_line_of_file = 20000 - - @staticmethod - def __get_digit_size(number: int) -> int: - ret = 0 - while number >= 1: - number /= 10 - ret += 1 - return ret - - @staticmethod - def read_file(path, page_number=1, page_size=500) -> str: - """ - page_number is from 1 to n - """ - abs_path = os.path.abspath(path) - print(f"Current working directory: {os.getcwd()}") # Debug print - print(f"Checking file path: {path}") # Debug print - print(f"Absolute file path: {abs_path}") # Debug print - is_valid_file_path = os.path.exists(abs_path) and os.path.isfile(abs_path) - if not is_valid_file_path: - print(f"Path exists: {os.path.exists(abs_path)}") # Debug print - print(f"Is file: {os.path.isfile(abs_path)}") # Debug print - - return "It's not a valid file path" - - with open(abs_path, "r") as f: - lines = f.readlines() - - if len(lines) > FileReader.max_line_of_file: - return f"The number of line {len(lines)} exceeded our limit: {FileReader.max_line_of_file}" - - digit_size = FileReader.__get_digit_size(len(lines)) - - page_numbers = math.ceil(len(lines) / page_size) - if page_numbers < page_number: - return f"page_number: {page_number} outbound the max page number ({page_numbers}) of this file " - output_lines = [] - for i in range((page_number - 1) * page_size, min(page_number * page_size, len(lines))): - output_lines.append(f'{i:0{digit_size}}|' + lines[i].rstrip()) - - last_sentence = f"[Showing page {page_number}/{page_numbers} , specify the page_number to see more content in this file]" - output_lines.append(last_sentence) - - return '\n'.join(output_lines) diff --git a/evaluation/swe_bench_lite/tools/file_system_helper.py b/evaluation/swe_bench_lite/tools/file_system_helper.py deleted file mode 100644 index e69de29b..00000000 diff --git a/evaluation/swe_bench_lite/tools/graph_searcher.py b/evaluation/swe_bench_lite/tools/graph_searcher.py deleted file mode 100644 index 8f838ba1..00000000 --- a/evaluation/swe_bench_lite/tools/graph_searcher.py +++ /dev/null @@ -1,47 +0,0 @@ -import networkx as nx - -class RepoSearcher: - def __init__(self, graph): - self.graph = graph - - def one_hop_neighbors(self, query): - # get one-hop neighbors from networkx graph - return list(self.graph.neighbors(query)) - - def two_hop_neighbors(self, query): - # get two-hop neighbors from networkx graph - one_hop = self.one_hop_neighbors(query) - two_hop = [] - for node in one_hop: - two_hop.extend(self.one_hop_neighbors(node)) - return list(set(two_hop)) - - def dfs(self, query, depth): - # perform depth-first search on networkx graph - visited = [] - stack = [(query, 0)] - while stack: - node, level = stack.pop() - if node not in visited: - visited.append(node) - if level < depth: - stack.extend( - [(n, level + 1) for n in self.one_hop_neighbors(node)] - ) - return visited - - def bfs(self, query, depth): - # perform breadth-first search on networkx graph - visited = [] - queue = [(query, 0)] - while queue: - node, level = queue.pop(0) - if node not in visited: - visited.append(node) - if level < depth: - queue.extend( - [(n, level + 1) for n in self.one_hop_neighbors(node)] - ) - return visited - - diff --git a/evaluation/swe_bench_lite/tools/repo_code_navigator.py b/evaluation/swe_bench_lite/tools/repo_code_navigator.py deleted file mode 100644 index 2d052617..00000000 --- a/evaluation/swe_bench_lite/tools/repo_code_navigator.py +++ /dev/null @@ -1,307 +0,0 @@ -import os -import tree_sitter -from pydantic import BaseModel, Field -from typing import List, Optional -from tree_sitter import Language, Parser -from tree_sitter_languages import get_language, get_parser -import pylint.lint -from pylint.reporters.text import TextReporter -from io import StringIO - - -_PythonParser = get_parser('python') - - -class CodeLocation(BaseModel): - file_path: str = Field( - description="The full path to the file where the definition is found" - ) - line_number: int = Field( - description="The line number where the definition starts (1-indexed)" - ) - column_number: int = Field( - description="The column number where the definition starts (1-indexed)" - ) - context: List[str] = Field( - description="A list of strings representing the lines of code around the definition, typically including a few lines before and after for context" - ) - - def __str__(self): - context_str = '\n'.join(f" {line.rstrip()}" for line in self.context) - return f"Definition found:\n" \ - f" File: {self.file_path}\n" \ - f" Line: {self.line_number}, Column: {self.column_number}\n" \ - f" Context:\n{context_str}" - - - -class CodeReference(BaseModel): - file_path: str = Field( - description="The full path to the file where the reference is found" - ) - line_number: int = Field( - description="The line number where the reference is found (1-indexed)" - ) - column_number: int = Field( - description="The column number where the reference starts (1-indexed)" - ) - context: str = Field( - description="The line of code containing the reference" - ) - - def __str__(self): - return f"Reference found:\n" \ - f" File: {self.file_path}\n" \ - f" Line: {self.line_number}, Column: {self.column_number}\n" \ - f" Context: {self.context.strip()}" - - - -class RepositoryCodeNavigator: - def __init__(self, repo_path): - self.parser = _PythonParser - self.repo_path = os.path.abspath(repo_path) - self.file_trees = {} - self._parse_repository() - - def _parse_repository(self): - for root, _, files in os.walk(self.repo_path): - for file in files: - if file.endswith('.py'): - file_path = os.path.relpath(os.path.join(root, file), self.repo_path) - with open(os.path.join(self.repo_path, file_path), 'r') as f: - code = f.read() - tree = self.parser.parse(bytes(code, 'utf8')) - self.file_trees[file_path] = tree - - def go_to_definition(self, file_path, line_number, target_string) -> Optional[CodeLocation]: - # Convert relative path to absolute path - abs_file_path = os.path.join(self.repo_path, file_path) - - # First, try to find the definition in the current file - definition = self._find_definition_in_file(abs_file_path, line_number, target_string) - if definition: - # Convert absolute path back to relative path in the result - definition.file_path = os.path.relpath(definition.file_path, self.repo_path) - return definition - - # If not found, search in all files - for rel_path, tree in self.file_trees.items(): - definition = self._find_definition_in_tree(tree, target_string) - if definition: - # Path is already relative in this case - return definition - - return None - - def find_references(self, file_path, line_number, target_string) -> List[CodeReference]: - """ - TODO: IDE的find_usages功能并不需要每次都遍历所有文件,只需要遍历与当前文件相关的文件即可。可以通过文件的import关系来确定。 - """ - references = [] - - # Search in all files - for rel_path, tree in self.file_trees.items(): - # Read the content of the file - with open(os.path.join(self.repo_path, rel_path), 'r') as file: - content = file.read() - - root_node = tree.root_node - cursor = root_node.walk() - - reached_root = False - while not reached_root: - if cursor.node.type == 'identifier' and cursor.node.text.decode('utf8') == target_string: - start_line = cursor.node.start_point[0] + 1 - start_column = cursor.node.start_point[1] + 1 - context_line = content.splitlines()[start_line - 1] - references.append(CodeReference( - file_path=rel_path, # Use relative path - line_number=start_line, - column_number=start_column, - context=context_line - )) - - if not cursor.goto_first_child(): - while not cursor.goto_next_sibling(): - if not cursor.goto_parent(): - reached_root = True - break - return references - - def find_implementations(self, target_string: str) -> List[CodeLocation]: - implementations = [] - for rel_path, tree in self.file_trees.items(): - root_node = tree.root_node - implementation_nodes = self._find_implementation_nodes(root_node, target_string) - - if implementation_nodes: - file_path = os.path.join(self.repo_path, rel_path) - with open(file_path, 'r') as file: - content = file.read() - lines = content.splitlines() - - for node in implementation_nodes: - start_line = node.start_point[0] + 1 - start_column = node.start_point[1] + 1 - - context_start = max(0, start_line - 3) - context_end = min(len(lines), start_line + 4) - context = lines[context_start:context_end] - - implementations.append(CodeLocation( - file_path=rel_path, - line_number=start_line, - column_number=start_column, - context=context - )) - - return implementations - - def _find_implementation_nodes(self, root_node, target_string): - implementation_nodes = [] - cursor = root_node.walk() - - reached_root = False - while not reached_root: - if cursor.node.type in ['function_definition', 'class_definition', 'method_definition']: - name_node = cursor.node.child_by_field_name('name') - if name_node and name_node.text.decode('utf8') == target_string: - implementation_nodes.append(cursor.node) - - if not cursor.goto_first_child(): - while not cursor.goto_next_sibling(): - if not cursor.goto_parent(): - reached_root = True - break - - return implementation_nodes - - def _find_definition_in_file(self, file_path, line_number, target_string): - with open(file_path, 'r') as file: - content = file.read() - - tree = self.parser.parse(bytes(content, 'utf8')) - root_node = tree.root_node - - target_node = self._find_node_by_line_and_string(root_node, line_number, target_string) - - if target_node: - start_line = target_node.start_point[0] - end_line = target_node.end_point[0] - - # Capture more lines before and after for context - context_start = max(0, start_line - 3) - context_end = min(len(content.splitlines()), end_line + 4) - - context_lines = content.splitlines()[context_start:context_end] - - return CodeLocation( - file_path=file_path, - line_number=start_line + 1, - column_number=target_node.start_point[1] + 1, - context=context_lines - ) - - return None - - def _find_definition_in_tree(self, tree, target_string) -> Optional[CodeLocation]: - root_node = tree.root_node - definition_node = self._find_definition(root_node, target_string) - if definition_node: - file_path = next((path for path, t in self.file_trees.items() if t == tree), None) - if file_path: - with open(os.path.join(self.repo_path, file_path), 'r') as file: - lines = file.readlines() - def_line = definition_node.start_point[0] + 1 - def_column = definition_node.start_point[1] + 1 - context = lines[max(0, def_line-3):def_line+2] - return CodeLocation( - file_path=file_path, # This is already a relative path - line_number=def_line, - column_number=def_column, - context=context - ) - return None - - def _find_node_by_line_and_string(self, root_node, line_number, target_string): - for node in root_node.children: - if node.start_point[0] + 1 <= line_number <= node.end_point[0] + 1: - cursor = node.walk() - reached_end = False - while not reached_end: - current_node = cursor.node - if current_node.type in ['function_definition', 'class_definition', 'method_definition'] and \ - current_node.child_by_field_name('name').text.decode('utf8') == target_string: - return current_node - if not cursor.goto_first_child(): - while not cursor.goto_next_sibling(): - if not cursor.goto_parent(): - reached_end = True - break - return None - - def _find_definition_from_node(self, start_node, target_string): - current = start_node - while current.parent: - current = current.parent - if current.type in ['function_definition', 'class_definition', 'assignment', 'expression_statement']: - # 检查是否是目标的定义 - if self._is_definition_of(current, target_string): - return current - return None - - def _is_definition_of(self, node, target_string): - if node.type in ['function_definition', 'class_definition']: - name_node = node.child_by_field_name('name') - return name_node and name_node.text.decode('utf8') == target_string - elif node.type == 'assignment': - left_side = node.child_by_field_name('left') - if left_side: - if left_side.type == 'identifier': - return left_side.text.decode('utf8') == target_string - elif left_side.type == 'pattern_list': - # Handle multiple assignments - for child in left_side.children: - if child.type == 'identifier' and child.text.decode('utf8') == target_string: - return True - elif node.type == 'expression_statement': - child = node.child_by_field_name('expression') - if child and child.type == 'assignment': - return self._is_definition_of(child, target_string) - elif node.type == 'identifier' and node.text.decode('utf8') == target_string: - # Check if the identifier is part of an assignment - parent = node.parent - if parent and parent.type == 'assignment': - return True - return False - - def _find_definition(self, root_node, target_string): - cursor = root_node.walk() - - reached_root = False - while not reached_root: - if cursor.node.type in ['function_definition', 'class_definition', 'assignment', 'expression_statement']: - if self._is_definition_of(cursor.node, target_string): - return cursor.node - - if not cursor.goto_first_child(): - while not cursor.goto_next_sibling(): - if not cursor.goto_parent(): - reached_root = True - break - return None - - - -# 使用示例 -if __name__ == "__main__": - repo_path = '/home/llm/Project/PythonProjects/auto-code-rover' - helper = RepositoryCodeNavigator(repo_path) - - file_path = 'app/api/manage.py' # Now using relative path - target_string = 'SearchManager' - - definition = helper.go_to_definition(file_path, 81, target_string) - print(f"Definition: {definition}") - diff --git a/evaluation/swe_bench_lite/tools/repo_context_manager.py b/evaluation/swe_bench_lite/tools/repo_context_manager.py deleted file mode 100644 index c65a9ec4..00000000 --- a/evaluation/swe_bench_lite/tools/repo_context_manager.py +++ /dev/null @@ -1,118 +0,0 @@ -import os -import logging -from typing import Optional, List, Tuple, Dict -from enum import Enum -import atexit -import subprocess -from evaluation.swe_bench_lite.ai_funcs.swe_task_manager import SWEDebugTaskCtx -from ghostos.core.moss.decorators import cls_definition, cls_source_code, cls_outline - - -@cls_source_code() -class PrepareRepositoryResult(Enum): - PREPARE_SUCCESS = "Prepare repository successfully" - REPO_NOT_FOUND = "Repository not found" - REPO_NOT_DIR = "Repository is not a directory" - REPO_CHECKOUT_FAILED = "Failed to checkout to base commit" - REPO_CHECKOUT_BRANCH_FAILED = "Failed to checkout to a new branch" - REPO_BRANCH_DEL_FAILED = "Failed to delete the new branch" - - -@cls_outline() -class RepositoryContextManager: - """ - This class manages the repository context for debugging. - You should call prepare_repository_for_debug() before your business logic, and call reset_repository_after_debug() after you're ALL done. - """ - def __init__(self, debug_task_ctx: SWEDebugTaskCtx): - self.debug_task_ctx = debug_task_ctx - atexit.register(self.reset_repository_after_debug) - - def prepare_repository_for_debug(self) -> PrepareRepositoryResult: - """ - Prepare the repository for debugging (contains git operations) - :return: the result of the preparation - """ - target_path = os.path.join(self.debug_task_ctx.workspace_path, self.debug_task_ctx.repo) - if not os.path.exists(target_path): - logging.error(f"Repository not found: {target_path}") - return PrepareRepositoryResult.REPO_NOT_FOUND - if not os.path.isdir(target_path): - logging.error(f"Repository is not a directory: {target_path}") - return PrepareRepositoryResult.REPO_NOT_DIR - - os.chdir(target_path) - logging.info(f"cd to the target repository: {target_path} succeed") - - # discard all changes - discard_cmd = "git checkout -- ." - if os.system(discard_cmd) != 0: - logging.error(f"Failed to discard all changes at initialize") - return PrepareRepositoryResult.REPO_CHECKOUT_FAILED - - # checkout to main branch - checkout_main_cmd = "git checkout main" - if os.system(checkout_main_cmd) != 0: - logging.error(f"Failed to checkout to main branch") - return PrepareRepositoryResult.REPO_CHECKOUT_FAILED - - # Check if the target branch exists - check_branch_cmd = f"git branch --list {self.debug_task_ctx.instance_id}" - #If the stripped output is non-empty (truthy), it means the branch exists. - if subprocess.run(check_branch_cmd, shell=True, capture_output=True, text=True).stdout.strip(): - # If the branch exists, delete it - delete_branch_cmd = f"git branch -D {self.debug_task_ctx.instance_id}" - if os.system(delete_branch_cmd) != 0: - logging.error(f"Failed to delete the target branch: {self.debug_task_ctx.instance_id}") - return PrepareRepositoryResult.REPO_BRANCH_DEL_FAILED - - # checkout to a new branch - checkout_branch_cmd = f"git checkout -b {self.debug_task_ctx.instance_id}" - if os.system(checkout_branch_cmd) != 0: - logging.error(f"Failed to checkout to a new branch: {self.debug_task_ctx.instance_id}") - return PrepareRepositoryResult.REPO_CHECKOUT_BRANCH_FAILED - - # checkout to the base commit - checkout_cmd = f"git checkout {self.debug_task_ctx.base_commit}" - if os.system(checkout_cmd) != 0: - logging.error(f"Failed to checkout to base commit: {self.debug_task_ctx.base_commit}") - return PrepareRepositoryResult.REPO_CHECKOUT_FAILED - - logging.info(f"Prepare repository for debug succeed") - return PrepareRepositoryResult.PREPARE_SUCCESS - - def reset_repository_after_debug(self) -> None: - """ - Reset the repository after debugging - """ - logging.info(f"^^^^^^^^^^^^^^^^^^^^^^^^Reset repository after debug^^^^^^^^^^^^^^^^^^^^^^^^") - target_path = os.path.join(self.debug_task_ctx.workspace_path, self.debug_task_ctx.repo) - if not os.path.exists(target_path) or not os.path.isdir(target_path): - logging.error(f"reset_repository_after_debug Repository not found or not a directory: {target_path}") - return - - # cd the target repository - os.chdir(target_path) - logging.info(f"reset_repository_after_debug cd to the target repository: {target_path} succeed") - - # discard all changes - discard_cmd = "git checkout -- ." - if os.system(discard_cmd) != 0: - logging.error(f"Failed to discard all changes") - return - - # checkout back to main - checkout_main_cmd = "git checkout main" - if os.system(checkout_main_cmd) != 0: - logging.error(f"Failed to checkout back to main") - return - - # delete the new branch - delete_branch_cmd = f"git branch -D {self.debug_task_ctx.instance_id}" - if os.system(delete_branch_cmd) != 0: - logging.error(f"Failed to delete the new branch: {self.debug_task_ctx.instance_id}") - return - - logging.info(f"Reset repository after debug succeed") - atexit.unregister(self.reset_repository_after_debug) # Unregister after execution - diff --git a/evaluation/swe_bench_lite/tools/repo_graph.py b/evaluation/swe_bench_lite/tools/repo_graph.py deleted file mode 100644 index e8402e48..00000000 --- a/evaluation/swe_bench_lite/tools/repo_graph.py +++ /dev/null @@ -1,586 +0,0 @@ -# This file is adapted from the following sources: -# RepoMap: https://github.com/paul-gauthier/aider/blob/main/aider/repomap.py -# Agentless: https://github.com/OpenAutoCoder/Agentless/blob/main/get_repo_structure/get_repo_structure.py -# grep-ast: https://github.com/paul-gauthier/grep-ast - -import colorsys -import os -import random -import re -import warnings -from collections import Counter, defaultdict, namedtuple -from pathlib import Path -import builtins -import inspect -import networkx as nx -from grep_ast import TreeContext, filename_to_lang -from pygments.lexers import guess_lexer_for_filename -from pygments.token import Token -from pygments.util import ClassNotFound -from tqdm import tqdm -import ast -import pickle -import json -from copy import deepcopy -from evaluation.swe_bench_lite.tools.utils import create_structure - -# tree_sitter is throwing a FutureWarning -warnings.simplefilter("ignore", category=FutureWarning) -from tree_sitter_languages import get_language, get_parser - -# relative path, full path, line numbers(start, end), name, kind, category, info(source code) -Tag = namedtuple("Tag", "rel_fname fname line name kind category info".split()) - - -class CodeGraph: - - warned_files = set() - - def __init__( - self, - map_tokens=1024, - root=None, - main_model=None, - io=None, - repo_content_prefix=None, - max_context_window=None, - ): - self.io = io - if not root: - root = os.getcwd() - self.root = root - self.max_map_tokens = map_tokens - self.max_context_window = max_context_window - self.repo_content_prefix = repo_content_prefix - self.structure = create_structure(self.root) - - def get_code_graph(self, other_files, mentioned_fnames=None): - if self.max_map_tokens <= 0: - return - if not other_files: - return - if not mentioned_fnames: - mentioned_fnames = set() - - max_map_tokens = self.max_map_tokens - - # With no files in the chat, give a bigger view of the entire repo - MUL = 16 - padding = 4096 - if max_map_tokens and self.max_context_window: - target = min(max_map_tokens * MUL, self.max_context_window - padding) - else: - target = 0 - - tags = self.get_tag_files(other_files, mentioned_fnames) - code_graph = self.tag_to_graph(tags) - - return tags, code_graph - - def get_tag_files(self, other_files, mentioned_fnames=None): - try: - tags = self.get_ranked_tags(other_files, mentioned_fnames) - return tags - except RecursionError: - self.io.tool_error("Disabling code graph, git repo too large?") - self.max_map_tokens = 0 - return - - def tag_to_graph(self, tags): - - G = nx.MultiDiGraph() - for tag in tags: - G.add_node(tag.name, category=tag.category, info=tag.info, fname=tag.fname, line=tag.line, kind=tag.kind) - - for tag in tags: - if tag.category == 'class': - class_funcs = tag.info.split('\t') - for f in class_funcs: - G.add_edge(tag.name, f.strip()) - - tags_ref = [tag for tag in tags if tag.kind == 'ref'] - tags_def = [tag for tag in tags if tag.kind == 'def'] - for tag in tags_ref: - for tag_def in tags_def: - if tag.name == tag_def.name: - G.add_edge(tag.name, tag_def.name) - return G - - def get_rel_fname(self, fname): - return os.path.relpath(fname, self.root) - - def split_path(self, path): - path = os.path.relpath(path, self.root) - return [path + ":"] - - def get_mtime(self, fname): - try: - return os.path.getmtime(fname) - except FileNotFoundError: - self.io.tool_error(f"File not found error: {fname}") - - def get_class_functions(self, tree, class_name): - class_functions = [] - - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef) and node.name == class_name: - for item in node.body: - if isinstance(item, ast.FunctionDef): - class_functions.append(item.name) - - return class_functions - - def get_func_block(self, first_line, code_block): - first_line_escaped = re.escape(first_line) - pattern = re.compile(rf'({first_line_escaped}.*?)(?=(^\S|\Z))', re.DOTALL | re.MULTILINE) - match = pattern.search(code_block) - - return match.group(0) if match else None - - def std_proj_funcs(self, code, fname): - """ - write a function to analyze the *import* part of a py file. - Input: code for fname - output: [standard functions] - please note that the project_dependent libraries should have specific project names. - """ - std_libs = [] - std_funcs = [] - tree = ast.parse(code) - codelines = code.split('\n') - - for node in ast.walk(tree): - if isinstance(node, ast.Import): - # identify the import statement - import_statement = codelines[node.lineno-1] - for alias in node.names: - import_name = alias.name.split('.')[0] - if import_name in fname: - continue - else: - # execute the import statement to find callable functions - import_statement = import_statement.strip() - try: - exec(import_statement) - except: - continue - std_libs.append(alias.name) - eval_name = alias.name if alias.asname is None else alias.asname - std_funcs.extend([name for name, member in inspect.getmembers(eval(eval_name)) if callable(member)]) - - if isinstance(node, ast.ImportFrom): - # execute the import statement - import_statement = codelines[node.lineno-1] - if node.module is None: - continue - module_name = node.module.split('.')[0] - if module_name in fname: - continue - else: - # handle imports with parentheses - if "(" in import_statement: - for ln in range(node.lineno-1, len(codelines)): - if ")" in codelines[ln]: - code_num = ln - break - import_statement = '\n'.join(codelines[node.lineno-1:code_num+1]) - import_statement = import_statement.strip() - try: - exec(import_statement) - except: - continue - for alias in node.names: - std_libs.append(alias.name) - eval_name = alias.name if alias.asname is None else alias.asname - if eval_name == "*": - continue - std_funcs.extend([name for name, member in inspect.getmembers(eval(eval_name)) if callable(member)]) - return std_funcs, std_libs - - - def get_tags(self, fname, rel_fname): - # Check if the file is in the cache and if the modification time has not changed - file_mtime = self.get_mtime(fname) - if file_mtime is None: - return [] - # miss! - data = list(self.get_tags_raw(fname, rel_fname)) - return data - - def get_tags_raw(self, fname, rel_fname): - ref_fname_lst = rel_fname.split('/') - s = deepcopy(self.structure) - for fname_part in ref_fname_lst: - s = s[fname_part] - structure_classes = {item['name']: item for item in s['classes']} - structure_functions = {item['name']: item for item in s['functions']} - structure_class_methods = dict() - for cls in s['classes']: - for item in cls['methods']: - structure_class_methods[item['name']] = item - structure_all_funcs = {**structure_functions, **structure_class_methods} - - lang = filename_to_lang(fname) - if not lang: - return - language = get_language(lang) - parser = get_parser(lang) - - # Load the tags queries - try: - # scm_fname = resources.files(__package__).joinpath( - # "/shared/data3/siruo2/SWE-agent/sweagent/environment/queries", f"tree-sitter-{lang}-tags.scm") - scm_fname = """ - (class_definition - name: (identifier) @name.definition.class) @definition.class - - (function_definition - name: (identifier) @name.definition.function) @definition.function - - (call - function: [ - (identifier) @name.reference.call - (attribute - attribute: (identifier) @name.reference.call) - ]) @reference.call - """ - except KeyError: - return - query_scm = scm_fname - # if not query_scm.exists(): - # return - # query_scm = query_scm.read_text() - - with open(str(fname), "r", encoding='utf-8') as f: - code = f.read() - with open(str(fname), "r", encoding='utf-8') as f: - codelines = f.readlines() - - # hard-coded edge cases - code = code.replace('\ufeff', '') - code = code.replace('constants.False', '_False') - code = code.replace('constants.True', '_True') - code = code.replace("False", "_False") - code = code.replace("True", "_True") - code = code.replace("DOMAIN\\username", "DOMAIN\\\\username") - code = code.replace("Error, ", "Error as ") - code = code.replace('Exception, ', 'Exception as ') - code = code.replace("print ", "yield ") - pattern = r'except\s+\(([^,]+)\s+as\s+([^)]+)\):' - # Replace 'as' with ',' - code = re.sub(pattern, r'except (\1, \2):', code) - code = code.replace("raise AttributeError as aname", "raise AttributeError") - - # code = self.io.read_text(fname) - if not code: - return - tree = parser.parse(bytes(code, "utf-8")) - try: - tree_ast = ast.parse(code) - except: - tree_ast = None - - # functions from third-party libs or default libs - try: - std_funcs, std_libs = self.std_proj_funcs(code, fname) - except: - std_funcs, std_libs = [], [] - - # functions from builtins - builtins_funs = [name for name in dir(builtins)] - builtins_funs += dir(list) - builtins_funs += dir(dict) - builtins_funs += dir(set) - builtins_funs += dir(str) - builtins_funs += dir(tuple) - - # Run the tags queries - query = language.query(query_scm) - captures = query.captures(tree.root_node) - captures = list(captures) - - saw = set() - for node, tag in captures: - if tag.startswith("name.definition."): - kind = "def" - elif tag.startswith("name.reference."): - kind = "ref" - else: - continue - - saw.add(kind) - cur_cdl = codelines[node.start_point[0]] - category = 'class' if 'class ' in cur_cdl else 'function' - tag_name = node.text.decode("utf-8") - - # we only want to consider project-dependent functions - if tag_name in std_funcs: - continue - elif tag_name in std_libs: - continue - elif tag_name in builtins_funs: - continue - - if category == 'class': - # try: - # class_functions = self.get_class_functions(tree_ast, tag_name) - # except: - # class_functions = "None" - class_functions = [item['name'] for item in structure_classes[tag_name]['methods']] - if kind == 'def': - line_nums = [structure_classes[tag_name]['start_line'], structure_classes[tag_name]['end_line']] - else: - line_nums = [node.start_point[0], node.end_point[0]] - result = Tag( - rel_fname=rel_fname, - fname=fname, - name=tag_name, - kind=kind, - category=category, - info='\n'.join(class_functions), # list unhashable, use string instead - line=line_nums, - ) - - elif category == 'function': - - if kind == 'def': - # func_block = self.get_func_block(cur_cdl, code) - # cur_cdl =func_block - cur_cdl = '\n'.join(structure_all_funcs[tag_name]['text']) - line_nums = [structure_all_funcs[tag_name]['start_line'], structure_all_funcs[tag_name]['end_line']] - else: - line_nums = [node.start_point[0], node.end_point[0]] - - result = Tag( - rel_fname=rel_fname, - fname=fname, - name=tag_name, - kind=kind, - category=category, - info=cur_cdl, - line=line_nums, - ) - - yield result - - if "ref" in saw: - return - if "def" not in saw: - return - - # We saw defs, without any refs - # Some tags files only provide defs (cpp, for example) - # Use pygments to backfill refs - - try: - lexer = guess_lexer_for_filename(fname, code) - except ClassNotFound: - return - - tokens = list(lexer.get_tokens(code)) - tokens = [token[1] for token in tokens if token[0] in Token.Name] - - for token in tokens: - yield Tag( - rel_fname=rel_fname, - fname=fname, - name=token, - kind="ref", - line=-1, - category='function', - info='none', - ) - - def get_ranked_tags(self, other_fnames, mentioned_fnames): - # defines = defaultdict(set) - # references = defaultdict(list) - # definitions = defaultdict(set) - - tags_of_files = list() - - personalization = dict() - - fnames = set(other_fnames) - # chat_rel_fnames = set() - - fnames = sorted(fnames) - - # Default personalization for unspecified files is 1/num_nodes - # https://networkx.org/documentation/stable/_modules/networkx/algorithms/link_analysis/pagerank_alg.html#pagerank - personalize = 10 / len(fnames) - - for fname in tqdm(fnames): - if not Path(fname).is_file(): - if fname not in self.warned_files: - if Path(fname).exists(): - self.io.tool_error( - f"Code graph can't include {fname}, it is not a normal file" - ) - else: - self.io.tool_error(f"Code graph can't include {fname}, it no longer exists") - - self.warned_files.add(fname) - continue - - # dump(fname) - rel_fname = self.get_rel_fname(fname) - - # if fname in chat_fnames: - # personalization[rel_fname] = personalize - # chat_rel_fnames.add(rel_fname) - - if fname in mentioned_fnames: - personalization[rel_fname] = personalize - - tags = list(self.get_tags(fname, rel_fname)) - - tags_of_files.extend(tags) - - if tags is None: - continue - - return tags_of_files - - - def render_tree(self, abs_fname, rel_fname, lois): - key = (rel_fname, tuple(sorted(lois))) - - if key in self.tree_cache: - return self.tree_cache[key] - - # code = self.io.read_text(abs_fname) or "" - with open(str(abs_fname), "r", encoding='utf-8') as f: - code = f.read() or "" - - if not code.endswith("\n"): - code += "\n" - - context = TreeContext( - rel_fname, - code, - color=False, - line_number=False, - child_context=False, - last_line=False, - margin=0, - mark_lois=False, - loi_pad=0, - # header_max=30, - show_top_of_file_parent_scope=False, - ) - - context.add_lines_of_interest(lois) - context.add_context() - res = context.format() - self.tree_cache[key] = res - return res - - def to_tree(self, tags, chat_rel_fnames): - if not tags: - return "" - - tags = [tag for tag in tags if tag[0] not in chat_rel_fnames] - tags = sorted(tags) - - cur_fname = None - cur_abs_fname = None - lois = None - output = "" - - # add a bogus tag at the end so we trip the this_fname != cur_fname... - dummy_tag = (None,) - for tag in tags + [dummy_tag]: - this_rel_fname = tag[0] - - # ... here ... to output the final real entry in the list - if this_rel_fname != cur_fname: - if lois is not None: - output += "\n" - output += cur_fname + ":\n" - output += self.render_tree(cur_abs_fname, cur_fname, lois) - lois = None - elif cur_fname: - output += "\n" + cur_fname + "\n" - if type(tag) is Tag: - lois = [] - cur_abs_fname = tag.fname - cur_fname = this_rel_fname - - if lois is not None: - lois.append(tag.line) - - # truncate long lines, in case we get minified js or something else crazy - output = "\n".join([line[:100] for line in output.splitlines()]) + "\n" - - return output - - - def find_src_files(self, directory): - if not os.path.isdir(directory): - return [directory] - - src_files = [] - for root, dirs, files in os.walk(directory): - for file in files: - src_files.append(os.path.join(root, file)) - return src_files - - - def find_files(self, dir): - chat_fnames = [] - - for fname in dir: - if Path(fname).is_dir(): - chat_fnames += self.find_src_files(fname) - else: - chat_fnames.append(fname) - - chat_fnames_new = [] - for item in chat_fnames: - # filter out non-python files - if not item.endswith('.py'): - continue - else: - chat_fnames_new.append(item) - - return chat_fnames_new - - -def get_random_color(): - hue = random.random() - r, g, b = [int(x * 255) for x in colorsys.hsv_to_rgb(hue, 1, 0.75)] - res = f"#{r:02x}{g:02x}{b:02x}" - return res - - -if __name__ == "__main__": - - # dir_name = sys.argv[1] - dir_name = "/home/llm/Project/PythonProjects/GhostOS" - code_graph = CodeGraph(root=dir_name) - chat_fnames_new = code_graph.find_files([dir_name]) - - tags, G = code_graph.get_code_graph(chat_fnames_new) - - print("---------------------------------") - print(f"🏅 Successfully constructed the code graph for repo directory {dir_name}") - print(f" Number of nodes: {len(G.nodes)}") - print(f" Number of edges: {len(G.edges)}") - print("---------------------------------") - - with open(f'{os.getcwd()}/graph.pkl', 'wb') as f: - pickle.dump(G, f) - - for tag in tags: - with open(f'{os.getcwd()}/tags.json', 'a+') as f: - line = json.dumps({ - "fname": tag.fname, - 'rel_fname': tag.rel_fname, - 'line': tag.line, - 'name': tag.name, - 'kind': tag.kind, - 'category': tag.category, - 'info': tag.info, - }) - f.write(line+'\n') - print(f"🏅 Successfully cached code graph and node tags in directory ''{os.getcwd()}''") diff --git a/evaluation/swe_bench_lite/tools/utils.py b/evaluation/swe_bench_lite/tools/utils.py deleted file mode 100644 index 754b1a5d..00000000 --- a/evaluation/swe_bench_lite/tools/utils.py +++ /dev/null @@ -1,102 +0,0 @@ -import os -import ast - - -def create_structure(directory_path): - """Create the structure of the repository directory by parsing Python files. - :param directory_path: Path to the repository directory. - :return: A dictionary representing the structure. - """ - structure = {} - - for root, _, files in os.walk(directory_path): - repo_name = os.path.basename(directory_path) - relative_root = os.path.relpath(root, directory_path) - if relative_root == ".": - relative_root = repo_name - curr_struct = structure - for part in relative_root.split(os.sep): - if part not in curr_struct: - curr_struct[part] = {} - curr_struct = curr_struct[part] - for file_name in files: - if file_name.endswith(".py"): - file_path = os.path.join(root, file_name) - class_info, function_names, file_lines = parse_python_file(file_path) - curr_struct[file_name] = { - "classes": class_info, - "functions": function_names, - "text": file_lines, - } - else: - curr_struct[file_name] = {} - - return structure - -def parse_python_file(file_path, file_content=None): - """Parse a Python file to extract class and function definitions with their line numbers. - :param file_path: Path to the Python file. - :return: Class names, function names, and file contents - """ - if file_content is None: - try: - with open(file_path, "r") as file: - file_content = file.read() - parsed_data = ast.parse(file_content) - except Exception as e: # Catch all types of exceptions - print(f"Error in file {file_path}: {e}") - return [], [], "" - else: - try: - parsed_data = ast.parse(file_content) - except Exception as e: # Catch all types of exceptions - print(f"Error in file {file_path}: {e}") - return [], [], "" - - class_info = [] - function_names = [] - class_methods = set() - - for node in ast.walk(parsed_data): - if isinstance(node, ast.ClassDef): - methods = [] - for n in node.body: - if isinstance(n, ast.FunctionDef): - methods.append( - { - "name": n.name, - "start_line": n.lineno, - "end_line": n.end_lineno, - "text": file_content.splitlines()[ - n.lineno - 1 : n.end_lineno - ], - } - ) - class_methods.add(n.name) - class_info.append( - { - "name": node.name, - "start_line": node.lineno, - "end_line": node.end_lineno, - "text": file_content.splitlines()[ - node.lineno - 1 : node.end_lineno - ], - "methods": methods, - } - ) - elif isinstance(node, ast.FunctionDef) and not isinstance( - node, ast.AsyncFunctionDef - ): - if node.name not in class_methods: - function_names.append( - { - "name": node.name, - "start_line": node.lineno, - "end_line": node.end_lineno, - "text": file_content.splitlines()[ - node.lineno - 1 : node.end_lineno - ], - } - ) - - return class_info, function_names, file_content.splitlines() \ No newline at end of file diff --git a/evaluation/__init__.py b/ghostos/__init__.py similarity index 100% rename from evaluation/__init__.py rename to ghostos/__init__.py diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 8484c0cc..4ea66215 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -95,9 +95,9 @@ def default_application_contracts() -> Contracts: from ghostos.contracts.workspace import Workspace from ghostos.entity import EntityFactory from ghostos.framework.configs import Configs - from ghostos.framework.processes import GhostProcessRepo - from ghostos.framework.threads import MsgThreadRepo - from ghostos.framework.tasks import TaskRepo + from ghostos.framework.processes import GoProcesses + from ghostos.framework.threads import GoThreads + from ghostos.framework.tasks import GoTasks from ghostos.framework.eventbuses import EventBus from ghostos.framework.llms import LLMs from ghostos.framework.logger import LoggerItf @@ -127,9 +127,9 @@ def default_application_contracts() -> Contracts: AIFuncRepository, # session contracts - GhostProcessRepo, # application processes repository - MsgThreadRepo, # application threads repository - TaskRepo, # application tasks repository + GoProcesses, # application processes repository + GoThreads, # application threads repository + GoTasks, # application tasks repository EventBus, # application session eventbus ]) diff --git a/ghostos/common.py b/ghostos/common.py index ecfaad7b..ab8d5770 100644 --- a/ghostos/common.py +++ b/ghostos/common.py @@ -1,30 +1,124 @@ +from __future__ import annotations + +import json from abc import ABC, abstractmethod -from typing import Any, Optional, Dict, Union +from typing import Optional, Dict, Union, Callable, Any, TypedDict, Required, Self, TypeVar, Type +from types import ModuleType from pydantic import BaseModel, Field -from ghostos.helpers import generate_import_path +from ghostos.helpers import generate_import_path, import_from_path from typing_extensions import Protocol -from types import FunctionType import inspect +import pickle __all__ = [ - 'Descriptive', - 'Identifier', 'Identifiable', 'IdentifiableClass', 'identify_class', 'identify_class_id', - 'PromptAble', 'PromptAbleClass' + 'Identifier', 'Identifiable', 'get_identifier', + 'get_defined_prompt', + 'to_entity_meta', 'from_entity_meta', 'get_entity', + 'EntityMeta', 'Entity', 'EntityType', + + 'Identical', 'IdenticalClass', + 'identify_class', 'identify_class_id', + 'IdenticalObject', + + 'Prompter', 'PrompterClass', + ] -class Descriptive(ABC): +def get_defined_prompt(value: Any) -> Union[str, None]: + if value is None: + return None + elif isinstance(value, Prompter): + return value.__prompt__() + elif issubclass(value, PrompterClass): + return value.__class_prompt__() - @abstractmethod - def get_description(self) -> str: - pass + elif isinstance(value, type): + # class without __class_prompt__ is not defined as prompter + if hasattr(value, "__class_prompt___"): + return getattr(value, "__class_prompt___")() + + elif hasattr(value, "__prompter__"): + prompter = getattr(value, "__prompter__") + if prompter.__self__ is not None: + return prompter() + elif isinstance(value, ModuleType) and '__prompt__' in value.__dict__: + prompter = value.__dict__['__prompt__'] + return prompter() + return None + + +def get_identifier(value: Any, throw: bool = False) -> Union[Identifier, None]: + """ + get identifier or not from any value + """ + try: + if value is None: + return None + # identifier it self + if isinstance(value, Identifier): + return value + # explicit identifiable object + elif isinstance(value, Identical): + return value.identifier() + # explicit identifiable class + elif issubclass(value, IdenticalClass): + return value.class_identifier() + # function is always identifiable + elif inspect.isfunction(value): + return Identifier( + id=generate_import_path(value), + name=value.__name__, + description=value.__doc__, + ) + # method just like function + elif inspect.ismethod(value): + return Identifier( + id=generate_import_path(value.__class__) + ":" + value.__name__, + name=value.__name__, + description=value.__doc__, + ) + # class is special at runtime. + # notice the id of a class is alternative at different project due to python import by relative path. + elif isinstance(value, type): + return identify_class(value) + # a dict + elif isinstance(value, Dict) and "name" in value and "description" in value: + return Identifier( + id=value.get("id", None), + name=value["name"], + description=value["description"], + ) + elif hasattr(value, "__identifier__"): + identifier = getattr(value, "identifier") + if isinstance(identifier, Identifier): + return identifier + elif isinstance(identifier, Callable): + return identifier() + + elif hasattr(value, 'name') and hasattr(value, 'description'): + return Identifier( + id=getattr(value, 'id', None), + name=getattr(value, 'name'), + description=getattr(value, 'description'), + ) + return None + except Exception: + if throw: + raise + return None class Identifier(BaseModel): - id: str = Field(default="", description="Unique identifier") - name: str = Field(default="", description="Name of the object") + """ + a simplest model identify an object + """ + id: Optional[str] = Field(default=None, description="Unique id") + name: str = Field( + default="", + description="Name of the object, name has it meaning only for the subject who named it", + ) description: str = Field(default="", description="Description of the object") - kind: Optional[str] = Field(default=None, description="Kind of the object") def match_keyword(self, keyword: str) -> bool: keyword = keyword.strip() @@ -37,9 +131,9 @@ def match_keyword(self, keyword: str) -> bool: ) -class Identifiable(ABC): +class Identical(ABC): """ - 描述一个可识别的对象. + abstract class that identifiable class can extend it. """ @abstractmethod @@ -47,13 +141,22 @@ def identifier(self) -> Identifier: pass -class IdentifiableProtocol(Protocol): - id: Optional[str] - name: str - description: str +class IdenticalObject(Protocol): + """ + less invasive way to describe an identifiable object. + when we need to hide the complexity of a class to someone, especially the AI model, + we need to use protocol to do implicit implementation sometimes. duck type is useful. + """ + @abstractmethod + def __identifier__(self) -> Identifier: + pass -class IdentifiableClass(ABC): + +class IdenticalClass(ABC): + """ + class is identifiable, but sometimes we need to specific the identifier. + """ @classmethod @abstractmethod @@ -61,13 +164,16 @@ def class_identifier(cls) -> Identifier: pass +Identifiable = Union[Identical, IdenticalObject] + + def identify_class(cls: type) -> Identifier: """ 一个默认的用来描述类的方法. :param cls: 目标类. :return: 返回一个 identifier. """ - if issubclass(cls, IdentifiableClass): + if issubclass(cls, IdenticalClass): return cls.class_identifier() id_ = identify_class_id(cls) name = cls.__name__ @@ -84,50 +190,10 @@ def identify_class_id(cls: type) -> str: return generate_import_path(cls) -def get_identifier(value: Any) -> Optional[Identifier]: - if isinstance(value, Identifier): - return value - if isinstance(value, Identifiable): - return value.identifier() - if isinstance(value, IdentifiableClass): - return value.class_identifier() - if inspect.isfunction(value): - return Identifier( - id=generate_import_path(value), - name=value.__name__, - description=value.__doc__, - kind="function", - ) - if inspect.ismethod(value): - return Identifier( - id=generate_import_path(value.__class__) + ":" + value.__name__, - name=value.__name__, - description=value.__doc__, - kind="method", - ) - if isinstance(value, type): - return identify_class(value) - if isinstance(value, Dict) and "name" in value and "description" in value: - return Identifier( - id=value.get("id", None), - name=value["name"], - description=value["description"], - kind=str(type(value)), - ) - if isinstance(value, object) and hasattr(value, 'name') and hasattr(value, 'description'): - return Identifier( - id=getattr(value, 'id', None), - name=getattr(value, 'name'), - description=getattr(value, 'description'), - kind=str(type(value)), - ) - return None - - -IDAble = Union[Identifier, Identifiable, IdentifiableClass, type, FunctionType, IdentifiableProtocol] +# ---- prompt ---- # -class PromptAble(ABC): +class Prompter(ABC): """ 拥有 __prompt__ 方法的类. 这里只是一个示范, 并不需要真正继承这个类, 只需要有 __prompt__ 方法或属性. @@ -138,9 +204,106 @@ def __prompt__(self) -> str: pass -class PromptAbleClass(ABC): +class PromptAbleObj(Protocol): + @abstractmethod + def __prompt__(self) -> str: + pass + + +class PrompterClass(ABC): @classmethod @abstractmethod def __class_prompt__(cls) -> str: pass + + +class PromptAbleClass(Protocol): + + @classmethod + @abstractmethod + def __class_prompt__(cls) -> str: + pass + + +PromptAble = Union[PromptAbleObj, PromptAbleClass] + + +# ---- entity ---- # + +class Entity(Protocol): + + @abstractmethod + def __to_entity_meta__(self) -> EntityMeta: + pass + + @classmethod + @abstractmethod + def __from_entity_meta__(cls, meta: EntityMeta) -> Self: + pass + + +class EntityMeta(TypedDict): + """ + I want python has an official way to marshal and unmarshal any instance and make it readable if allowed. + I found so many package-level implements like various kinds of Serializable etc. + + So, I develop EntityMeta as a wrapper for any kind. + The EntityType will grow bigger with more marshaller, but do not affect who (me) is using the EntityMeta. + + One day I can replace it with any better way inside the functions (but in-compatible) + """ + type: Required[str] + content: Required[bytes] + + +EntityType = Union[Entity, EntityMeta, BaseModel] + + +def to_entity_meta(value: Union[EntityType, Any]) -> EntityMeta: + if isinstance(value, EntityMeta): + return value + elif hasattr(value, '__to_entity_meta__'): + return getattr(value, '__to_entity_meta__')() + elif isinstance(value, BaseModel): + return EntityMeta( + type=generate_import_path(value.__class__), + content=value.model_dump_json(exclude_defaults=True).encode(), + ) + else: + content = pickle.dumps(value) + return EntityMeta( + type="pickle", + content=content, + ) + + +T = TypeVar("T") + + +def get_entity(meta: EntityMeta, expect: Type[T]) -> T: + entity = from_entity_meta(meta) + if not isinstance(entity, expect): + raise TypeError(f"Expected entity type {expect} but got {type(entity)}") + return entity + + +def from_entity_meta(meta: EntityMeta) -> Any: + unmarshal_type = meta['type'] + if unmarshal_type == 'pickle': + return pickle.loads(meta['content']) + + # raise if import error + cls = import_from_path(unmarshal_type) + + if issubclass(cls, EntityMeta): + return meta + + elif hasattr(cls, "__from_entity_meta__"): + return getattr(cls, "__from_entity_meta__")(meta) + + elif issubclass(cls, BaseModel): + data = json.loads(meta["content"]) + return cls(**data) + + raise TypeError(f"unsupported entity meta type: {unmarshal_type}") diff --git a/ghostos/container.py b/ghostos/container.py index fa187252..8f186efa 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -20,7 +20,7 @@ 打算实现一个 IoC 容器用来管理大量可替换的中间库. """ -INSTANCE = TypeVar('INSTANCE', bound=object) +INSTANCE = TypeVar('INSTANCE') """instance in the container""" ABSTRACT = Type[INSTANCE] @@ -63,7 +63,7 @@ def bootstrap(self) -> None: pass @abstractmethod - def get(self, abstract: ABSTRACT) -> Optional[INSTANCE]: + def get(self, abstract: Type[INSTANCE]) -> Optional[INSTANCE]: """ get bound instance or initialize one from registered abstract, or generate one by factory or provider. :return: None if no bound instance. @@ -71,7 +71,7 @@ def get(self, abstract: ABSTRACT) -> Optional[INSTANCE]: pass @abstractmethod - def get_bound(self, abstract: ABSTRACT) -> Union[INSTANCE, Provider]: + def get_bound(self, abstract: Type[INSTANCE]) -> Union[INSTANCE, Provider]: """ get bound of an abstract useful to debug @@ -80,7 +80,7 @@ def get_bound(self, abstract: ABSTRACT) -> Union[INSTANCE, Provider]: pass @abstractmethod - def fetch(self, abstract: ABSTRACT, strict: bool = False) -> Optional[INSTANCE]: + def fetch(self, abstract: Type[INSTANCE], strict: bool = False) -> Optional[INSTANCE]: """ :param abstract: use type of the object (usually an abstract class) to fetch the implementation. :param strict: autotype check @@ -89,7 +89,7 @@ def fetch(self, abstract: ABSTRACT, strict: bool = False) -> Optional[INSTANCE]: pass @abstractmethod - def force_fetch(self, contract: ABSTRACT, strict: bool = False) -> INSTANCE: + def force_fetch(self, contract: Type[INSTANCE], strict: bool = False) -> INSTANCE: """ if fetch contract failed, raise error. :exception: NotImplementedError if contract is not registered. @@ -98,7 +98,7 @@ def force_fetch(self, contract: ABSTRACT, strict: bool = False) -> INSTANCE: pass @abstractmethod - def bound(self, contract: ABSTRACT) -> bool: + def bound(self, contract: Type[INSTANCE]) -> bool: """ return whether contract is bound. """ @@ -148,6 +148,7 @@ def __init__(self, parent: Optional[Container] = None): self._bootstrapped: bool = False self._aliases: Dict[Any, Any] = {} self._destroyed: bool = False + self._shutdown: List[Callable[[], None]] = [] def bootstrap(self) -> None: """ @@ -166,6 +167,9 @@ def bootstrap(self) -> None: if isinstance(provider, Bootstrapper): provider.bootstrap(self) + def add_shutdown(self, shutdown: Callable): + self._shutdown.append(shutdown) + def set(self, abstract: Any, instance: INSTANCE) -> None: """ 设置一个实例, 不会污染父容器. @@ -186,7 +190,7 @@ def bound(self, contract: Type) -> bool: self._check_destroyed() return contract in self._bound or (self.parent is not None and self.parent.bound(contract)) - def get(self, abstract: Type[INSTANCE]) -> Optional[INSTANCE]: + def get(self, abstract: Union[Type[INSTANCE]]) -> Optional[INSTANCE]: """ get bound instance or initialize one from registered factory or provider. @@ -353,6 +357,9 @@ def destroy(self) -> None: if self._destroyed: return self._destroyed = True + for shutdown in self._shutdown: + shutdown() + del self._shutdown del self._instances del self.parent del self._providers @@ -377,9 +384,9 @@ def singleton(self) -> bool: def contract(self) -> ABSTRACT: """ :return: contract for this provider. - override this method to define a contract without generic type + override this method to define a contract without get from generic args """ - return self.get_instance_type() + return get_contract_type(self.__class__) def aliases(self) -> Iterable[ABSTRACT]: """ @@ -394,19 +401,19 @@ def factory(self, con: Container) -> Optional[INSTANCE]: """ pass - def get_instance_type(self) -> ABSTRACT: - """ - get generic INSTANCE type from the instance of the provider. - """ - cls = self.__class__ - for parent in cls.__orig_bases__: - if get_origin(parent) is not Provider: - continue - args = get_args(parent) - if not args: - break - return args[0] - raise AttributeError("can not get instance type") + +def get_contract_type(cls: Type[Provider]) -> ABSTRACT: + """ + get generic INSTANCE type from the instance of the provider. + """ + for parent in cls.__orig_bases__: + if get_origin(parent) is not Provider: + continue + args = get_args(parent) + if not args: + break + return args[0] + raise AttributeError("can not get instance type") class Bootstrapper(metaclass=ABCMeta): diff --git a/ghostos/contracts/documents.py b/ghostos/contracts/documents.py index e721f9da..ef3fadc6 100644 --- a/ghostos/contracts/documents.py +++ b/ghostos/contracts/documents.py @@ -1,10 +1,10 @@ from typing import List, Iterable from typing_extensions import Self from abc import ABC, abstractmethod -from ghostos.common import Identifiable, Identifier +from ghostos.common import Identical, Identifier -class Documents(Identifiable, ABC): +class Documents(Identical, ABC): @abstractmethod def domain(self) -> str: diff --git a/ghostos/contracts/variables.py b/ghostos/contracts/variables.py new file mode 100644 index 00000000..84388705 --- /dev/null +++ b/ghostos/contracts/variables.py @@ -0,0 +1,53 @@ +from typing import AnyStr, TypedDict, Required, Union, Dict, List, TypeVar, Type, Optional +from typing_extensions import Self +from abc import ABC, abstractmethod +from pydantic import BaseModel + + +class VarPtr(TypedDict): + id: Required[str] + kind: Required[str] + + +class VarData(TypedDict): + id: Required[str] + kind: Required[str] + data: Required[bytes] + + +class Var(ABC): + @abstractmethod + def marshal(self) -> bytes: + pass + + @classmethod + @abstractmethod + def unmarshal(cls, value: bytes) -> Self: + pass + + def ptr(self) -> VarPtr: + pass + + +V = TypeVar('V', Var, BaseModel, Dict, List, str, int, float) + + +class Variables(ABC): + + @abstractmethod + def save(self, v: Union[Var, BaseModel, dict, list, str, int, float]) -> VarPtr: + pass + + @abstractmethod + def load(self, vid: str, wrapper: Type[V]) -> Optional[V]: + pass + + +class ModelVar(Var): + + def __init__(self, model: BaseModel): + pass + + +class PickleVar(Var): + pass diff --git a/ghostos/core/abcd/actor.py b/ghostos/core/abcd/actor.py deleted file mode 100644 index a88fdab0..00000000 --- a/ghostos/core/abcd/actor.py +++ /dev/null @@ -1,75 +0,0 @@ -from abc import abstractmethod -from typing import Iterable, Optional -from typing_extensions import Protocol -from .transport import Message -from ghostos.common import Identifier - -__all__ = ("Actor", "Address", "Topic", "Mail", "Message") - - -class Address(Protocol): - """ - instance of the actor - """ - - @abstractmethod - def identifier(self) -> Identifier: - pass - - -class Topic(Protocol): - """ - topic that transport messages - """ - - @abstractmethod - def identifier(self) -> Identifier: - pass - - @abstractmethod - def get_parent(self) -> Optional[str]: - pass - - -class Mail(Protocol): - - @abstractmethod - def issuer(self) -> Optional[Address]: - pass - - @abstractmethod - def receiver(self) -> Optional[Address]: - pass - - @abstractmethod - def topic(self) -> Topic: - pass - - @abstractmethod - def content(self) -> Iterable[Message]: - pass - - -class ActCtx(Protocol): - - @abstractmethod - def topics(self) -> Iterable[Topic]: - pass - - -class Actor(Protocol): - - @abstractmethod - def identifier(self) -> Identifier: - pass - - @abstractmethod - def on_recv( - self, - ctx: ActCtx, - recv: Mail, - ) -> Iterable[Message]: - """ - 回复一个邮件 - """ - pass diff --git a/ghostos/core/abcd/agent.py b/ghostos/core/abcd/agent.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/core/abcd/aifunc.py b/ghostos/core/abcd/aifunc.py deleted file mode 100644 index 882b90f0..00000000 --- a/ghostos/core/abcd/aifunc.py +++ /dev/null @@ -1,9 +0,0 @@ -from abc import ABC, abstractmethod -from .ghostos import Conversable - - - -class AIFunc(ABC): - pass - - diff --git a/ghostos/core/abcd/ghost.py b/ghostos/core/abcd/ghost.py deleted file mode 100644 index 18ff7678..00000000 --- a/ghostos/core/abcd/ghost.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Optional - -from .ghostos import Conversable, Runtime, Event, Operator - - -class Ghost(Conversable): - - def on_event(self, runtime: Runtime, event: Event) -> Optional[Operator]: - pass diff --git a/ghostos/core/abcd/ghostos.py b/ghostos/core/abcd/ghostos.py index c0578203..970af6a1 100644 --- a/ghostos/core/abcd/ghostos.py +++ b/ghostos/core/abcd/ghostos.py @@ -1,87 +1,335 @@ from __future__ import annotations -from typing import Protocol, Optional, Iterable, List +from typing import ( + Protocol, Optional, Iterable, TypeVar, Any, Union, Self, ClassVar, Type, Callable, Generic, Dict, +) from abc import ABC, abstractmethod -from ghostos.container import Container -from ghostos.entity import EntityMeta +from ghostos.container import Container, Provider +from ghostos.common import Identifiable, EntityType, Identifier +from ghostos.core.session.session import Session, SessionProps +from ghostos.core.session.tasks import GoTaskStruct, TaskState, GoTasks +from ghostos.core.session.events import Event +from ghostos.core.messages import Message -class GhostOS(ABC): +class Ghost(Identifiable, EntityType, Protocol): + """ + """ + # + # The word `Ghost` is picked from `Ghost In the Shell` movie. + # The Ghost can perform as both conversational object or an async function. + # Ghost is the abstract of atomic state machine unit in the GhostOS. + # + # for example, llm-based `Agent` is a state machine, an implementation of Ghost in GhostOS. + # + # Why Agent is a state machine? + # 1. Agent receives an event at a time, not parallel, or face brain split. + # 2. Agent keep it state in the system prompt and messages, by nature language. + # 3. Agent take actions that matching expectation. + # So Agent is an AI-State-Machine, defined from prompt, not code; executed by Model, not Interpreter. + # + # About the Ghost Abstract: + # 1. it is a class. + # 2. the ghost class can construct ghost instance. + # 3. any ghost instance can run as a conversational task + # 4. a conversational task runs in turns, receiving event and replying messages. + # 5. the conversational task is stateful, accept one event at a time. + # 6. the conversational task reach the end when it is canceled, done or failed + # 7. all the ghost has a Goal model to describe its current achievement. + # 8. The Ghost Class shall be simple and clear to the AI models, when they are creating ghosts themselves. + # + # and the Most valuable features about ghost are: + # 1. ghosts shall be fractal, can be called by other ghosts. + # 2. ghost shall be defined by code, which can be generated by meta-agents. + # + + Goal: ClassVar[Union[Type, None]] + """ the model of the ghost's goal""" + + __ghost_driver__: Type[GhostDriver] = None + + +G = TypeVar("G", bound=Ghost) + + +class GhostDriver(Generic[G], ABC): + """ + Ghost class is supposed to be a data class without complex methods definitions. + so it seems much clear when prompt to the LLM or user-level developer. + when LLM is creating a ghost class or instance, we expect it only see the code we want it to see, + without knowing the details codes of it, for safety / fewer tokens / more focus or other reasons. + + so the methods of the ghost class defined in this class. + only core developers should know details about it. + """ + + def __init__(self, ghost: G) -> None: + self.ghost = ghost @abstractmethod - def converse( - self, - conversable: Conversable, - process_id: Optional[str] = None, - ) -> Conversation: + def create(self, parent_session: Session) -> GoTaskStruct: + """ + create task in given + :param parent_session: + :return: + """ pass + @abstractmethod + def get_goal(self, session: Session) -> Optional[G.Goal]: + """ + generate the ghost goal from session_state + may be the Goal Model is a SessionStateValue that bind to it. -class Conversable(Protocol): + The AI behind a ghost is not supposed to operate the session object, + but work on the goal through functions or Moss Injections. + """ + pass @abstractmethod - def on_event(self, runtime: Runtime, event: Event) -> Optional[Operator]: + def on_event(self, session: Session, event: Event) -> Union[Operator, None]: + """ + all the state machine is only handling session event with the predefined operators. + """ pass -class Event(Protocol): - pass +class Operator(Protocol): + """ + Operator to operating the GhostOS through the Session encapsulation. + The Operator is just like the primitives of any coding language. + for example, GhostOS have some operators work like python's `return`, `yield`, `await` . -class Message(Protocol): - pass + I'm not capable to develop a real OS or a new coding language for AI, + GhostOS is built above python with the additional complexities. + Operators should be predefined, offer to user-level developer, or AI-models. + """ -class Runtime(Protocol): + @abstractmethod + def run(self, session: Session) -> Union[Operator, None]: + """ + :return: None means stop the loop, otherwise keep going. + + operator returns an operator is a way to encapsulate repetitive codes. + """ + pass + + @abstractmethod + def destroy(self): + """ + Python gc is not trust-worthy + Especially A keep B, B keep C, C keep A, father and child keep each other. + I prefer to del the object attributes in the end of the object lifecycle. + """ + pass + + +class GhostOS(Protocol): @abstractmethod def container(self) -> Container: + """ + root container for GhostOS + """ pass @abstractmethod - def frame(self) -> Frame: + def connect( + self, + shell_id: str = "local", + process_id: Optional[str] = None, + properties: Optional[Dict[str, Any]] = None, + *providers: Provider, + ) -> Shell: + """ + The word 'Shell' is picked from `Ghost In the Shell` movie. + Shell is the body of an AI or something. + this method create or connect a shell that can communicate with the ghosts inside it. + + :param shell_id: id of the runtime instance keep all the shell level runtime objects. + :param providers: register shell level providers. only this shell instance have them. + :param process_id: once a shell instance is recreated, + all the process level runtime objects will be abandoned. + such as tasks, threads, events etc. + but the shell level memory will keep existence. + :param properties: the properties of the ghost instance, inherited by every task created in the shell. + :return: a connection to the limbo where all the ghosts running inside + """ pass + +class Shell(Protocol): + """ + shell basically is an event loop run all the ghost (agentic State Machine). + """ + @abstractmethod - def save_frame(self, frame: Frame) -> None: + def container(self) -> Container: + """ + shell has its own container with providers. + in case ghostos start multiple shell at same time + """ pass + @abstractmethod + def send_event(self, event: Event) -> None: + """ + send an event into the loop. + the event always has a task_id, so the task shall be created first. + """ + pass -class Frame(Protocol): - frame_id: str - args: dict - state: dict - result: Optional[dict] - status: str - children: List[str] - conversable: EntityMeta + @abstractmethod + def quit(self): + """ + quit the shell connection. + """ + pass + @abstractmethod + def get_task(self, task_id: str, lock: bool) -> Optional[GoTaskStruct]: + """ + get a task instance by id + :param task_id: + :param lock: if True, try to lock the task before getting. + :return: None if the task is not exists or is locked. + """ + pass -class Operator(Protocol): + @abstractmethod + def sync_task(self, task_id: str) -> Optional[Conversation]: + """ + lock a task then create a conversation. + :param task_id: + :return: + """ + pass @abstractmethod - def run(self, runtime: Runtime) -> Optional[Operator]: + def sync(self, ghost: Ghost) -> Conversation: + """ + create a top-level conversation with a ghost. + top-level means task depth is 0. + So it never locked until the conversation is created. + :param ghost: + :return: + """ + pass + + @abstractmethod + def create_task(self, ghost: Ghost, parent_task: Optional[str] = None) -> GoTaskStruct: + pass + + @abstractmethod + def run_task( + self, + task_id: str, + timeout: float = 0.0, + loop: int = 0, + ) -> Union[Any, TaskState]: + """ + run a task until it is end. which means: + - reach timeout + - reach max loop times + - state is Done, Canceled, or Failed. + + :param task_id: use task_id to lock the task before running. if failed, raise TaskIsLockedError + :param timeout: + :param loop: + :return: + """ + pass + + @abstractmethod + def run_ghost( + self, + ghost: Ghost, *, + timeout: float = 0.0, + loop: int = 0, + ) -> Union[Any, TaskState]: + """ + run a ghost task until it stopped, + :param ghost: the ghost is used to generate a task, actually. + :param timeout: if timeout > 0, throw TimeoutError after timeout. + :param loop: how many times to run the ghost event loop. < 1 means no limitations. + :return: [Ghost.Goal, TaskState] + """ + pass + + @abstractmethod + def background_run( + self, + *, + timeout: float = 0.0, + max_events_handling: int = 0, + stop_check: Optional[Callable[[], bool]] = None, + ): + """ + run the event loop for the ghosts in the Shell. + loop is: + 1. pop task notification. + 2. try to converse the task + 3. if failed, pop another task notification. + 4. if success, pop task event and handle it until no event found. + 5. send a task notification after handling, make sure someone check the task events are empty. + only the tasks that depth > 0 have notifications. + background run itself is blocking method, run it in a separate thread for parallel execution. + :param timeout: + :param max_events_handling: + :param stop_check: check stop signal from outside function. + :return: + """ pass class Conversation(Protocol): - id: str - args: dict - state: dict - result: Optional[dict] - status: str + """ + interface for operate on synchronized (task is locked) ghost + """ @abstractmethod - def messages(self) -> Iterable[Message]: + def ghost(self) -> Ghost: pass @abstractmethod - def handle_event(self, event: Event) -> Iterable[Message]: + def session(self) -> Session: pass @abstractmethod - def send_event(self, event: Event) -> None: + def is_done(self) -> bool: + pass + + @abstractmethod + def create_response(self, inputs: Iterable[Message]) -> Iterable[Message]: + pass + + @abstractmethod + def handle_event(self, event: Event) -> Iterable[Message]: pass @abstractmethod def pop_event(self, event: Event) -> Optional[Event]: pass + + @abstractmethod + def fail(self, error: Exception) -> bool: + pass + + @abstractmethod + def close(self): + pass + + @abstractmethod + def closed(self) -> bool: + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_val is not None: + return self.fail(exc_val) + else: + self.close() + return None diff --git a/ghostos/core/abcd/ghostos_data_objects.py b/ghostos/core/abcd/ghostos_data_objects.py deleted file mode 100644 index 6422bb75..00000000 --- a/ghostos/core/abcd/ghostos_data_objects.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations -from typing import Protocol, Optional, Iterable, List, Dict - - -class Task(Protocol): - class Args(Protocol): - pass - - class Result(Protocol): - pass - - -class Function(Protocol): - pass - - -class Message(Protocol): - pass - - -class Prompt(Protocol[Function]): - id: str - system: List[Message] - history: List[Message] - inputs: Iterable[Message] - thinks: Iterable[Message] - instructions: Iterable[Message] - tools: Dict[str, Function] - - -class Event(Protocol): - pass - - -class Turn(Protocol): - id: str - event: Optional[Event] - thinks: List[Message] - actions: List[Message] - - -class Conversation(Protocol[Function]): - id: str - turns: List[Turn] diff --git a/ghostos/core/abcd/ghostos_for_ai.py b/ghostos/core/abcd/ghostos_for_ai.py deleted file mode 100644 index 6fc81eae..00000000 --- a/ghostos/core/abcd/ghostos_for_ai.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import Protocol, TypeVar, Optional -from abc import ABC, abstractmethod -from .ghostos_data_objects import Task - -T = TypeVar("T", bound=Task) - - -class State(Protocol[T]): - - @abstractmethod - def on_args(self, args: T.Args): - pass - - @abstractmethod - def result(self) -> Optional[T.Result]: - pass - - -S = TypeVar("S", bound=State) - - -class Moss(Protocol): - state: State - - -def think(moss: Moss) -> None: - """ - :param moss: - """ - pass - -def action(moss: Moss) -> None: - pass diff --git a/ghostos/core/abcd/ghostos_for_app.py b/ghostos/core/abcd/ghostos_for_app.py deleted file mode 100644 index 4f5d08bd..00000000 --- a/ghostos/core/abcd/ghostos_for_app.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import Optional, Type -from .ghostos_for_ai import Task, State, Moss -from .ghostos_for_user import Agent -from .kernel import Ghost, Shell - -__task__: Optional[Type[Task]] = None -__state__: Optional[Type[State]] = None -__moss__: Optional[Type[Moss]] = None -__agent__: Optional[Agent] = None -__ghost__: Optional[Ghost] = None -__shell__: Optional[Shell] = None diff --git a/ghostos/core/abcd/ghostos_for_developer.py b/ghostos/core/abcd/ghostos_for_developer.py deleted file mode 100644 index 9f95a427..00000000 --- a/ghostos/core/abcd/ghostos_for_developer.py +++ /dev/null @@ -1,125 +0,0 @@ -from __future__ import annotations -from typing import Protocol, Callable, Optional, Literal, Union, Iterable, Type, TypeVar, ClassVar, Generic -from abc import ABC, abstractmethod -from pydantic import BaseModel -from ghostos.container import Container -from ghostos.common import Identifiable, Identifier -from .kernel import Ghost -from .ghostos_for_ai import Task - -MessageTypes = Union[str,] - -G = TypeVar("G", bound=Ghost) - - -class GhostOS(Protocol): - @abstractmethod - def container(self) -> Container: - pass - - @abstractmethod - def get_agent(self, name: str = "") -> Agent: - pass - - @abstractmethod - def set_agent(self, name: str, agent: Agent) -> None: - pass - - @abstractmethod - def get_ghost(self, route: str) -> GhostFunc: - pass - - @abstractmethod - def converse( - self, - agent: Union[Agent, str, None] = None, - ghost: Union[GhostFunc, str, None] = None, - args: Union[dict, BaseModel] = None, - session_id: Optional[str] = None, - parent_id: Optional[str] = None, - ) -> Conversation: - pass - - @abstractmethod - def call( - self, - ghost: G, - args: Union[dict, G.Args], - run_until_complete: bool = True, - state: Optional[State[G]] = None - ) -> State[G]: - pass - - -class Event(Protocol): - event_id: str - type: str - - -class Agent(Identifiable, ABC): - - def meta_instruction(self) -> str: - pass - - def ghost_id(self) -> Union[str, None]: - pass - - -class Turn(BaseModel): - id: str - event: Optional[Event] - messages: list[Message] - - -class Thread(Protocol): - turns: list[Turn] - - -class State(Generic[G]): - id: str - status: Literal[''] - args: G.Args - ghost: G - returns: G.Returns - - -class Conversation(Protocol[Ghost]): - @abstractmethod - def state(self) -> State: - pass - - @abstractmethod - def thread(self) -> Thread: - pass - - @abstractmethod - def update(self, args: Union[Ghost.Args, dict]) -> State: - pass - - @abstractmethod - def chat(self, *messages: MessageTypes, chunks: bool = True) -> Iterable[Message]: - pass - - @abstractmethod - def handle_event(self, event: Event, chunks: bool = True) -> Iterable[Message]: - pass - - @abstractmethod - def recv_event(self, event: Event, peek: bool = True) -> Optional[Event]: - pass - - @abstractmethod - def close(self): - pass - - @abstractmethod - def closed(self) -> bool: - pass - - @abstractmethod - def fail(self, error: Exception) -> bool: - pass - - @abstractmethod - def save(self): - pass diff --git a/ghostos/core/abcd/ghostos_for_user.py b/ghostos/core/abcd/ghostos_for_user.py deleted file mode 100644 index 1d721414..00000000 --- a/ghostos/core/abcd/ghostos_for_user.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations -from typing import Protocol, Optional, Iterable, List, Dict -from abc import ABC, abstractmethod -from ghostos.container import Container, Provider -from ghostos.common import IdentifierProtocol -from ghostos.contracts.logger import LoggerItf -from .transport import Message - - -class Agent(IdentifierProtocol, Protocol): - - @abstractmethod - def meta_instruction(self) -> str: - pass diff --git a/ghostos/core/abcd/ghosts.py b/ghostos/core/abcd/ghosts.py new file mode 100644 index 00000000..e11f2eb9 --- /dev/null +++ b/ghostos/core/abcd/ghosts.py @@ -0,0 +1,78 @@ +from abc import ABC, abstractmethod +from ghostos.common import Identifier +from .ghostos import Ghost, GhostDriver +from pydantic import BaseModel + +""" +Some ghost prototypes. +""" + + +class Agent(BaseModel, Ghost, ABC): + """ + Agent is the base abstract of LLM-based conversational AI entity. + + The Model of the Agent defines its behavior, normally includes: + - persona and instruction + - configurations to create a context (cot/examples/knowledge/memory) for llm + - llm configurations + - tools + - system configurations, like thread truncating / authorities / welcome craft etc. + """ + + Goal = None + + @abstractmethod + def __identifier__(self) -> Identifier: + pass + + +class ChatBot(Agent, ABC): + """ + Chatbot is the simplest kind of the Agents. + Typical Chatbot is Customer Service or Internet search. + Chat only means the most needed feature is to create a dialog-related but alternative context, + for LLM in-context learning. + """ + pass + + +class UserProxy(Ghost, ABC): + """ + LLM-based UserProxy can understand human language and translate the user intends to system actions. + It does not own any charactor or persona, is merely a Nature Language Interface of the system. + Speed and Accuracy are the most important features. + """ + pass + + +class Thought(BaseModel, Ghost, ABC): + """ + Thought is a micro unit to processing thinking with current context; + the Goal of the Thought is to produce a decision or suggestion, add them to the context. + """ + Goal = str + + @abstractmethod + def __identifier__(self) -> Identifier: + pass + + +class AIFunc(BaseModel, Ghost, ABC): + """ + Act like a function but driven by AI models. + AI models dynamic check the function call, and generate code in realtime. + """ + + @abstractmethod + def __identifier__(self) -> Identifier: + pass + + +class Workflow(Ghost, ABC): + """ + workflow is a programmed Finite State Machine that does a certain job and return a certain result. + The Goal of workflow is the result. + Workflow itself is a FSM, but some node of it can be other ghost entity like AIFunc, Thought or Agent. + """ + pass diff --git a/ghostos/core/abcd/kernel.py b/ghostos/core/abcd/kernel.py deleted file mode 100644 index 5cd0a365..00000000 --- a/ghostos/core/abcd/kernel.py +++ /dev/null @@ -1,96 +0,0 @@ -from __future__ import annotations -from typing import Protocol, Optional, Iterable, List, Dict, Tuple, Callable, TypeVar, Union, Literal -from abc import ABC, abstractmethod - -import urllib3.util - -from ghostos.container import Container, Provider, Any -from ghostos.common import IdentifierProtocol -from ghostos.contracts.logger import LoggerItf -from ghostos.core.moss import MossRuntime -from ghostos.core.messages import Message, DefaultMessageTypes -from .ghostos_data_objects import * - - -class Frame(Protocol): - id: str - parent_id: Union[str, None] - args: dict - state: dict - status: Literal["created", "running", "finished", ""] - result: Union[dict, None] - callback_id: str - callback_data: dict - - -class Function(Protocol): - @abstractmethod - def on_event(self, runtime: Runtime, frame: Frame, event: Event) -> Operator: - pass - - -class Future(Protocol): - id: str - name: str - arguments: dict - state: dict - returns: Any - - -class Operator(Protocol): - - @abstractmethod - def run(self, runtime: Runtime) -> Optional[Operator]: - pass - - -class Thread(Protocol): - pass - - -class Runtime(Protocol): - container: Container - frame: StackFrame - event: Event - thread: Thread - logger: LoggerItf - - @abstractmethod - def send(self, messages: Iterable[Message], actions: List[Action]) -> Operator: - pass - - @abstractmethod - def wait(self) -> Operator: - pass - - @abstractmethod - def submit(self, func: Callable, *args, **kwargs) -> None: - pass - - @abstractmethod - def destroy(self): - pass - - @abstractmethod - def save(self): - pass - - -class Kernel(Protocol): - - def get_stack(self, session_id: str) -> Run: - pass - - def create_runtime(self, session_id: str) -> Runtime: - pass - - def run(self, runtime: Runtime, max_times: int) -> None: - op = runtime.ghost.on_event(runtime, runtime.event) - count = 0 - while op is not None: - if count > max_times: - raise RuntimeError(f"Operator exceeds max times {max_times}") - next_op = op.run(runtime) - count += 1 - if next_op is not None: - op = next_op diff --git a/ghostos/core/abcd/moss_agent.py b/ghostos/core/abcd/moss_agent.py new file mode 100644 index 00000000..c9f1b048 --- /dev/null +++ b/ghostos/core/abcd/moss_agent.py @@ -0,0 +1,170 @@ +from __future__ import annotations +from typing import Tuple, Optional, Protocol, Any, Self, Iterable, List, Dict, Union, Callable, Any +from types import ModuleType + +from ghostos.common import Identifier, Identical, get_identifier +from ghostos.core.llms import Prompt, PromptPipe, LLMApi, LLMs +from ghostos.core.messages import Message, Caller, Role +from ghostos.core.session import Session, GoThreadInfo, Event +from ghostos.core.abcd.ghostos import Ghost, GhostDriver, Operator, G +from ghostos.core.session.session import SessionProps +from ghostos.core.moss import MossRuntime, MossCompiler, Moss +from ghostos.helpers import generate_import_path, import_from_path, join_import_module_and_spec, uuid, unwrap +from abc import ABC, abstractmethod +from pydantic import BaseModel, Field +import inspect + + +class MossAgent(BaseModel, Ghost): + """ + An Agent defined by a single python file + """ + + Goal = None + + module_name: str = Field( + description="the python module name of the MossAgent located." + ) + + name: str = Field( + default="", + description="The name of the agent, if empty, the source will be it's name if agent instance is missing", + ) + description: str = Field( + default="", + description="The description of the agent. can also defined by __description__ in the source file", + ) + id: Optional[str] = Field( + default=None, + description="if not none, the agent is singleton to the shell", + ) + + @abstractmethod + def __identifier__(self) -> Identifier: + # check if the module exists + agent_id = self.id + name = self.module_name + if self.name: + name = self.name + description = self.description + return Identifier( + id=agent_id, + name=name, + description=description, + ) + + +def __goal__(moss: Moss) -> Any: + return None + + +def __thought__(moss: Moss, props: dict) -> str: + pass + + +class MossAgentDriver(GhostDriver[MossAgent]): + """ + default moss agent driver. + """ + + def __init__(self, ghost: MossAgent): + super().__init__(ghost) + self._module = import_from_path(ghost.module_name) + + def get_goal(self, session: Session) -> Optional[G.Goal]: + if __goal__.__name__ in self._module.__dict__: + method = getattr(self._module, __goal__.__name__) + return method(self.ghost, session) + return __goal__(self.ghost, session) + + def instructions(self, session: Session) -> List[Message]: + if self.ghost.__instruction__: + instruction = self.ghost.__instruction__(self.ghost, session) + else: + instruction = self.ghost.instruction + return [Role.SYSTEM.new(content=instruction)] + + def truncate(self, session: Session, thread: GoThreadInfo) -> GoThreadInfo: + if self.ghost.__truncate__: + return self.ghost.__truncate__(self.ghost, session, thread) + return thread + + def on_event(self, session: Session, event: Event) -> Union[Operator, None]: + thread = session.thread() + thread = self.truncate(session, thread) + + # update event + ok = True + if self.ghost.__update_event__: + ok = self.ghost.__update_event__(self.ghost, session, thread, event) + else: + thread.new_turn(event) + session.update_thread(thread, False) + if not ok: + return None + + # prompt + system = self.instructions(session) + prompt = thread.to_prompt(system) + + thoughts = self.thoughts(session) + for t in thoughts: + prompt, op = t.think(session, prompt) + if op is not None: + return op + return self.action(session, prompt) + + def thoughts(self, session: Session) -> Iterable[Thought]: + if self.ghost.__thoughts__: + return self.ghost.__thoughts__(self.ghost, session) + return [] + + def actions(self, session: Session) -> Dict[str, Action]: + if self.ghost.__actions__: + return self.ghost.__actions__(self.ghost, session) + pass + + def llm_api(self, session: Session) -> LLMApi: + if self.ghost.__llm_api__: + return self.ghost.__llm_api__(self.ghost, session) + return session.container().force_fetch(LLMs).get_api("") + + def action(self, session: Session, prompt: Prompt) -> Optional[Operator]: + actions = self.actions(session) + for action in actions.values(): + prompt = action.process(prompt) + + llm_api = self.llm_api(session) + messenger = session.messenger() + llm_api.deliver_chat_completion( + prompt, + messenger, + ) + messages, callers = messenger.flush() + for caller in callers: + if caller.name in actions: + action = actions[caller.name] + op = action.callback(session, caller) + if op is not None: + return op + return None + + +MossAgent.__ghost_driver__ = MossAgentDriver + + +class Action(PromptPipe, ABC): + @abstractmethod + def name(self) -> str: + pass + + @abstractmethod + def callback(self, session: Session, caller: Caller) -> Optional[Operator]: + pass + + +class Thought(ABC): + + @abstractmethod + def think(self, session: Session, prompt: Prompt) -> Tuple[Prompt, Optional[Operator]]: + pass diff --git a/ghostos/core/abcd/transport.py b/ghostos/core/abcd/transport.py deleted file mode 100644 index a2652e31..00000000 --- a/ghostos/core/abcd/transport.py +++ /dev/null @@ -1,132 +0,0 @@ -from abc import abstractmethod -from typing import Iterable, Callable, List, Optional -from typing_extensions import Literal, Protocol, Self - -__all__ = [ - "Message", -] - - -class Message(Protocol): - """ - 消息协议中的最小传输单元. - """ - - @abstractmethod - def get_seq(self) -> Literal["head", "chunk", "complete"]: - pass - - @abstractmethod - def get_copy(self) -> Self: - pass - - -class Parser(Protocol): - - @abstractmethod - def batch(self, messages: Iterable[Message]) -> Iterable[Message]: - pass - - @abstractmethod - def parse(self, message: Message) -> Iterable[Message]: - pass - - @abstractmethod - def completes(self) -> List[Message]: - pass - - -class Connection(Protocol): - - @abstractmethod - def on_message(self, callback: Callable[[Message], None]): - pass - - @abstractmethod - def on_error(self, callback: Callable[[Message], None]): - pass - - @abstractmethod - def send(self, inputs: Iterable[Message]) -> None: - pass - - @abstractmethod - def cancel(self, error: Optional[str]) -> None: - pass - - @abstractmethod - def wait( - self, - on_message: Optional[Callable[[Message], None]] = None, - on_error: Optional[Callable[[Message], None]] = None, - ) -> None: - pass - - @abstractmethod - def close(self) -> None: - pass - - @abstractmethod - def closed(self) -> bool: - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if self.closed(): - return - if exc_val is not None: - self.cancel(error=str(exc_val)) - self.close() - - -class Request(Protocol): - - @abstractmethod - def ack(self) -> None: - pass - - @abstractmethod - def inputs(self) -> Iterable[Message]: - pass - - @abstractmethod - def write(self, messages: Iterable[Message]) -> None: - pass - - @abstractmethod - def done(self) -> None: - pass - - @abstractmethod - def fail(self, error: str) -> None: - pass - - @abstractmethod - def buffer(self) -> List[Message]: - pass - - @abstractmethod - def close(self) -> None: - pass - - @abstractmethod - def closed(self) -> None: - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if self.closed(): - return - if exc_val is not None: - self.fail(error=str(exc_val)) - self.close() - - -class Server(Protocol): - - def run(self, func: Callable[[Request], None]) -> Connection: - pass diff --git a/ghostos/core/abcd/utils.py b/ghostos/core/abcd/utils.py new file mode 100644 index 00000000..0c23e672 --- /dev/null +++ b/ghostos/core/abcd/utils.py @@ -0,0 +1,63 @@ +from typing import TypeVar, Optional, Type +from ghostos.helpers import import_class_from_path, generate_import_path, md5 +from ghostos.common import get_identifier, to_entity_meta +from .ghostos import Ghost, GhostDriver + + +def get_ghost_driver_type(ghost: Ghost) -> Type[GhostDriver]: + """ + get ghost driver instance by default protocol + """ + if ghost.__ghost_driver__ is not None: + return ghost.__ghost_driver__ + name = ghost.__class__.__name__ + module_name = ghost.__class__.__module__ + import_path = f"{module_name}:{name}Driver" + cls = import_class_from_path(import_path, GhostDriver) + return cls + + +def get_ghost_driver(ghost: Ghost) -> GhostDriver: + ghost_driver_type = get_ghost_driver_type(ghost) + return ghost_driver_type(ghost) + + +def is_ghost(value) -> bool: + try: + id_ = get_identifier(value) + assert id_ is not None + meta = to_entity_meta(value) + assert meta is not None + driver = get_ghost_driver_type(value) + assert issubclass(driver, GhostDriver) + return True + except AssertionError: + return False + + +def make_ghost_task_id( + ghost: Ghost, + shell_id: str, + process_id: str, + parent_task_id: Optional[str], +) -> str: + """ + default way to create ghost task ID + ghost itself can be a locator to its task instance, if the task_id is the same. + """ + identifier = get_identifier(ghost) + + # shell level id + # if the ghost_id is generated each time, the task id is alternative + # if the ghost_id is static, the task id is identical to shell. + if ghost_id := identifier.id: + unique_str = f"shell:{shell_id}:ghost_id:{ghost_id}" + return md5(unique_str) + + # parent scope unique task + # the task is unique to it parent by the name + self_name = identifier.name + cls_name = generate_import_path(ghost.__class__) + unique_str = (f"shell:{shell_id}:process:{process_id}" + f":parent:{parent_task_id}:cls{cls_name}:name:{self_name}") + return md5(unique_str) diff --git a/evaluation/adas/__init__.py b/ghostos/core/agents/__init__.py similarity index 100% rename from evaluation/adas/__init__.py rename to ghostos/core/agents/__init__.py diff --git a/ghostos/core/agents/agent.py b/ghostos/core/agents/agent.py new file mode 100644 index 00000000..0679eb86 --- /dev/null +++ b/ghostos/core/agents/agent.py @@ -0,0 +1,62 @@ +from typing import Optional, Iterable, List, Callable, Self +from abc import ABC, abstractmethod +from ghostos.common import Identical, Identifier +from ghostos.core.session import ( + Event, +) +from ghostos.core.messages import Message +from ghostos.container import Container, Contracts + + +class Agent(Identical, ABC): + + @abstractmethod + def identifier(self) -> Identifier: + pass + + @abstractmethod + def instruction(self) -> str: + pass + + @abstractmethod + def container(self) -> Container: + pass + + @abstractmethod + def run(self, history: List[Message], inputs: Iterable[Message]) -> Iterable[Message]: + pass + + +class StatefulAgent(Agent, ABC): + + @abstractmethod + def on_inputs(self, inputs: Iterable[Message]) -> Iterable[Message]: + pass + + @abstractmethod + def pop_event(self) -> Optional[Event]: + pass + + @abstractmethod + def handle_event(self, event: Event) -> None: + pass + + +class Realtime(ABC): + + @abstractmethod + def on_event(self, callback: Callable[[StatefulAgent, Event], Optional[Event]]) -> None: + pass + + @abstractmethod + def on_message(self, callback: Callable[[StatefulAgent, Message], None]) -> None: + pass + + @abstractmethod + def on_message_chunks( + self, + msg_type: str, + callback: Callable[[StatefulAgent, Iterable[Message]], None], + ) -> None: + pass + diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index 9bb03386..fb65b5f3 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -9,9 +9,9 @@ AIFunc, get_aifunc_instruction, get_aifunc_result_type, get_aifunc_pycontext, get_aifunc_llmapi, ) -from ghostos.core.llms import LLMs, Chat +from ghostos.core.llms import LLMs, Prompt from ghostos.core.moss.abc import MossRuntime -from ghostos.core.session import MsgThread, DefaultEventType, MsgThreadRepo, thread_to_chat +from ghostos.core.session import GoThreadInfo, EventTypes, GoThreads, thread_to_chat from ghostos.core.messages import Role, Message, Stream from ghostos.container import Container @@ -91,10 +91,10 @@ def __init__(self, fn: AIFunc): def name(self) -> str: return self.aifunc.__class__.__name__ - def initialize(self, container: Container, frame: ExecFrame) -> MsgThread: + def initialize(self, container: Container, frame: ExecFrame) -> GoThreadInfo: pycontext = get_aifunc_pycontext(self.aifunc) messages = [] - threads = container.get(MsgThreadRepo) + threads = container.get(GoThreads) if threads: thread = threads.get_thread(frame.frame_id, create=False) if thread: @@ -107,12 +107,12 @@ def initialize(self, container: Container, frame: ExecFrame) -> MsgThread: ) messages.append(system_message) - event = DefaultEventType.INPUT.new( + event = EventTypes.REQUEST.new( task_id="", from_task_id="", messages=messages, ) - thread = MsgThread.new( + thread = GoThreadInfo.new( thread_id=frame.frame_id, event=event, pycontext=pycontext, @@ -133,7 +133,7 @@ def generate_system_messages(self, runtime: MossRuntime) -> List[Message]: message = Role.SYSTEM.new(content=prompt) return [message] - def on_chat(self, chat: Chat) -> None: + def on_chat(self, chat: Prompt) -> None: pass def on_message(self, message: Message, step: ExecStep, upstream: Optional[Stream]) -> None: @@ -150,10 +150,10 @@ def on_system_messages(self, messages: List[Message]) -> None: def think( self, manager: AIFuncExecutor, - thread: MsgThread, + thread: GoThreadInfo, step: ExecStep, upstream: Optional[Stream] - ) -> Tuple[MsgThread, Optional[Any], bool]: + ) -> Tuple[GoThreadInfo, Optional[Any], bool]: # get compiler by current exec step # the MossCompiler.container().get(AIFuncCtx) will bind this step. compiler = manager.compiler(step, upstream) @@ -194,19 +194,19 @@ def think( error = None # handle code: if not code: - error = Role.new_assistant_system( + error = Role.new_system( content="Error! You shall only write python code! DO NOT ACT LIKE IN A CHAT. " "Generate code in ``." ) elif "main(" not in code: - error = Role.new_assistant_system( + error = Role.new_system( content="Error! No main function found in your generation! use `` to wrap your code." ) if error is not None: thread.new_turn( - event=DefaultEventType.OBSERVE.new( + event=EventTypes.ROTATE.new( messages=[error], task_id=thread.id, from_task_id=thread.id, @@ -233,13 +233,13 @@ def think( output = executed.std_output step.std_output = output if output: - output_message = Role.new_assistant_system( + output_message = Role.new_system( content=f"Observation:\n\nmoss executed main, std output is: \n{output}" ) messages = [output_message] self.on_message(output_message, step, upstream) else: - output_message = Role.new_assistant_system( + output_message = Role.new_system( content=f"Observation:\n\nhave not printed anything" ) messages = [output_message] @@ -247,7 +247,7 @@ def think( # append the messages. thread.new_turn( - event=DefaultEventType.OBSERVE.new( + event=EventTypes.ROTATE.new( messages=messages, task_id=thread.id, from_task_id=thread.id, @@ -262,11 +262,11 @@ def think( raise except Exception as e: exe_info = "\n".join(traceback.format_exception(e)[-5:]) - output_message = Role.new_assistant_system( + output_message = Role.new_system( content=f"moss executed main, exception occurs: \n{exe_info}" ) thread.new_turn( - event=DefaultEventType.OBSERVE.new( + event=EventTypes.ROTATE.new( messages=[output_message], task_id=thread.id, from_task_id=thread.id, @@ -295,9 +295,9 @@ def parse_moss_code_in_message(self, message: Message) -> str: return content[code_start_index + len(CODE_MARK_LEFT): code_end_index].strip() - def on_save(self, container: Container, frame: ExecFrame, step: ExecStep, thread: MsgThread) -> None: + def on_save(self, container: Container, frame: ExecFrame, step: ExecStep, thread: GoThreadInfo) -> None: # 如果 threads 抽象存在, 就保存一下. 还应该做一些日志的工作. - threads = container.get(MsgThreadRepo) + threads = container.get(GoThreads) if threads is not None: threads.save_thread(thread) repo = container.get(AIFuncRepository) diff --git a/ghostos/core/aifunc/executor.py b/ghostos/core/aifunc/executor.py index f4078415..b9be26ce 100644 --- a/ghostos/core/aifunc/executor.py +++ b/ghostos/core/aifunc/executor.py @@ -9,7 +9,7 @@ from ghostos.core.aifunc.func import AIFunc, AIFuncResult, get_aifunc_result_type from ghostos.core.aifunc.interfaces import AIFuncExecutor, AIFuncCtx, AIFuncDriver, ExecFrame, ExecStep from ghostos.core.aifunc.driver import DefaultAIFuncDriverImpl -from ghostos.core.messages import Stream, DefaultMessageTypes +from ghostos.core.messages import Stream, MessageType __all__ = ['DefaultAIFuncExecutorImpl', 'DefaultAIFuncExecutorProvider'] @@ -119,7 +119,7 @@ def execute( # if frame is the root, send final message as protocol return result except Exception as e: - frame.error = DefaultMessageTypes.ERROR.new(content=str(e)) + frame.error = MessageType.ERROR.new(content=str(e)) raise def get_driver( diff --git a/ghostos/core/aifunc/func.py b/ghostos/core/aifunc/func.py index 3091aae7..1070a767 100644 --- a/ghostos/core/aifunc/func.py +++ b/ghostos/core/aifunc/func.py @@ -3,7 +3,7 @@ from abc import ABC from pydantic import BaseModel from ghostos.helpers import generate_import_path, import_from_path -from ghostos.common import PromptAbleClass +from ghostos.common import PrompterClass from ghostos.core.llms import LLMs, LLMApi from ghostos.core.moss.utils import make_class_prompt, add_comment_mark from ghostos.core.moss.prompts import get_class_magic_prompt @@ -19,7 +19,7 @@ ] -class AIFunc(PromptAbleClass, BaseModel, ABC): +class AIFunc(PrompterClass, BaseModel, ABC): """ Model interface for an AIFunc arguments, always followed by an AIFuncResult Model. The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need. @@ -47,7 +47,7 @@ def func_name(cls) -> str: return generate_import_path(cls) -class AIFuncResult(PromptAbleClass, BaseModel, ABC): +class AIFuncResult(PrompterClass, BaseModel, ABC): """ the AIFuncResult Model """ diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index e50c5faf..fa9d6378 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -3,8 +3,8 @@ from ghostos.core.aifunc.func import AIFunc, AIFuncResult from ghostos.core.moss.decorators import cls_source_code from ghostos.core.moss import MossCompiler, PyContext -from ghostos.core.llms import LLMApi, Chat -from ghostos.core.session import MsgThread +from ghostos.core.llms import LLMApi, Prompt +from ghostos.core.session import GoThreadInfo from ghostos.core.messages import Message, Stream, Payload from ghostos.common import Identifier from ghostos.helpers import generate_import_path, uuid @@ -93,7 +93,7 @@ class ExecStep(BaseModel): func: str = Field(description="AIFunc name") depth: int = Field(description="depth of the ExecFrame") step_id: str = Field(default_factory=uuid, description="step id") - chat: Optional[Chat] = Field(default=None, description="llm chat") + chat: Optional[Prompt] = Field(default=None, description="llm chat") generate: Optional[Message] = Field(default=None, description="AI generate message") messages: List[Message] = Field(default_factory=list, description="list of messages") std_output: str = Field(default="", description="the std output of the AIFunc step") @@ -350,7 +350,7 @@ def __init__(self, fn: AIFunc): self.aifunc = fn @abstractmethod - def initialize(self, container: Container, frame: ExecFrame) -> MsgThread: + def initialize(self, container: Container, frame: ExecFrame) -> GoThreadInfo: """ initialize the AIFunc thread by quest configuration. """ @@ -360,10 +360,10 @@ def initialize(self, container: Container, frame: ExecFrame) -> MsgThread: def think( self, manager: AIFuncExecutor, - thread: MsgThread, + thread: GoThreadInfo, step: ExecStep, upstream: Optional[Stream], - ) -> Tuple[MsgThread, Optional[Any], bool]: + ) -> Tuple[GoThreadInfo, Optional[Any], bool]: """ think another round based on msg thread. each think round must pass a ExecStep to it. @@ -377,7 +377,7 @@ def think( pass @abstractmethod - def on_save(self, container: Container, frame: ExecFrame, step: ExecStep, thread: MsgThread) -> None: + def on_save(self, container: Container, frame: ExecFrame, step: ExecStep, thread: GoThreadInfo) -> None: """ save the status on each step """ diff --git a/ghostos/core/ghost_dev_pattern/concepts.py b/ghostos/core/ghost_dev_pattern/concepts.py index d2125c2e..d3bc2acd 100644 --- a/ghostos/core/ghost_dev_pattern/concepts.py +++ b/ghostos/core/ghost_dev_pattern/concepts.py @@ -11,23 +11,11 @@ class Func(Protocol): AI Function definition in data-driven pattern. """ - class Args(BaseModel): - """ - the Arguments model of the function. - """ - pass - - class Returns(BaseModel): - """ - the return values model of the function. - """ - pass - - # __meta__: ClassVar[dict] = {} - # meta is convention of the func, optional field + Args: Type[BaseModel] + Returns: Optional[Type[BaseModel]] -F = TypeVar("F", bound=Func) +F = TypeVar('F', bound=Func) class Task(Protocol[F]): diff --git a/ghostos/core/ghost_dev_pattern/ghost.py b/ghostos/core/ghost_dev_pattern/ghost.py index 17d10b19..dcb24759 100644 --- a/ghostos/core/ghost_dev_pattern/ghost.py +++ b/ghostos/core/ghost_dev_pattern/ghost.py @@ -9,7 +9,7 @@ from ghostos.core.moss import PyContext, Moss, MossCompiler from ghostos.core.session import Session, Event from ghostos.core.messages import Message -from ghostos.common import IdentifiableClass, identify_class, Identifier, Identifiable +from ghostos.common import IDAbleClass, identify_class, Identifier, Identical from ghostos.entity import EntityMeta from pydantic import BaseModel, Field from contextlib import contextmanager @@ -36,7 +36,7 @@ """ -class Ghost(BaseModel, Identifiable, ABC): +class Ghost(BaseModel, Identical, ABC): @abstractmethod def identifier(self) -> Identifier: diff --git a/ghostos/core/ghostos.py b/ghostos/core/ghostos.py index dd14da1e..a46cc570 100644 --- a/ghostos/core/ghostos.py +++ b/ghostos/core/ghostos.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from ghostos.entity import EntityMeta from ghostos.core.messages import Stream -from ghostos.core.session import EventBus, Event, TaskRepo, Task, SessionProcess, GhostProcessRepo +from ghostos.core.session import EventBus, Event, GoTasks, GoTaskStruct, GoProcess, GoProcesses from ghostos.core.ghosts import Ghost, GhostConf, Inputs from ghostos.contracts.logger import LoggerItf from ghostos.contracts.shutdown import Shutdown @@ -43,7 +43,7 @@ def get_or_create_process( session_id: str, process_id: Optional[str] = None, task_id: Optional[str] = None, - ) -> Optional[SessionProcess]: + ) -> Optional[GoProcess]: """ get a process from session_id, if not exists, create one. :param ghost_meta: to create ghost instance. @@ -58,8 +58,8 @@ def get_or_create_process( def make_ghost( self, *, upstream: Stream, - process: SessionProcess, - task: Optional[Task] = None, + process: GoProcess, + task: Optional[GoTaskStruct] = None, task_id: Optional[str] = None, ) -> Ghost: """ @@ -145,18 +145,18 @@ def _eventbus(self) -> EventBus: """ return self.container().force_fetch(EventBus) - def _processes(self) -> GhostProcessRepo: - return self.container().force_fetch(GhostProcessRepo) + def _processes(self) -> GoProcesses: + return self.container().force_fetch(GoProcesses) - def _tasks(self) -> TaskRepo: - return self.container().force_fetch(TaskRepo) + def _tasks(self) -> GoTasks: + return self.container().force_fetch(GoTasks) @abstractmethod def make_ghost( self, *, upstream: Stream, - process: SessionProcess, - task: Optional[Task] = None, + process: GoProcess, + task: Optional[GoTaskStruct] = None, task_id: Optional[str] = None, ) -> Ghost: """ @@ -170,11 +170,11 @@ def get_or_create_process( session_id: str, process_id: Optional[str] = None, task_id: Optional[str] = None, - ) -> Optional[SessionProcess]: + ) -> Optional[GoProcess]: processes = self._processes() proc = processes.get_session_process(session_id) if proc is None or (process_id and process_id != proc.pid): - proc = SessionProcess.new( + proc = GoProcess.new( session_id=session_id, ghost_meta=ghost_meta, process_id=process_id, diff --git a/ghostos/core/ghostos2.py b/ghostos/core/ghostos2.py new file mode 100644 index 00000000..74f66bde --- /dev/null +++ b/ghostos/core/ghostos2.py @@ -0,0 +1,257 @@ +from typing import Optional, Generic, TypeVar, Iterable, Callable, Tuple, Union, List, Type +from typing_extensions import Literal, Self +from abc import ABC, abstractmethod +from ghostos.container import Container +from ghostos.core.ghosts import ( + Ghost, GhostConf, Inputs, + Shell, Thought +) +from ghostos.core.session import ( + Event, + GoProcess, + GoTaskStruct, GoTasks, + GoThreadInfo, GoThreads, + EventBus, +) +from ghostos.core.messages import ( + Stream, Message, Received, +) + + +class Host(ABC): + """ + An instance of a bot or an agent. + Composed by a shell and the ghost. + """ + + @abstractmethod + def process(self) -> GoProcess: + """ + process of the host instance. + """ + pass + + @abstractmethod + def conf(self) -> GhostConf: + """ + ghost conf to generate a ghost instance + """ + pass + + @abstractmethod + def shell(self) -> Shell: + """ + the shell of the Host + """ + pass + + @abstractmethod + def send_event(self, event: Event) -> None: + """ + send an event to the ghost, but not handle it immediately + """ + pass + + @abstractmethod + def task(self, task_id: Optional[str] = None) -> Optional[GoTaskStruct]: + pass + + @abstractmethod + def tasks(self) -> GoTasks: + pass + + @abstractmethod + def thread(self, task_id: Optional[str] = None) -> Optional[GoThreadInfo]: + pass + + @abstractmethod + def history(self, task_id: Optional[str] = None) -> Optional[List[Message]]: + pass + + @abstractmethod + def threads(self) -> GoThreads: + pass + + +class Run(ABC): + @abstractmethod + def task(self) -> GoTaskStruct: + pass + + @abstractmethod + def host(self) -> Host: + pass + + @abstractmethod + def is_main_task(self) -> bool: + pass + + @abstractmethod + def event(self) -> Event: + pass + + @abstractmethod + def thought(self) -> None: + pass + + @abstractmethod + def receive(self) -> Iterable[Received]: + pass + + @abstractmethod + def stop(self, wait: bool = True, *, cancel_futures: bool = False) -> None: + """ + :param wait: + :param cancel_futures: + :return: + """ + pass + + +class Runner(ABC): + """ + Is a way to run host's tasks. + You can either use it synchronously by run-frame in a controlled loop; + or asynchronously by loop_util_stop function. + """ + + @abstractmethod + def run_frame(self) -> Optional[Run]: + """ + run a single frame of background tasks, + one frame means a task handle one event. + :return: a Run instance that can retrieve the messages. stop is useless + """ + pass + + @abstractmethod + def loop_until_stop( + self, + on_run: Callable[[Run], None], + on_error: Callable[[Run, Exception], None], + stop: Optional[Callable[[], bool]] = None, + worker: int = 4, + idle: float = -1, + ) -> None: + """ + block the main loop, run background task frame until stop is called. + the actual logic is: + 1. pop task notification from eventbus, if none, idle or stop + 2. lock the task, if failed, drop the notification and go on popping. + 3. consume task event from eventbus + 4. if event is None, go on popping task notification + 5. if event exists, run a task frame with the event and callback function. + 6. checkpoint: check stop condition + 7. redirect to step 1 + + :param on_run: when a Run is generated by some task, event. + can do something, like push message to someone + :param on_error: usually log error, or stop everything by run.stop() + :param stop: stop condition callback. if return True, the loop will stop at checkpoint. + :param worker: concurrent worker number. + :param idle: if no work to do, idle a while, in seconds. if negative, the loop stop when in idle. + :return: block the main loop. or you can call this function in another thread. + """ + pass + + +class HostRunner(Runner, ABC): + @abstractmethod + def host(self) -> Host: + """ + host runner can get to the host instance. + """ + pass + + @abstractmethod + def send_inputs( + self, + inputs: Inputs, + *, + mod: Literal["block", "queue", "intercept"] = "block", + timeout: float = 2, + ) -> Optional[Run]: + """ + send inputs to the ghost main task and try to create a synchronize response. + the inputs will be filtered by main task's thought, to generate an event or intercept it. + then the main task will execute a frame immediately, and return a Run instance. + + if another process lock the main task, the `mod` decide the following actions. + + :param inputs: the unparsed inputs. + :param mod: + - block: if the main task is locked, retry until the timeout is reached, then return with error message. + - queue: if the main task is locked and timeout, send an asynchronize event, and return None + - intercept: if the main task is locked and timeout, force to lock the main task, intercept other run. + :param timeout: timeout in second, before the main task's locker required. + :return: if locked the main task, run a frame and return a Run instance. + """ + pass + + @abstractmethod + def run_task_frame(self, task_id: Optional[str] = None) -> Optional[Run]: + """ + run a background task frame manually + :param task_id: if task_id is None, pop a task notification from eventbus. + :return: if run is None, means no event found or lock task failed. + """ + pass + + +class GhostOS(ABC): + + @abstractmethod + def container(self) -> Container: + pass + + @abstractmethod + def register(self, conf: GhostConf) -> None: + pass + + @abstractmethod + def forge( + self, + shell: Shell, + ghost_id: Union[str, GhostConf], + ) -> Host: + """ + build a Host (robot or agent) with it shell (body) and ghost (brain). + shell id is the session id of the ghost. + :param shell: + :param ghost_id: + :return: + :exception: NotImplementedError if the ghost_id is not registered + """ + pass + + @abstractmethod + def send_event(self, event: Event) -> None: + """ + send an async event to the system. + shall be handled by background run. + """ + pass + + @abstractmethod + def create_runner(self) -> Runner: + """ + create a background runner for all the tasks in this ghostos system. + """ + pass + + @abstractmethod + def create_host_runner( + self, + shell: Shell, + ghost_id: Union[str, GhostConf], + process_id: Optional[str] = None, + ) -> HostRunner: + """ + create a host runner that has its own process eventbus. + only host runner can receive its task events. + :param shell: + :param ghost_id: + :param process_id: the specific process id, + :return: + """ + pass diff --git a/ghostos/core/ghosts/actions.py b/ghostos/core/ghosts/actions.py index 9afa3520..5c400eeb 100644 --- a/ghostos/core/ghosts/actions.py +++ b/ghostos/core/ghosts/actions.py @@ -2,23 +2,23 @@ import json from abc import ABC, abstractmethod from ghostos.container import Container -from ghostos.core.llms import Chat, LLMTool, ChatPreparer +from ghostos.core.llms import Prompt, LLMFunc, PromptPipe from ghostos.core.ghosts.operators import Operator from ghostos.core.messages.message import Caller from ghostos.core.session import Session -from ghostos.common import Identifiable, Identifier +from ghostos.common import Identical, Identifier from pydantic import BaseModel __all__ = ['Action', 'ToolAction'] -class Action(Identifiable, ChatPreparer, ABC): +class Action(Identical, PromptPipe, ABC): """ ghost actions that triggered by LLM output's caller """ @abstractmethod - def prepare_chat(self, chat: Chat) -> Chat: + def process(self, chat: Prompt) -> Prompt: """ Action update the chat with messages, tool, functional_tokens, etc. :param chat: origin chat. @@ -62,11 +62,11 @@ def do_act(self, container: "Container", session: Session, arguments: A) -> Opti """ pass - def prepare_chat(self, chat: Chat) -> Chat: + def process(self, chat: Prompt) -> Prompt: """ 将工具注入到 chat. """ - tool = LLMTool.new( + tool = LLMFunc.new( name=self.name, desc=self.description, parameters=self.args_model.model_json_schema(), diff --git a/ghostos/core/ghosts/assistants.py b/ghostos/core/ghosts/assistants.py index 3dc50369..b8117a38 100644 --- a/ghostos/core/ghosts/assistants.py +++ b/ghostos/core/ghosts/assistants.py @@ -1,6 +1,6 @@ from typing import Optional, TypeVar, Generic, Type from abc import ABC, abstractmethod -from ghostos.common import Identifiable, Identifier +from ghostos.common import Identical, Identifier from ghostos.core.ghosts import Ghost from ghostos.core.ghosts.thoughts import Thought, ModelThought from ghostos.helpers import generate_import_path, md5, import_from_path @@ -16,7 +16,7 @@ ] -class Assistant(Identifiable, ABC): +class Assistant(Identical, ABC): """ Assistant is a special thinking unit in Ghost. Each assistant has a unique identifier, is a singleton instance in the Process. diff --git a/ghostos/core/ghosts/ghost.py b/ghostos/core/ghosts/ghost.py index 9e8d929f..90651515 100644 --- a/ghostos/core/ghosts/ghost.py +++ b/ghostos/core/ghosts/ghost.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from ghostos.entity import ModelEntity, EntityMeta, EntityFactory from ghostos.container import Container -from ghostos.common import Identifiable, Identifier +from ghostos.common import Identical, Identifier from ghostos.contracts.logger import LoggerItf from ghostos.contracts.modules import Modules from ghostos.contracts.configs import Configs @@ -55,7 +55,7 @@ class Inputs(BaseModel): ) -class GhostConf(ModelEntity, Identifiable, ABC): +class GhostConf(ModelEntity, Identical, ABC): """ configuration of the ghost """ diff --git a/ghostos/core/ghosts/schedulers.py b/ghostos/core/ghosts/schedulers.py index d666c565..2e660d58 100644 --- a/ghostos/core/ghosts/schedulers.py +++ b/ghostos/core/ghosts/schedulers.py @@ -4,7 +4,7 @@ from ghostos.core.ghosts.thoughts import Thought from ghostos.core.ghosts.assistants import Assistant from ghostos.core.messages.message import MessageKind -from ghostos.core.llms import ChatPreparer +from ghostos.core.llms import PromptPipe from dataclasses import dataclass __all__ = [ @@ -66,7 +66,7 @@ def fail(self, reason: str, reply: str) -> Operator: pass -class MultiTask(ChatPreparer, ABC): +class MultiTask(PromptPipe, ABC): """ You are equipped with this MultiTasks Library that can execute thought in an asynchronous task. A thought is a mind-machine usually driven by LLM, can resolve certain type of task in multi-turns chain of thought. diff --git a/ghostos/core/ghosts/thoughts.py b/ghostos/core/ghosts/thoughts.py index e2041ccc..bb1635f7 100644 --- a/ghostos/core/ghosts/thoughts.py +++ b/ghostos/core/ghosts/thoughts.py @@ -2,17 +2,17 @@ from typing import Optional, TypeVar, Generic, Type, Iterable from abc import ABC, abstractmethod from ghostos.entity import Entity, ModelEntity -from ghostos.core.session import Event, MsgThread, Session +from ghostos.core.session import Event, GoThreadInfo, Session from ghostos.core.ghosts.ghost import Ghost from ghostos.core.ghosts.operators import Operator -from ghostos.common import Identifiable, Identifier, PromptAbleClass +from ghostos.common import Identical, Identifier, PrompterClass from ghostos.helpers import uuid, generate_import_path from pydantic import Field __all__ = ['Thought', 'ModelThought', 'ThoughtDriver', 'BasicThoughtDriver', "Mindset", "get_thought_driver_type", 'T'] -class Thought(Identifiable, Entity, ABC): +class Thought(Identical, Entity, ABC): """ The Thought class serves as a fundamental component of AI, adept at initiating a stateful task to address specific inquiries. @@ -60,7 +60,7 @@ class Thought(ABC): return inspect.getsource(cls) -class ModelThought(Thought, ModelEntity, PromptAbleClass, ABC): +class ModelThought(Thought, ModelEntity, PrompterClass, ABC): """ The abstract model of the thought based by pydantic.BaseModel. """ @@ -179,7 +179,7 @@ def on_created(self, g: Ghost, e: Event) -> Optional[Operator]: return self.think(g, e) @staticmethod - def prepare_thread(session: Session, thread: MsgThread) -> MsgThread: + def prepare_thread(session: Session, thread: GoThreadInfo) -> GoThreadInfo: """ prepare thread usually defining thread id and thread.save_file for debug reason """ diff --git a/ghostos/core/ghosts/user.py b/ghostos/core/ghosts/user.py deleted file mode 100644 index e3637fa2..00000000 --- a/ghostos/core/ghosts/user.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import List -from abc import ABC, abstractmethod -from ghostos.common import Identifiable -from ghostos.core.llms import ChatPreparer - - -class User(Identifiable, ChatPreparer, ABC): - - @abstractmethod - def allow(self, action: str, *args, **kwargs) -> bool: - pass - - @abstractmethod - def authorized(self, resource: str, *args, **kwargs) -> List[str]: - pass diff --git a/ghostos/core/ghosts/utils.py b/ghostos/core/ghosts/utils.py index 09918268..f43e259b 100644 --- a/ghostos/core/ghosts/utils.py +++ b/ghostos/core/ghosts/utils.py @@ -3,8 +3,8 @@ from ghostos.core.ghosts.operators import Operator from ghostos.core.ghosts.thoughts import Thought, ThoughtDriver from ghostos.core.session import ( - Event, DefaultEventType, - Task, TaskState, TaskRepo, + Event, EventTypes, + GoTaskStruct, TaskState, GoTasks, ) from ghostos.core.messages import ( MessageKind, @@ -51,7 +51,7 @@ def initialize(self) -> None: root_thought = self.ghost.root_thought() identifier = root_thought.identifier() meta = root_thought.to_entity_meta() - task = Task.new( + task = GoTaskStruct.new( task_id=task_id, session_id=session.id(), process_id=process.process_id, @@ -64,7 +64,7 @@ def initialize(self) -> None: session.update_process(process) session.update_task(task, None, False) - def fetch_thought_from_task(self, task: "Task") -> ThoughtDriver: + def fetch_thought_from_task(self, task: "GoTaskStruct") -> ThoughtDriver: thought = self.ghost.entity_factory().force_new_entity(task.meta, Thought) return self.ghost.mindset().get_thought_driver(thought) @@ -126,7 +126,7 @@ def create_child_tasks( children.append(child) children_names.append(child.name) # 准备任务的创建事件. 这个事件的消息应该是目标 Thought 自己生成的. 所以不需要消息. - e = DefaultEventType.CREATED.new( + e = EventTypes.CREATED.new( task_id=task_id, messages=[], from_task_id=parent_task_id, @@ -138,7 +138,7 @@ def create_child_tasks( session.create_tasks(*children) # 存储要发送的事件. session.fire_events(*events) - thread.append(Role.new_assistant_system( + thread.append(Role.new_system( content=f"create {len(children_names)} async tasks", )) # 更新 awaits 的信息. @@ -153,7 +153,7 @@ def cancel_children_tasks( reason: str = "", instruction: str = "", includes: Optional[List[str]] = None, - self_task: Optional[Task] = None, + self_task: Optional[GoTaskStruct] = None, ) -> None: """ 取消当前任务的子任务. @@ -169,7 +169,7 @@ def cancel_children_tasks( if not children_ids: return - tasks = self.ghost.container().force_fetch(TaskRepo) + tasks = self.ghost.container().force_fetch(GoTasks) children = list(tasks.get_task_briefs(children_ids)) if not children: # 没有 children. @@ -179,7 +179,7 @@ def cancel_children_tasks( canceling_events = [] for t in children: if not TaskState.is_dead(t.state) and t.task_id in includes_set: - event = DefaultEventType.CANCELING.new( + event = EventTypes.CANCEL.new( task_id=t.task_id, from_task_id=self_task.task_id, from_task_name=self_task.name, @@ -210,7 +210,7 @@ def send_task_event( messages: List[MessageKind], reason: str = "", instruction: str = "", - self_task: Optional[Task] = None, + self_task: Optional[GoTaskStruct] = None, ) -> None: """ 主动向一个目标任务发送通知. @@ -230,7 +230,7 @@ def send_task_event( session = self.ghost.session() self_task = self_task if self_task is not None else session.task() - event = DefaultEventType(event_type).new( + event = EventTypes(event_type).new( task_id=task_id, messages=outputs, from_task_id=self_task.task_id, diff --git a/ghostos/core/llms/__init__.py b/ghostos/core/llms/__init__.py index 503cfe5e..0f08622b 100644 --- a/ghostos/core/llms/__init__.py +++ b/ghostos/core/llms/__init__.py @@ -1,13 +1,13 @@ from __future__ import annotations from ghostos.core.llms.configs import ModelConf, ServiceConf, LLMsConfig, OPENAI_DRIVER_NAME from ghostos.core.llms.llm import LLMs, LLMDriver, LLMApi -from ghostos.core.llms.chat import Chat, ChatPreparer, prepare_chat +from ghostos.core.llms.prompt import Prompt, PromptPipe, run_prompt_pipeline from ghostos.core.llms.embedding import Embeddings, EmbedApi, Embedding -from ghostos.core.llms.tools import LLMTool, FunctionalToken +from ghostos.core.llms.tools import LLMFunc, FunctionalToken __all__ = [ - 'Chat', 'ChatPreparer', 'prepare_chat', - 'LLMs', 'LLMDriver', 'LLMApi', 'LLMTool', 'FunctionalToken', + 'Prompt', 'PromptPipe', 'run_prompt_pipeline', + 'LLMs', 'LLMDriver', 'LLMApi', 'LLMFunc', 'FunctionalToken', 'ModelConf', 'ServiceConf', 'LLMsConfig', 'OPENAI_DRIVER_NAME', 'Embedding', 'Embeddings', 'EmbedApi', diff --git a/ghostos/core/llms/configs.py b/ghostos/core/llms/configs.py index 162b6651..cf3a4492 100644 --- a/ghostos/core/llms/configs.py +++ b/ghostos/core/llms/configs.py @@ -32,11 +32,6 @@ class ModelConf(Payload): kwargs: Dict[str, Any] = Field(default_factory=dict, description="kwargs") -class EmbedConf(BaseModel): - service: str = Field(description="service name, share with llm model conf") - model: str = Field(description="the model name that provide embeddings") - - class ServiceConf(BaseModel): """ The service configuration of a llm. @@ -70,9 +65,7 @@ class LLMsConfig(BaseModel): default_factory=list, description="define llm services, such as openai or moonshot", ) - default: ModelConf = Field( - description="define default LLMApi 's model config.", - ) + default: str = Field(description="one of the models key") models: Dict[str, ModelConf] = Field( default_factory=dict, description="define llm apis, the key is llm_api_name and value is model config of it.", diff --git a/ghostos/core/llms/embedding.py b/ghostos/core/llms/embedding.py deleted file mode 100644 index 126a893b..00000000 --- a/ghostos/core/llms/embedding.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import List, Optional -from abc import ABC, abstractmethod -from pydantic import BaseModel, Field - - -# ---- config ---- # - -class Embedding(BaseModel): - content: str = Field(description="origin content") - service: str = Field(description="llm service") - model: str = Field(description="llm model") - embedding: List[float] = Field(description="embedding") - - -class Embeddings(BaseModel): - result: List[Embedding] = Field(default_factory=list) - # todo: 未来再管这些. - # cast: Cast = Field(description="cast") - - -class EmbedApi(ABC): - - @abstractmethod - def get_embedding(self, content: str, model: Optional[str] = None) -> Embedding: - pass - - @abstractmethod - def get_embeddings(self, contents: List[str], model: Optional[str] = None) -> Embeddings: - pass diff --git a/ghostos/core/llms/llm.py b/ghostos/core/llms/llm.py index b73ac4b0..ae440b93 100644 --- a/ghostos/core/llms/llm.py +++ b/ghostos/core/llms/llm.py @@ -4,7 +4,7 @@ from typing import List, Tuple, Iterable, Optional from ghostos.core.messages import Message, Stream from ghostos.core.llms.configs import ModelConf, ServiceConf -from ghostos.core.llms.chat import Chat +from ghostos.core.llms.prompt import Prompt __all__ = [ 'LLMs', 'LLMApi', 'LLMDriver', @@ -31,7 +31,7 @@ def get_model(self) -> ModelConf: pass @abstractmethod - def parse_chat(self, chat: Chat) -> Chat: + def parse_chat(self, chat: Prompt) -> Prompt: """ parse chat by llm api default logic. Functional tokens for example. this method is used to test. @@ -46,25 +46,22 @@ def text_completion(self, prompt: str) -> str: pass @abstractmethod - def chat_completion(self, chat: Chat) -> Message: + def chat_completion(self, chat: Prompt) -> Message: pass @abstractmethod - def chat_completion_chunks(self, chat: Chat) -> Iterable[Message]: + def chat_completion_chunks(self, chat: Prompt) -> Iterable[Message]: """ todo: 暂时先这么定义了. """ pass - def deliver_chat_completion(self, chat: Chat, deliver: Stream) -> None: + def deliver_chat_completion(self, chat: Prompt, deliver: Stream) -> None: """ 逐个发送消息的包. """ - if not deliver.accept_chunks(): + if deliver.completes_only(): message = self.chat_completion(chat) - if message.is_complete(): - # add model conf as message payload - self.get_model().set(message) deliver.deliver(message) return items = self.chat_completion_chunks(chat) diff --git a/ghostos/core/llms/chat.py b/ghostos/core/llms/prompt.py similarity index 62% rename from ghostos/core/llms/chat.py rename to ghostos/core/llms/prompt.py index 75d3c1e4..99b618c4 100644 --- a/ghostos/core/llms/chat.py +++ b/ghostos/core/llms/prompt.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod -from typing import List, Iterable, Optional, Union, Callable +from typing import List, Iterable, Optional, Union, Callable, Tuple from openai.types.chat.completion_create_params import Function, FunctionCall from openai import NotGiven, NOT_GIVEN from openai.types.chat.chat_completion_function_call_option_param import ChatCompletionFunctionCallOptionParam @@ -10,35 +10,36 @@ from pydantic import BaseModel, Field from ghostos import helpers -from ghostos.core.messages import Message, Role -from .tools import LLMTool, FunctionalToken +from ghostos.core.messages import Message, Role, Payload +from .tools import LLMFunc, FunctionalToken __all__ = [ - 'LLMTool', 'FunctionalToken', - 'Chat', 'ChatPreparer', - 'prepare_chat', + 'Prompt', 'PromptPipe', + 'run_prompt_pipeline', + 'PromptStorage', ] - # ---- api objects ---- # -class Chat(BaseModel): +class Prompt(BaseModel): """ 模拟对话的上下文. """ id: str = Field(default_factory=helpers.uuid, description="trace id") - streaming: bool = Field(default=False, description="streaming mode") + description: str = Field(default="") system: List[Message] = Field(default_factory=list, description="system messages") history: List[Message] = Field(default_factory=list) inputs: List[Message] = Field(default_factory=list, description="input messages") appending: List[Message] = Field(default_factory=list, description="appending messages") - functions: List[LLMTool] = Field(default_factory=list) - functional_tokens: List[FunctionalToken] = Field(default_factory=list) + functions: List[LLMFunc] = Field(default_factory=list) function_call: Optional[str] = Field(default=None, description="function call") + # deprecated + functional_tokens: List[FunctionalToken] = Field(default_factory=list) + def system_prompt(self) -> str: contents = [] if self.system: @@ -47,13 +48,13 @@ def system_prompt(self) -> str: contents.append(message.get_content()) return "\n\n".join(contents) - def get_messages(self) -> List[Message]: + def get_messages(self, with_system: bool = True) -> List[Message]: """ 返回所有的消息. """ messages = [] # combine system messages into one - if self.system: + if with_system and self.system: system_message = Role.SYSTEM.new(content=self.system_prompt()) messages.append(system_message) if self.history: @@ -117,22 +118,81 @@ def get_openai_function_call(self) -> Union[FunctionCall, NotGiven]: return "auto" return ChatCompletionFunctionCallOptionParam(name=self.function_call) - -class ChatPreparer(ABC): + def add(self, messages: Iterable[Message]) -> Iterable[Message]: + for msg in messages: + if msg.is_complete(): + self.appending.append(msg.get_copy()) + yield msg + + def fork( + self, + inputs: List[Message], + system: Optional[List[Message]] = None, + description: str = "", + prompt_id: Optional[str] = None, + functions: Optional[List[Function]] = None, + function_call: Optional[str] = None, + ) -> Prompt: + """ + fork current prompt. + """ + prompt_id = prompt_id or helpers.uuid() + description = description + copied = self.model_copy(update={ + "id": prompt_id, + "description": description, + }, deep=True) + if copied.inputs: + copied.history.extend(copied.inputs) + copied.inputs = inputs + if copied.appending: + copied.history.extend(copied.appending) + copied.appending = [] + if system: + copied.system = system + if functions: + copied.functions = functions + if function_call is not None: + copied.function_call = function_call + return copied + + +class PromptPayload(Payload): + key = "prompt_info" + + prompt_id: str = Field(description="created from prompt") + desc: str = Field(default="description of the prompt") + + +class PromptPipe(ABC): """ 用来对 chat message 做加工. 基本思路是, 尽可能保证消息体本身的一致性, 在使用的时候才对消息结构做调整. """ @abstractmethod - def prepare_chat(self, chat: Chat) -> Chat: + def process(self, prompt: Prompt) -> Prompt: pass -def prepare_chat(chat: Chat, updater: Iterable[ChatPreparer]) -> Chat: +def run_prompt_pipeline(prompt: Prompt, pipeline: Iterable[PromptPipe]) -> Prompt: """ 通过多个 filter 来加工 chat. """ - for f in updater: - chat = f.prepare_chat(chat) - return chat + for f in pipeline: + prompt = f.process(prompt) + return prompt + + +class PromptStorage(ABC): + """ + save and get prompt + """ + + @abstractmethod + def save(self, prompt: Prompt) -> None: + pass + + @abstractmethod + def get(self, prompt_id: str) -> Optional[Prompt]: + pass diff --git a/ghostos/core/llms/tools.py b/ghostos/core/llms/tools.py index ace0cb95..7abb0732 100644 --- a/ghostos/core/llms/tools.py +++ b/ghostos/core/llms/tools.py @@ -2,16 +2,16 @@ from enum import Enum -from typing import Dict, Optional +from typing import Dict, Optional, Type from pydantic import BaseModel, Field -from ghostos.common import Identifiable, Identifier +from ghostos.common import Identical, Identifier from ghostos.core.messages import Caller # ---- tool and function ---- # -class LLMTool(BaseModel): +class LLMFunc(BaseModel): """ a common wrapper for JSONSchema LLM tool. Compatible to OpenAI Tool. @@ -38,6 +38,19 @@ def new(cls, name: str, desc: Optional[str] = None, parameters: Optional[Dict] = del parameters["title"] return cls(name=name, description=desc, parameters=parameters) + @classmethod + def from_model( + cls, + name: str, + model: Type[BaseModel], + description: Optional[str] = None, + ): + if description is None: + description = model.__doc__ + return cls.new(name, desc=description, parameters=model.model_json_schema()) + + +# todo: remove class FunctionalTokenMode(str, Enum): XML = "xml" @@ -48,7 +61,7 @@ class FunctionalTokenMode(str, Enum): """ token mod. use single token to parse content. """ -class FunctionalToken(Identifiable, BaseModel): +class FunctionalToken(Identical, BaseModel): """ functional token means to provide function ability to LLM not by JsonSchema, but by token. LLM generates special tokens (such as XML marks) to indicate further tokens are the content of the function. @@ -81,8 +94,8 @@ def identifier(self) -> Identifier: description=self.description, ) - def as_tool(self) -> LLMTool: + def as_tool(self) -> LLMFunc: """ all functional token are compatible to a llm tool. """ - return LLMTool.new(name=self.name, desc=self.description, parameters=self.parameters) + return LLMFunc.new(name=self.name, desc=self.description, parameters=self.parameters) diff --git a/ghostos/core/messages/__init__.py b/ghostos/core/messages/__init__.py index e056ecf8..c4c39e91 100644 --- a/ghostos/core/messages/__init__.py +++ b/ghostos/core/messages/__init__.py @@ -1,14 +1,14 @@ from ghostos.core.messages.message import ( - Message, Role, DefaultMessageTypes, - Caller, Payload, PayloadItem, Attachment, + Message, Role, MessageType, + Caller, MessageClass, MessageKind, MessageKindParser, ) +from ghostos.core.messages.payload import Payload from ghostos.core.messages.openai import ( OpenAIMessageParser, DefaultOpenAIMessageParser, DefaultOpenAIParserProvider, CompletionUsagePayload, ) from ghostos.core.messages.buffers import Buffer, Flushed from ghostos.core.messages.helpers import copy_messages -# todo: replace with transport -from ghostos.core.messages.stream import Stream, Receiver, Received -from ghostos.core.messages.transport import new_arr_connection +# from ghostos.core.messages.stream import Stream, Receiver, Received +from ghostos.core.messages.transport import Stream, Receiver, new_arr_connection diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index e6624373..1678bbe3 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -1,5 +1,6 @@ import enum -from typing import Optional, Dict, Set, Iterable, Union, List, ClassVar +import time +from typing import Optional, Dict, Set, Iterable, Union, List, Any, ClassVar from typing_extensions import Self, Literal from abc import ABC, abstractmethod from pydantic import BaseModel, Field @@ -7,13 +8,13 @@ from copy import deepcopy __all__ = [ - "Message", "Role", "DefaultMessageTypes", + "Message", "Role", "MessageType", "MessageClass", "MessageKind", "MessageKindParser", - "Payload", "PayloadItem", "Attachment", "Caller", + "Caller", ] -Seq = Literal["head", "chunk", "complete"] +SeqType = Literal["head", "chunk", "complete"] class Role(str, enum.Enum): @@ -21,23 +22,22 @@ class Role(str, enum.Enum): 消息体的角色, 对齐了 OpenAI """ + UNKNOWN = "" USER = "user" ASSISTANT = "assistant" SYSTEM = "system" - FUNCTION = "function" - TOOL = "tool" @classmethod def all(cls) -> Set[str]: return set(map(lambda x: x.value, cls)) @classmethod - def new_assistant_system( + def new_system( cls, content: str, memory: Optional[str] = None, ): - return cls.USER.new(content, memory=memory, name="__system__") + return cls.SYSTEM.new(content, memory=memory) def new( self, @@ -47,7 +47,7 @@ def new( type_: Optional[str] = None, ) -> "Message": return Message.new_tail( - type_=type_ if type_ else DefaultMessageTypes.DEFAULT.value, + type_=type_ if type_ else MessageType.DEFAULT.value, role=self.value, name=name, content=content, @@ -55,16 +55,25 @@ def new( ) -class DefaultMessageTypes(str, enum.Enum): +class MessageType(str, enum.Enum): DEFAULT = "" - CHAT_COMPLETION = "openai.chat_completion" - ERROR = "ghostos.streaming.error" - FINAL = "ghostos.streaming.final" - ACK = "ghostos.streaming.ack" + TEXT = "text" + VARIABLE = "variable" + FUNCTION_CALL = "function_call" + FUNCTION_OUTPUT = "function_output" + AUDIO = "audio" + IMAGE = "image" + VIDEO = "video" + FILE = "file" + ERROR = "error" + FINAL = "final" def new( self, *, - content: str, role: str = Role.ASSISTANT.value, memory: Optional[str] = None, name: Optional[str] = None, + content: str, + role: str = Role.ASSISTANT.value, + memory: Optional[str] = None, + name: Optional[str] = None, ) -> "Message": chunk = not self.is_protocol_type(self.value) return Message(content=content, memory=memory, name=name, type=self.value, role=role, chunk=chunk) @@ -92,13 +101,13 @@ def new_user( ): return self.new(content=content, role=Role.USER.value, memory=memory, name=name) - def match(self, message: "Message") -> bool: - return message.type == self.value - @classmethod def final(cls): return Message(type=cls.FINAL.value, role=Role.ASSISTANT.value, chunk=False) + def match(self, message: "Message") -> bool: + return message.type == self.value + @classmethod def is_final(cls, pack: "Message") -> bool: return pack.type == cls.FINAL.value @@ -127,70 +136,6 @@ def add(self, message: "Message") -> None: message.callers.append(self) -class Payload(BaseModel, ABC): - """ - 消息体的可扩展的部分. 拥有强类型设计. - """ - key: ClassVar[str] - - @classmethod - def read(cls, message: "Message") -> Optional["Payload"]: - value = message.payloads.get(cls.key, None) - if value is None: - return None - return cls(**value) - - def set(self, message: "Message") -> None: - message.payloads[self.key] = self.model_dump() - - def exists(self, message: "Message") -> bool: - return self.key in message.payloads - - -class PayloadItem(Payload, ABC): - """ - 自身可以粘包的特殊 payload. - 比如 tokens 的计数. - """ - - @abstractmethod - def join(self, payload: "PayloadItem") -> "PayloadItem": - pass - - def set(self, message: "Message") -> None: - exists = message.payloads.get(self.key, None) - if exists is not None: - join = self.__class__(**exists) - payload = self.join(join) - payload.set(message) - return - super().set(message) - - -class Attachment(BaseModel, ABC): - """ - 消息上可以追加的附件. - """ - key: ClassVar[str] - - @classmethod - def read(cls, message: "Message") -> Optional[List["Attachment"]]: - value = message.attachments.get(cls.key, None) - if not value: - return None - result = [] - for item in value: - result.append(cls(**item)) - return result - - def add(self, message: "Message") -> None: - values = message.attachments.get(self.key) - if values is None: - values = [] - values.append(self.model_dump()) - message.attachments[self.key] = values - - # the Message class is a container for every kind of message and it's chunks. # I need this container because: # 1. I hate weak-type container of message, countless type checking and adapting @@ -232,17 +177,11 @@ class Message(BaseModel): msg_id: str = Field(default="", description="unique message id. ") ref_id: Optional[str] = Field(default=None, description="the referenced message id.") + index: Optional[int] = Field(default=None, description="the index of the message.") type: str = Field(default="", description="default message type, if empty, means text") - # created: float = Field( - # default=0.0, - # description="Message creation time, only available in head chunk or complete one", - # ) - # todo: remove later, use seq instead - chunk: bool = Field(default=True, description="if the message is a chunk or a complete one") - - role: str = Field(default=Role.ASSISTANT.value, description="Message role", enum=Role.all()) - name: Optional[str] = Field(default=None, description="Message sender name") + role: str = Field(default="", description="Message role", enum=Role.all()) + name: Optional[str] = Field(default=None, description="Message sender name") content: Optional[str] = Field( default=None, description="Message content that for client side. empty means it shall not be showed", @@ -252,16 +191,15 @@ class Message(BaseModel): description="Message memory that for llm, if none, means content is memory", ) - # --- attachments --- # + attrs: Optional[Dict[str, Any]] = Field( + None, + description="the additional attrs for the message type" + ) payloads: Dict[str, Dict] = Field( default_factory=dict, description="payload type key to payload item. payload shall be a strong-typed dict" ) - attachments: Dict[str, List[Dict]] = Field( - default_factory=dict, - description="attachment type key to attachment items. attachment shall be a strong-typed dict", - ) callers: List[Caller] = Field( default_factory=list, @@ -271,11 +209,10 @@ class Message(BaseModel): # chunk_count: int = Field(default=0, description="how many chunks of this complete message") # time_cast: float = Field(default=0.0, description="from first chunk to tail message.") - streaming_id: Optional[str] = Field( - default=None, - description="may be multiple streaming exists, use streaming id to separate them into a order", - ) - seq: Seq = Field(default="chunk") + seq: SeqType = Field(default="chunk", description="sequence type in streaming") + created: float = Field(default=0.0, description="time when message was created") + + __attachment__: Optional[Any] = None @classmethod def new_head( @@ -287,7 +224,6 @@ def new_head( name: Optional[str] = None, msg_id: Optional[str] = None, ref_id: Optional[str] = None, - # created: int = 0, ): """ create a head chunk message @@ -303,14 +239,17 @@ def new_head( """ if msg_id is None: msg_id = uuid() - # if created <= 0: - # created = round(time.time(), 4) + created = round(time.time(), 4) return cls( - role=role, name=name, content=content, memory=memory, chunk=True, + role=role, + name=name, + content=content, + memory=memory, + seq="head", type=typ_, ref_id=ref_id, msg_id=msg_id, - # created=created, + created=created, ) @classmethod @@ -323,7 +262,7 @@ def new_tail( name: Optional[str] = None, msg_id: Optional[str] = None, ref_id: Optional[str] = None, - # created: int = 0, + attrs: Optional[Dict[str, Any]] = None, ): """ create a tail message, is the complete message of chunks. @@ -334,17 +273,20 @@ def new_tail( :param name: :param msg_id: :param ref_id: - # :param created: + :param attrs: :return: """ msg = cls.new_head( - role=role, name=name, content=content, memory=memory, + role=role, + name=name, + content=content, + memory=memory, typ_=type_, msg_id=msg_id, ref_id=ref_id, - # created=created, ) - msg.chunk = False + msg.seq = "complete" + msg.attrs = attrs return msg @classmethod @@ -366,8 +308,9 @@ def new_chunk( :return: """ return cls( - role=role, name=name, content=content, memory=memory, chunk=True, + role=role, name=name, content=content, memory=memory, type=typ_, + seq="chunk", ) def get_content(self) -> str: @@ -448,44 +391,38 @@ def update(self, pack: "Message") -> None: if pack.memory is not None: self.memory = pack.memory - self.payloads.update(deepcopy(pack.payloads)) + if pack.attrs: + self.attrs.update(pack.attrs) - if pack.attachments is not None: - for key, items in pack.attachments.items(): - saved = self.attachments.get(key, []) - saved.append(*[deepcopy(at) for at in saved]) - self.attachments[key] = saved + self.payloads.update(deepcopy(pack.payloads)) if pack.callers: self.callers.extend(pack.callers) - # self.chunk_count += 1 - # if self.created: - # now = round(time.time(), 4) - # self.time_cast = round(now - self.created, 4) def get_type(self) -> str: """ return a message type """ - return self.type or DefaultMessageTypes.DEFAULT + return self.type or MessageType.DEFAULT def is_empty(self) -> bool: """ a message is empty means it has no content, payloads, callers, or attachments """ no_content = not self.content and not self.memory - no_payloads = not self.payloads and not self.attachments and not self.callers - return no_content and no_payloads + no_attrs = not self.attrs + no_payloads = not self.payloads and self.__attachment__ is None and not self.callers + return no_content and no_attrs and no_payloads def is_complete(self) -> bool: """ complete message is not a chunk one """ - return not self.chunk or self.seq == "complete" + return self.seq == "complete" def is_head(self) -> bool: return self.seq == "head" - def get_seq(self) -> Seq: + def get_seq(self) -> SeqType: return self.seq def dump(self) -> Dict: @@ -498,30 +435,12 @@ def __str__(self): return self.get_content() - -class Buffer(ABC): - - @abstractmethod - def buffer(self, message: Iterable[Message]) -> Iterable[Message]: - pass - - @abstractmethod - def completes(self) -> List[Message]: - pass - - def __enter__(self): - return self - - @abstractmethod - def __exit__(self, exc_type, exc_val, exc_tb): - pass - - class MessageClass(ABC): """ A message class with every field that is strong-typed the payloads and attachments shall parse to dict when generate to a Message. """ + message_type: ClassVar[Union[MessageType, str]] @abstractmethod def to_message(self) -> Message: @@ -537,6 +456,38 @@ def from_message(cls, container: Message) -> Optional[Self]: """ pass + @abstractmethod + def to_openai_param(self) -> Dict: + pass + + +class MessageClasses: + def __init__( + self, + classes: Iterable[MessageClass], + ) -> None: + self.classes = {str(cls.message_type): cls for cls in classes} + + def parse(self, messages: List[Message]) -> List[MessageClass]: + result = [] + for message in messages: + if not message.is_complete(): + continue + if message.type not in self.classes: + continue + cls = self.classes[message.type] + item = cls.from_message(message) + if item is not None: + result.append(item) + return result + + def to_openai_params(self, messages: List[Message]) -> List[Dict]: + parsed = self.parse(messages) + result = [] + for message in parsed: + result.append(message.to_openai_param()) + return result + MessageKind = Union[Message, MessageClass, str] """sometimes we need three forms of the message to define an argument or property.""" @@ -547,9 +498,15 @@ class MessageKindParser: middleware that parse weak MessageKind into Message chunks """ - def __init__(self, role: str = Role.ASSISTANT.value, ref_id: Optional[str] = None) -> None: + def __init__( + self, *, + name: Optional[str] = None, + role: str = Role.ASSISTANT.value, + ref_id: Optional[str] = None, + ) -> None: self.role = role self.ref_id = ref_id + self.name = name def parse(self, messages: Iterable[MessageKind]) -> Iterable[Message]: for item in messages: @@ -571,4 +528,8 @@ def parse(self, messages: Iterable[MessageKind]) -> Iterable[Message]: def _with_ref(self, item: Message) -> Message: if self.ref_id is not None: item.ref_id = self.ref_id + if not item.role and self.role: + item.role = self.role + if not item.name and self.name: + item.name = self.name return item diff --git a/ghostos/core/messages/message_classes.py b/ghostos/core/messages/message_classes.py new file mode 100644 index 00000000..0d5abd1a --- /dev/null +++ b/ghostos/core/messages/message_classes.py @@ -0,0 +1,63 @@ +from typing import Optional, Dict +from typing_extensions import Self + +from .message import Message, MessageClass, MessageType +from pydantic import BaseModel, Field + + +class DefaultMC(MessageClass): + message_type = MessageType.DEFAULT + message: Message + + def __init__(self, message: Message): + self.message = message + + def to_message(self) -> Message: + return self.message + + @classmethod + def from_message(cls, container: Message) -> Optional[Self]: + if container.is_complete(): + return cls(container) + return None + + def to_openai_param(self) -> Dict: + raise NotImplementedError("todo") + + +class VariableMC(MessageClass, BaseModel): + """ + 变量类型消息. + """ + message_type: MessageType.VARIABLE + + role: str = Field(default="", description="who send the message") + name: Optional[str] = Field(None, description="who send the message") + vid: str = Field(description="variable unique id") + type: str = Field(description="variable type, used to unmarshal the variable. could be any str, or import path") + description: str = Field("", description="Description of the variable") + + def to_message(self) -> Message: + return Message.new_tail( + type_=MessageType.VARIABLE.value, + content="", + role=self.role, + name=self.name, + attrs=self.model_dump(include={"vid", "type", "description"}) + ) + + @classmethod + def from_message(cls, container: Message) -> Optional[Self]: + if container.type != MessageType.VARIABLE.value: + return None + + data = container.attrs + if data is None: + return None + data["name"] = container.name + data["role"] = container.role + obj = cls(**data) + return obj + + def to_openai_param(self) -> Dict: + pass diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index 27223767..6bcb69f4 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -12,7 +12,7 @@ from openai.types.chat.chat_completion_function_message_param import ChatCompletionFunctionMessageParam from openai.types.chat.chat_completion_tool_message_param import ChatCompletionToolMessageParam -from ghostos.core.messages.message import Message, DefaultMessageTypes, Role, Caller, PayloadItem +from ghostos.core.messages.message import Message, MessageType, Role, Caller, PayloadItem from ghostos.container import Provider, Container, INSTANCE __all__ = [ @@ -89,7 +89,7 @@ class DefaultOpenAIMessageParser(OpenAIMessageParser): """ def parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: - if message.type == DefaultMessageTypes.CHAT_COMPLETION: + if message.type == MessageType.CHAT_COMPLETION: return self._parse_assistant_chat_completion(message) else: return self._parse_message(message) @@ -163,7 +163,7 @@ def _parse_assistant_chat_completion(message: Message) -> Iterable[ChatCompletio )] def from_chat_completion(self, message: ChatCompletionMessage) -> Message: - pack = Message.new_tail(type_=DefaultMessageTypes.CHAT_COMPLETION, role=message.role, content=message.content) + pack = Message.new_tail(type_=MessageType.CHAT_COMPLETION, role=message.role, content=message.content) if message.function_call: caller = Caller( name=message.function_call.name, @@ -189,7 +189,7 @@ def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) - if len(item.choices) == 0: # 接受到了 openai 协议尾包. 但在这个协议里不作为尾包发送. usage = CompletionUsagePayload.from_chunk(item) - pack = Message.new_chunk(role=Role.ASSISTANT.value, typ_=DefaultMessageTypes.CHAT_COMPLETION) + pack = Message.new_chunk(role=Role.ASSISTANT.value, typ_=MessageType.CHAT_COMPLETION) usage.set(pack) yield pack else: @@ -203,20 +203,20 @@ def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) - def _new_pack_from_delta(delta: ChoiceDelta, first: bool) -> Message: if first: pack = Message.new_head(role=Role.ASSISTANT.value, content=delta.content, - typ_=DefaultMessageTypes.CHAT_COMPLETION) + typ_=MessageType.CHAT_COMPLETION) else: pack = Message.new_chunk(role=Role.ASSISTANT.value, content=delta.content, - typ_=DefaultMessageTypes.CHAT_COMPLETION) + typ_=MessageType.CHAT_COMPLETION) # function call if delta.function_call: function_call = Caller(**delta.function_call.model_dump()) - pack.callers.append(function_call) + pack.callers.add(function_call) # tool calls if delta.tool_calls: for item in delta.tool_calls: tool_call = Caller(**item.tool_call.model_dump()) - pack.callers.append(tool_call) + pack.callers.add(tool_call) return pack diff --git a/ghostos/core/messages/payload.py b/ghostos/core/messages/payload.py new file mode 100644 index 00000000..c7f16422 --- /dev/null +++ b/ghostos/core/messages/payload.py @@ -0,0 +1,37 @@ +from typing import ClassVar, Optional, Protocol, Dict, Union, Self +from abc import ABC +from pydantic import BaseModel +from .message import Message + + +class HasPayloads(Protocol): + """ + some item that has payloads + """ + payloads: Dict[str, Dict] + + +class Payload(BaseModel, ABC): + """ + strong typed payload protocol + """ + key: ClassVar[str] + """ the unique key of the payload""" + + @classmethod + def read(cls, message: Union[Message, HasPayloads]) -> Optional[Self]: + value = message.payloads.get(cls.key, None) + if value is None: + return None + return cls(**value) + + def set(self, message: Union[Message, HasPayloads]) -> None: + message.payloads[self.key] = self.model_dump() + + @classmethod + def exists(cls, message: Union[Message, HasPayloads]) -> bool: + if not hasattr(message, "payloads"): + return False + if not isinstance(message.payloads, dict): + return False + return cls.key in message.payloads diff --git a/ghostos/core/messages/pipeline.py b/ghostos/core/messages/pipeline.py index 1f78d5c3..0c4ae7b6 100644 --- a/ghostos/core/messages/pipeline.py +++ b/ghostos/core/messages/pipeline.py @@ -1,7 +1,7 @@ from typing import Iterable, List, Optional, Dict from typing_extensions import Self from abc import ABC, abstractmethod -from ghostos.core.messages.message import Message, DefaultMessageTypes +from ghostos.core.messages.message import Message, MessageType from ghostos.core.messages.helpers import iter_messages @@ -42,7 +42,7 @@ def across(self, messages: Iterable[Message]) -> Iterable[Message]: head: Optional[Message] = None final: Optional[Message] = None for item in messages: - if DefaultMessageTypes.is_protocol_message(item): + if MessageType.is_protocol_message(item): break if head is None: if item.is_complete(): @@ -78,7 +78,7 @@ def new(self) -> Self: def across(self, messages: Iterable[Message]) -> Iterable[Message]: for item in messages: - if DefaultMessageTypes.is_protocol_message(item): + if MessageType.is_protocol_message(item): yield item break elif item.is_complete(): @@ -93,7 +93,7 @@ def new(self) -> Self: def across(self, messages: Iterable[Message]) -> Iterable[Message]: last_tail: Optional[Message] = None for item in messages: - if DefaultMessageTypes.is_protocol_message(item): + if MessageType.is_protocol_message(item): yield item break if not item.is_complete(): diff --git a/ghostos/core/messages/stream.py b/ghostos/core/messages/stream.py index 86d22725..a59c2dca 100644 --- a/ghostos/core/messages/stream.py +++ b/ghostos/core/messages/stream.py @@ -8,6 +8,8 @@ ] +# todo: remove + class Stream(ABC): """ streaming output messages. diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index d14b5c6e..42a9ecf4 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -3,7 +3,7 @@ from typing_extensions import Protocol from collections import deque from abc import abstractmethod -from ghostos.core.messages.message import Message, DefaultMessageTypes +from ghostos.core.messages.message import Message, MessageType from ghostos.core.messages.pipeline import SequencePipe import time @@ -136,7 +136,7 @@ def recv(self) -> Iterable[Message]: continue is_alive = alive() if not is_alive: - self._error = DefaultMessageTypes.ERROR.new(content="Receiver is closed") + self._error = MessageType.ERROR.new(content="Receiver is closed") self._done = True break if self._idle: @@ -152,9 +152,9 @@ def add(self, message: Message) -> bool: return False if not self._check_alive(): return False - if DefaultMessageTypes.is_protocol_message(message): + if MessageType.is_protocol_message(message): self._done = True - if DefaultMessageTypes.ERROR.match(message): + if MessageType.ERROR.match(message): self._error = message return True else: @@ -167,7 +167,7 @@ def cancel(self): def fail(self, error: str) -> bool: self._done = True - self._error = DefaultMessageTypes.ERROR.new(content=error) + self._error = MessageType.ERROR.new(content=error) return False def done(self) -> bool: @@ -228,7 +228,7 @@ def close(self): if self._closed: return if self._alive: - self._receiver.add(DefaultMessageTypes.final()) + self._receiver.add(MessageType.final()) self._alive = False self._closed = True del self._receiver @@ -236,7 +236,7 @@ def close(self): def fail(self, error: str) -> bool: if self._error is not None: return False - self._error = DefaultMessageTypes.ERROR.new(content=error) + self._error = MessageType.ERROR.new(content=error) if self._alive: self._receiver.add(self._error) self._alive = False diff --git a/ghostos/core/moss/abc.py b/ghostos/core/moss/abc.py index 9bcde474..d2a1e6f9 100644 --- a/ghostos/core/moss/abc.py +++ b/ghostos/core/moss/abc.py @@ -77,10 +77,10 @@ MOSS_NAME = "moss" -MOSS_HIDDEN_MARK = "# " +MOSS_HIDDEN_MARK = "# " """ pycontext.module 源码某一行以这个标记开头, 其后的代码都不生成到 prompt 里. """ -MOSS_HIDDEN_UNMARK = "# " +MOSS_HIDDEN_UNMARK = "# " """ pycontext.module 源码某一行以这个标记开头, 其后的代码都展示到 prompt 里. """ diff --git a/ghostos/core/moss/examples/baseline.py b/ghostos/core/moss/examples/baseline.py index 6ab12bc8..377e5422 100644 --- a/ghostos/core/moss/examples/baseline.py +++ b/ghostos/core/moss/examples/baseline.py @@ -30,8 +30,8 @@ class Moss(Parent): """依赖注入 Foo 的测试用例. """ -# -# !!! 使用 `# ` 和 `# ` 包裹的代码不会对大模型呈现. +# +# !!! 使用 `# ` 和 `# ` 包裹的代码不会对大模型呈现. from typing import TYPE_CHECKING @@ -105,4 +105,4 @@ def main(moss: Moss) -> int: """ return plus(2, 2) -# \ No newline at end of file +# \ No newline at end of file diff --git a/ghostos/core/moss/examples/mem_baseline.py b/ghostos/core/moss/examples/mem_baseline.py index 6f7cb887..21b672f1 100644 --- a/ghostos/core/moss/examples/mem_baseline.py +++ b/ghostos/core/moss/examples/mem_baseline.py @@ -11,7 +11,7 @@ class Moss(Parent): text_memory: Mem0TextMemory -# +# def test_main(moss: Moss) -> int: """ @@ -49,7 +49,7 @@ def test_main(moss: Moss) -> int: history = m.history(memory_id=memory_id) print(history) -# +# if __name__ == "__main__": diff --git a/ghostos/core/moss/examples/test_suite.py b/ghostos/core/moss/examples/test_suite.py index 54ebf23a..c4a3180d 100644 --- a/ghostos/core/moss/examples/test_suite.py +++ b/ghostos/core/moss/examples/test_suite.py @@ -5,7 +5,7 @@ def plus(a: int, b: int) -> int: return a + b -# +# if __name__ == '__test__': """ 可以这样定义只在当前文件编译成 modulename=__test__ 才运行的方法. @@ -27,4 +27,4 @@ def test_3(moss: Moss) -> int: __moss_test_cases__ = ['test_1', 'test_2', 'test_3'] """用这个魔术变量, 可以让 MossTestSuit 批量调用三个方法测试. """ -# +# diff --git a/ghostos/core/moss/prompts.py b/ghostos/core/moss/prompts.py index bb020a22..a4b0d58a 100644 --- a/ghostos/core/moss/prompts.py +++ b/ghostos/core/moss/prompts.py @@ -10,7 +10,7 @@ add_source_indent, ) from ghostos.core.moss.exports import Exporter -from ghostos.common import PromptAble, PromptAbleClass +from ghostos.common import Prompter, PrompterClass from ghostos.helpers import generate_import_path import inspect @@ -270,7 +270,7 @@ def get_magic_prompt(value: Any) -> Optional[str]: 不做类型校验, 直接返回 PROMPT_MAGIC_ATTR 生成 prompt 的结果. :param value: 合理类型是 module, function, method, instance of class """ - if isinstance(value, PromptAble): + if isinstance(value, Prompter): return value.__prompt__() fn = getattr(value, PROMPT_MAGIC_ATTR, None) return unwrap_str(fn) if fn is not None else None @@ -281,7 +281,7 @@ def get_class_magic_prompt(value: Any) -> Optional[str]: 不做类型校验, 直接返回 CLASS_PROMPT_MAGIC_ATTR 生成 prompt 的结果. :param value: 合理的类型是 class. """ - if issubclass(value, PromptAbleClass): + if issubclass(value, PrompterClass): return value.__class_prompt__() fn = getattr(value, CLASS_PROMPT_MAGIC_ATTR, None) return unwrap_str(fn) if fn is not None else None diff --git a/ghostos/core/moss/utils.py b/ghostos/core/moss/utils.py index 5a2c4114..6a7e6621 100644 --- a/ghostos/core/moss/utils.py +++ b/ghostos/core/moss/utils.py @@ -2,7 +2,7 @@ import re from typing import Any, Dict, Callable, Optional, List, Iterable, TypedDict, is_typeddict from pydantic import BaseModel -from ghostos.common import Identifiable, Descriptive +from ghostos.common import Identical, Descriptive __all__ = [ @@ -352,21 +352,6 @@ def get_calling_modulename(skip: int = 0) -> Optional[str]: return None -def get_obj_desc(obj: Any) -> Optional[str]: - if isinstance(obj, Descriptive): - return obj.get_description() - if isinstance(obj, Identifiable): - return obj.identifier().description - if hasattr(obj, 'desc'): - return getattr(obj, 'desc', None) - if hasattr(obj, "description"): - return getattr(obj, 'description', None) - if hasattr(obj, "__desc__"): - attr = getattr(obj, "__desc__", None) - if attr: - return unwrap_str(attr) - return None - def is_code_same_as_print(value: Any) -> bool: return isinstance(value, bool) \ diff --git a/ghostos/core/session/__init__.py b/ghostos/core/session/__init__.py index 47807013..2cabbd8c 100644 --- a/ghostos/core/session/__init__.py +++ b/ghostos/core/session/__init__.py @@ -1,10 +1,10 @@ -from ghostos.core.session.session import Session +from ghostos.core.session.session import Session, Operator from ghostos.core.session.tasks import ( - Task, TaskPayload, TaskBrief, - TaskRepo, TaskState, WaitGroup, + GoTaskStruct, TaskPayload, TaskBrief, + GoTasks, TaskState, WaitGroup, ) -from ghostos.core.session.threads import MsgThreadRepo, MsgThread, thread_to_chat, Turn -from ghostos.core.session.processes import SessionProcess, GhostProcessRepo +from ghostos.core.session.threads import GoThreads, GoThreadInfo, thread_to_chat, Turn +from ghostos.core.session.processes import GoProcess, GoProcesses from ghostos.core.session.messenger import Messenger, Buffed -from ghostos.core.session.events import Event, EventBus, DefaultEventType +from ghostos.core.session.events import Event, EventBus, EventTypes from ghostos.core.session.simple_thread import SimpleMsgThread diff --git a/ghostos/core/session/events.py b/ghostos/core/session/events.py index fd769b44..0b3bfd2c 100644 --- a/ghostos/core/session/events.py +++ b/ghostos/core/session/events.py @@ -8,7 +8,7 @@ from contextlib import contextmanager __all__ = [ - 'Event', 'EventBus', 'DefaultEventType', + 'Event', 'EventBus', 'EventTypes', ] EVENT_ENTITY_TYPE = "ghostos.core.session.events.Event" @@ -22,22 +22,17 @@ class Event(BaseModel): Session.task() shall handle the Event, change the session state, and maybe fire more events. """ - task_id: str = Field( - description="task id of which this event shall send to.", + event_id: str = Field( + default_factory=uuid, + description="event id", ) type: str = Field( default="", description="event type, by default the handler shall named on_{type}" ) - name: Optional[str] = Field( - default=None, - description="sender name of this event messages", - ) - id: str = Field( - default_factory=uuid, - description="event id", + task_id: str = Field( + description="task id of which this event shall send to.", ) - from_task_id: Optional[str] = Field( default=None, description="task id in which this event is fired", @@ -112,43 +107,40 @@ def new( ) -class DefaultEventType(str, Enum): +class EventTypes(str, Enum): """ 默认的消息类型. """ + + # --- upstream events --- # + CREATED = "created" - """任务刚刚被创建出来""" - INPUT = "input" - """外部对当前 task 的输入. """ + REQUEST = "request" + + NOTIFY = "notify" - OBSERVE = "observe" - """自我驱动的思考""" + CANCEL = "cancel" - CANCELING = "cancelling" - """任务取消时, 触发的事件. 需要广播给子节点. """ - KILLING = "killing" + # --- self events --- # + + ROTATE = "rotate" + + # --- callback events --- # FINISH_CALLBACK = "finish_callback" - """child task 运行正常, 返回的消息. """ FAILURE_CALLBACK = "failure_callback" - """child task 运行失败, 返回的消息. """ - - NOTIFY_CALLBACK = "notify_callback" - """child task send some notice messages""" WAIT_CALLBACK = "wait_callback" - """Child task 返回消息, 期待更多的输入. """ + + # --- dead events --- # FINISHED = "finished" - """任务结束时, 触发的事件. 可用于反思.""" - FAILED = "failed" - """任务失败时, 触发的事件. 可用于反思.""" + CANCELED = "canceled" - def block(self) -> bool: - return self not in {} + FAILED = "failed" def new( self, @@ -192,9 +184,13 @@ class EventBus(ABC): """ global event bus. """ - # @abstractmethod - # def with_process_id(self, process_id: str) -> Self: - # pass + + @abstractmethod + def with_process_id(self, process_id: str) -> Self: + """ + process level eventbus, all event and notifications are private for the process + """ + pass @abstractmethod def send_event(self, e: Event, notify: bool) -> None: @@ -207,17 +203,15 @@ def send_event(self, e: Event, notify: bool) -> None: pass @abstractmethod - def pop_task_event(self, task_id: str) -> Optional[Event]: + def pop_task_event(self, task_id: str, block: bool = False, timeout: float = 0.0) -> Optional[Event]: """ pop a task event by task_id. the canceled event has higher priority to others. - :param task_id: certain task id. - :return: event or None if timeout is reached """ pass @abstractmethod - def pop_task_notification(self) -> Optional[str]: + def pop_task_notification(self, block: bool = False, timeout: float = 0.0) -> Optional[str]: """ pop a task notification from the main queue. :return: task id or None if not found. @@ -236,9 +230,9 @@ def notify_task(self, task_id: str) -> None: def clear_task(self, task_id: str) -> None: pass - # @abstractmethod - # def clear_all(self): - # pass + @abstractmethod + def clear_all(self): + pass @contextmanager def transaction(self): diff --git a/ghostos/core/session/messenger.py b/ghostos/core/session/messenger.py index c95f07c9..8e38e420 100644 --- a/ghostos/core/session/messenger.py +++ b/ghostos/core/session/messenger.py @@ -1,7 +1,7 @@ from typing import NamedTuple, List, Tuple from abc import ABC, abstractmethod from ghostos.core.messages.message import Message, Caller, Role -from ghostos.core.messages.stream import Stream +from ghostos.core.messages.transport import Stream __all__ = ['Messenger', 'Buffed'] diff --git a/ghostos/core/session/processes.py b/ghostos/core/session/processes.py index 92366aab..57d6e41f 100644 --- a/ghostos/core/session/processes.py +++ b/ghostos/core/session/processes.py @@ -6,12 +6,12 @@ from ghostos.helpers import uuid __all__ = [ - 'SessionProcess', - 'GhostProcessRepo', + 'GoProcess', + 'GoProcesses', ] -class SessionProcess(BaseModel): +class GoProcess(BaseModel): process_id: str = Field( description=""" Unique process id for the agent session. Session shall only have one process a time. @@ -48,10 +48,10 @@ def new( ghost_meta: EntityMeta, process_id: Optional[str] = None, main_task_id: Optional[str] = None, - ) -> "SessionProcess": + ) -> "GoProcess": process_id = process_id if process_id else uuid() main_task_id = process_id if main_task_id is None else main_task_id - return SessionProcess( + return GoProcess( session_id=session_id, process_id=process_id, main_task_id=main_task_id, @@ -59,13 +59,13 @@ def new( ) -class GhostProcessRepo(ABC): +class GoProcesses(ABC): """ repository to save or load process """ @abstractmethod - def get_process(self, process_id: str) -> Optional[SessionProcess]: + def get_process(self, process_id: str) -> Optional[GoProcess]: """ get process by id :param process_id: process id @@ -73,14 +73,14 @@ def get_process(self, process_id: str) -> Optional[SessionProcess]: pass @abstractmethod - def get_session_process(self, session_id: str) -> Optional[SessionProcess]: + def get_session_process(self, session_id: str) -> Optional[GoProcess]: """ get session process by session id """ pass @abstractmethod - def save_process(self, process: SessionProcess) -> None: + def save_process(self, process: GoProcess) -> None: """ save process :param process: diff --git a/ghostos/core/session/session.py b/ghostos/core/session/session.py index 2291b76d..8deb90ae 100644 --- a/ghostos/core/session/session.py +++ b/ghostos/core/session/session.py @@ -1,15 +1,27 @@ -from typing import Optional, Iterable, List, Callable +from typing import Optional, Iterable, List, Callable, Self, Union, Protocol, Dict, Any from abc import ABC, abstractmethod from ghostos.core.session.events import Event, EventBus from ghostos.core.session.messenger import Messenger -from ghostos.core.session.processes import GhostProcessRepo, SessionProcess -from ghostos.core.session.tasks import TaskRepo, Task, TaskBrief -from ghostos.core.session.threads import MsgThreadRepo, MsgThread -from ghostos.core.messages import MessageKind, Role, Buffer, Payload, Attachment, Message +from ghostos.core.session.processes import GoProcesses, GoProcess +from ghostos.core.session.tasks import GoTasks, GoTaskStruct, TaskBrief +from ghostos.core.session.threads import GoThreads, GoThreadInfo +from ghostos.core.messages import MessageKind, Role, Buffer, Payload, Message from ghostos.core.llms import FunctionalToken +from ghostos.container import Container +from pydantic import BaseModel -__all__ = ['Session'] +__all__ = ['Session', 'SessionProps', 'SessionStateValue'] + +SessionProps = Dict[str, Union[Dict, BaseModel]] + + +class Scope(BaseModel): + shell_id: str + process_id: str + task_id: str + thread_id: str + parent_task_id: Optional[str] = None class Session(ABC): @@ -19,15 +31,37 @@ class Session(ABC): Session 则提供了 Ghost 的 Task 运行时状态统一管理的 API. 通常每个运行中的 Task 都会创建一个独立的 Session. - Session 在运行周期里不会立刻调用底层 IO 存储消息, 而是要等 Finish 执行后. + Session 在运行周期里不会立刻调用底层 IO 存储消息, 而是要等一个周期正常结束. 这是为了减少运行时错误对状态机造成的副作用. """ - def id(self) -> str: - """ - session 自身的 id. - """ - return self.process().session_id + scope: Scope + """ + the running scope of the session + """ + + globals: Dict[str, Any] + """ + global values of the session. + inherit from parent task or shell props. + some key are override by parent task or current task. + """ + + properties: SessionProps + """ + Most important value of the session. + keep the runtime properties of the task. + key is unique to the task handler, value shall be dict or BaseModel. + value shall be serializable, otherwise the world may crash!! + + session 最重要的数据结构, 用来承载所有的运行时数据. + key 是数据运行时唯一的 key, 值只能是 dict. + 所有的值都应该是 Serializable 的, 否则会有世界毁灭之类的灾难爆发!! + """ + + @abstractmethod + def container(self) -> Container: + pass @abstractmethod def alive(self) -> bool: @@ -42,9 +76,9 @@ def alive(self) -> bool: pass @abstractmethod - def refresh_lock(self) -> bool: + def refresh(self) -> Self: """ - :return: + refresh the session, update overdue time and task lock. """ pass @@ -56,15 +90,7 @@ def refresh_lock(self) -> bool: # pass @abstractmethod - def process(self) -> "SessionProcess": - """ - 当前会话所处的进程数据. - 不允许直接修改. 只有指定的 API 会修改结果并保存. - """ - pass - - @abstractmethod - def task(self) -> "Task": + def task(self) -> "GoTaskStruct": """ 获取当前的任务对象. 描述了任务所有的状态. @@ -73,7 +99,7 @@ def task(self) -> "Task": pass @abstractmethod - def thread(self) -> "MsgThread": + def thread(self) -> "GoThreadInfo": """ Session 会持有当前任务的 Thread, 只有 finish 的时候才会真正地保存它. """ @@ -84,7 +110,7 @@ def messenger( self, *, sending: bool = True, saving: bool = True, - thread: Optional[MsgThread] = None, + thread: Optional[GoThreadInfo] = None, name: Optional[str] = None, buffer: Optional[Buffer] = None, payloads: Optional[Iterable[Payload]] = None, @@ -99,34 +125,32 @@ def messenger( pass @abstractmethod - def send_messages(self, *messages: MessageKind, role: str = Role.ASSISTANT.value) -> List[Message]: + def send_messages(self, *messages: MessageKind, remember: bool = True) -> List[Message]: """ 发送消息. :param messages: - :param role: + :param remember: remember the messages within the thread :return: """ pass @abstractmethod - def update_task(self, task: "Task", thread: Optional["MsgThread"], update_history: bool) -> None: + def update_task(self, task: "GoTaskStruct") -> None: """ 更新当前 session 的 task. :param task: 如果不属于当前 session, 则会报错 - :param thread: 由于 thread 和 task 是绑定的, 需要一起保存. update thread 的时候, thread 的 appending 等信息会更新. - :param update_history: 如果为 True, thread 会把 current round 添加到 history. """ pass @abstractmethod - def update_process(self, process: "SessionProcess") -> None: + def update_process(self, process: "GoProcess") -> None: """ 改动 process 并保存. 通常只在初始化里才需要. """ pass @abstractmethod - def update_thread(self, thread: "MsgThread", update_history: bool) -> None: + def update_thread(self, thread: "GoThreadInfo", update_history: bool) -> None: """ 单独更新当前 session 的 thread. :param thread: 如果不属于当前 session, 则会报错 @@ -135,7 +159,7 @@ def update_thread(self, thread: "MsgThread", update_history: bool) -> None: pass @abstractmethod - def create_tasks(self, *tasks: "Task") -> None: + def create_tasks(self, *tasks: "GoTaskStruct") -> None: """ 创建多个 task. 只有 session.done() 的时候才会执行. """ @@ -151,17 +175,6 @@ def fire_events(self, *events: "Event") -> None: """ pass - @abstractmethod - def future(self, name: str, call: Callable[[], Iterable[MessageKind]], reason: str) -> None: - """ - 异步运行一个函数, 将返回的消息作为 think 事件发送. - :param name: task name - :param call: - :param reason: - :return: - """ - pass - @abstractmethod def get_task_briefs(self, *task_ids, children: bool = False) -> List[TaskBrief]: """ @@ -171,22 +184,6 @@ def get_task_briefs(self, *task_ids, children: bool = False) -> List[TaskBrief]: """ pass - @abstractmethod - def tasks(self) -> TaskRepo: - pass - - @abstractmethod - def processes(self) -> GhostProcessRepo: - pass - - @abstractmethod - def threads(self) -> MsgThreadRepo: - pass - - @abstractmethod - def eventbus(self) -> EventBus: - pass - @abstractmethod def save(self) -> None: """ @@ -203,7 +200,7 @@ def save(self) -> None: pass @abstractmethod - def fail(self, err: Optional[Exception]) -> None: + def fail(self, err: Optional[Exception]) -> bool: """ 任务执行异常的处理. 需要判断任务是致命的, 还是可以恢复. :param err: @@ -221,3 +218,38 @@ def destroy(self) -> None: 手动清理数据, 方便垃圾回收. """ pass + + def __enter__(self) -> "Session": + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + intercept = None + if exc_val is not None: + intercept = self.fail(exc_val) + else: + self.done() + self.destroy() + return intercept + + +class SessionStateValue(Protocol): + """ + show a way to easy the session state controlling + """ + + @classmethod + @abstractmethod + def load(cls, session: Session) -> Union[Self, None]: + pass + + @abstractmethod + def bind(self, session: Session) -> None: + pass + + @abstractmethod + def get_or_bind(self, session: Session) -> Self: + val = self.load(session) + if val is None: + self.bind(session) + val = self + return val diff --git a/ghostos/core/session/simple_thread.py b/ghostos/core/session/simple_thread.py index fe67f296..3b20cd20 100644 --- a/ghostos/core/session/simple_thread.py +++ b/ghostos/core/session/simple_thread.py @@ -1,7 +1,7 @@ from typing import List, Optional, Dict, Any from pydantic import BaseModel, Field from ghostos.core.messages import Message -from ghostos.core.session.threads import MsgThread, Turn +from ghostos.core.session.threads import GoThreadInfo, Turn DESCRIPTION = """ Simple Thread is a simple mode for MsgThread, useful to show thread important information when debugging. @@ -53,7 +53,7 @@ class SimpleMsgThread(BaseModel): turns: List[SimpleTurn] = Field(default_factory=list) @classmethod - def from_thread(cls, thread: MsgThread) -> "SimpleMsgThread": + def from_thread(cls, thread: GoThreadInfo) -> "SimpleMsgThread": turns = [] idx = 0 for turn in thread.turns(): diff --git a/ghostos/core/session/tasks.py b/ghostos/core/session/tasks.py index 389cd7c0..5c97bf9f 100644 --- a/ghostos/core/session/tasks.py +++ b/ghostos/core/session/tasks.py @@ -1,18 +1,16 @@ import time -from typing import Optional, List, Set, Iterable, ClassVar, Dict +from typing import Optional, List, Set, Iterable, ClassVar, Dict, Self from abc import ABC, abstractmethod from enum import Enum from pydantic import BaseModel, Field -from ghostos.entity import EntityMeta -from ghostos.common import Identifier, Identifiable +from ghostos.common import Identifier, Identical, EntityMeta from ghostos.core.messages import Payload from contextlib import contextmanager __all__ = [ - 'Task', 'TaskPayload', 'TaskBrief', + 'GoTaskStruct', 'TaskPayload', 'TaskBrief', 'TaskState', - 'TaskRepo', - 'WaitGroup', + 'GoTasks', ] @@ -37,8 +35,6 @@ class TaskState(str, Enum): FAILED = "failed" """the task is failed due to an exception""" - KILLED = "killed" - FINISHED = "finished" """the task is finished""" @@ -47,17 +43,18 @@ def is_dead(cls, state: str) -> bool: return state in {cls.FINISHED, cls.FAILED, cls.CANCELLED, cls.KILLED} -class WaitGroup(BaseModel): - """ - await group of children tasks that will wake up the task. - """ - tasks: Dict[str, bool] = Field(description="children task ids to wait") - - def is_done(self) -> bool: - for _, ok in self.tasks.items(): - if not ok: - return False - return True +# +# class WaitGroup(BaseModel): +# """ +# await group of children tasks that will wake up the task. +# """ +# tasks: Dict[str, bool] = Field(description="children task ids to wait") +# +# def is_done(self) -> bool: +# for _, ok in self.tasks.items(): +# if not ok: +# return False +# return True class AssistantInfo(Identifier, BaseModel): @@ -67,10 +64,10 @@ class AssistantInfo(Identifier, BaseModel): meta_prompt: str = Field(description="meta prompt of the assistant") -class Task(BaseModel): +class GoTaskStruct(BaseModel): # -- scope --- # - session_id: str = Field( - description="session id that task belongs.", + shell_id: str = Field( + description="shell id that task belongs.", ) process_id: str = Field( description=""" @@ -94,26 +91,51 @@ class Task(BaseModel): Parent task id of the task. """, ) + depth: int = Field( + default=0, + description="the depth of the task", + ) + + # --- state values --- # + + pointer: EntityMeta = Field( + description="the entity meta of the task handler", + ) + + state: str = Field( + default=TaskState.NEW.value, + description=""" + the state of the current task. + """ + ) + + status_desc: str = Field( + default="", + description="The description of the current task status.", + ) + + globals: Dict = Field( + default_factory=dict, + description="the global values that inherit from the parent task or shell", + ) + + properties: Dict[str, Dict] = Field( + default_factory=dict, + description="the state data of the task handler" + ) # --- brief --- # name: str = Field( - description="The name of the task. " + description="The name of the task. not very important" ) - description: str = Field( - description="The description of the task" + purpose: str = Field( + description="The description of the task purpose" ) priority: float = Field( default=0.0, description="The priority of the task", ) - # --- assistant info --- # - - assistant: Optional[AssistantInfo] = Field( - default=None, - description="the assistant information, if given, took it as the message sender", - ) - # --- relations --- # children: List[str] = Field( @@ -123,30 +145,6 @@ class Task(BaseModel): """ ) - depending: List[WaitGroup] = Field( - default_factory=list, - description="the children task ids that wait them callback", - ) - - # --- thought --- # - meta: EntityMeta = Field( - description=""" -The meta data to restore the handler of this task. -""" - ) - - # --- state --- # - state: str = Field( - default=TaskState.NEW.value, - description=""" -the state of the current task. -""" - ) - # --- state ---# - logs: List[str] = Field( - default_factory=list, - description="log of the status change of the task", - ) # --- time related --- # created: float = Field( default_factory=lambda: round(time.time(), 4), @@ -156,39 +154,16 @@ class Task(BaseModel): default=0.0, description="The time the task was updated.", ) - overdue: float = Field( - default=0.0, - description="The time the task was overdue.", - ) - timeout: float = Field( - default=0.0, - description="timeout for each round of the task execution", - ) # --- system --- # lock: Optional[str] = Field( default=None, ) + turns: int = Field( default=0, description="the turn number of the task runs", ) - think_turns: int = Field( - default=0, - description="记录 task 已经自动运行过多少次. 如果是 0 的话, 则意味着它刚刚被创建出来. ", - ) - depth: int = Field( - default=0, - description="task depth that should be parent task depth +1 if parent exists", - ) - max_think_turns: int = Field( - default=20, - description="任务最大自动运行轮数, 为 0 的话表示无限. " - ) - max_children: int = Field( - default=20, - description="当前任务最大的子任务数, 超过这范围的子任务开始垃圾回收. " - ) @classmethod def new( @@ -200,9 +175,8 @@ def new( description: str, meta: EntityMeta, parent_task_id: Optional[str] = None, - assistant: Optional[Identifier] = None, - ) -> "Task": - return Task( + ) -> "GoTaskStruct": + return GoTaskStruct( task_id=task_id, session_id=session_id, process_id=process_id, @@ -211,7 +185,6 @@ def new( meta=meta, name=name, description=description, - assistant=assistant, ) def add_child( @@ -221,11 +194,11 @@ def add_child( description: str, meta: EntityMeta, assistant: Optional[Identifier] = None, - ) -> "Task": + ) -> "GoTaskStruct": self.children.append(task_id) return self.new( task_id=task_id, - session_id=self.session_id, + session_id=self.shell_id, process_id=self.process_id, name=name, description=description, @@ -234,15 +207,6 @@ def add_child( assistant=assistant, ) - def think_too_much(self) -> bool: - """ - 任务是否超过了自动思考的轮次. - """ - return 0 < self.max_think_turns <= self.think_turns - - def too_much_children(self) -> bool: - return 0 < self.max_children <= len(self.children) - def remove_child(self, child_task_id: str) -> bool: results = [] removed = False @@ -258,7 +222,7 @@ def identifier(self) -> Identifier: return Identifier( id=self.id, name=self.name, - description=self.description, + description=self.purpose, ) def is_dead(self) -> bool: @@ -267,46 +231,27 @@ def is_dead(self) -> bool: def is_new(self) -> bool: return TaskState.NEW.value == self.state - def depending_tasks(self) -> Set[str]: - result = set() - for group in self.depending: - for task in group.tasks: - result.add(task) - return result - - def depend_on_tasks(self, task_ids: List[str]) -> None: - group = WaitGroup( - tasks={task_id: False for task_id in task_ids}, - ) - self.depending.append(group) - - def on_callback_task(self, task_id: str) -> Optional[WaitGroup]: - """ - 得到一个 task id 的回调. 判断是否一组 wait group 被激活了. - :param task_id: - :return: 是否有 wait group 激活了. - """ - for group in self.depending: - if task_id in group.tasks: - group.tasks[task_id] = True - if group.is_done(): - return group - return None - - def update_turn(self) -> None: + def new_turn(self) -> Self: """ 保存一轮变更之前运行的方法. + todo """ - self.updated = round(time.time(), 4) - self.turns += 1 + return self.model_copy( + update={ + "updated": round(time.time(), 4), + "turns": self.turns + 1, + }, + deep=True, + ) -class TaskBrief(BaseModel, Identifiable): + +class TaskBrief(BaseModel, Identical): task_id: str = Field(description="the id of the task") name: str = Field(description="the name of the task") - description: str = Field(description="the description of the task") + purpose: str = Field(description="the purpose of the task") state: str = Field(description="the state of the task") - logs: List[str] = Field(description="the logs of the task") + status_desc: str = Field(description="the description of the task status") def is_overdue(self) -> bool: now = time.time() @@ -320,7 +265,7 @@ def identifier(self) -> Identifier: ) @classmethod - def from_task(cls, task: Task) -> "TaskBrief": + def from_task(cls, task: GoTaskStruct) -> "TaskBrief": return TaskBrief(**task.model_dump()) @@ -330,32 +275,49 @@ class TaskPayload(Payload): task_id: str = Field(description="the id of the task") task_name: str = Field(description="the name of the task") process_id: str = Field(description="the id of the process") + session_id: str = Field(description="the session id of the task") thread_id: str = Field(description="the id of the thread") @classmethod - def from_task(cls, task: Task) -> "TaskPayload": + def from_task(cls, task: GoTaskStruct) -> "TaskPayload": return cls( task_id=task.task_id, task_name=task.name, + session_id=task.shell_id, process_id=task.process_id, thread_id=task.thread_id, ) -class TaskRepo(ABC): +class TaskLocker(ABC): + + @abstractmethod + def acquire(self) -> bool: + pass + + @abstractmethod + def refresh(self) -> bool: + pass + + @abstractmethod + def release(self) -> bool: + pass + + +class GoTasks(ABC): """ 管理 task 存储的模块. 通常集成到 Session 里. """ @abstractmethod - def save_task(self, *tasks: Task) -> None: + def save_task(self, *tasks: GoTaskStruct) -> None: """ 保存一个或者多个 task. """ pass @abstractmethod - def get_task(self, task_id: str, lock: bool) -> Optional[Task]: + def get_task(self, task_id: str, lock: bool) -> Optional[GoTaskStruct]: """ 使用 task id 来获取一个 task. :param task_id: @@ -374,17 +336,16 @@ def exists(self, task_id: str) -> bool: pass @abstractmethod - def get_tasks(self, task_ids: List[str], states: Optional[List[TaskState]] = None) -> Iterable[Task]: + def get_tasks(self, task_ids: List[str]) -> Dict[str, GoTaskStruct]: """ 从数据库里读取出多个 task. 不会获取目标 task 的锁, 所以也无法更新. :param task_ids: - :param states: :return: """ pass @abstractmethod - def get_task_briefs(self, task_ids: List[str], states: Optional[List[TaskState]] = None) -> Iterable[TaskBrief]: + def get_task_briefs(self, task_ids: List[str]) -> Dict[str, TaskBrief]: """ 获取多个任务的摘要信息. :param task_ids: @@ -394,22 +355,10 @@ def get_task_briefs(self, task_ids: List[str], states: Optional[List[TaskState]] pass @abstractmethod - def unlock_task(self, task_id: str, lock: str) -> None: - """ - 对一个任务解锁. - :param task_id: - :param lock: - :return: - """ - pass - - @abstractmethod - def refresh_task_lock(self, task_id: str, lock: str) -> Optional[str]: + def lock_task(self, task_id: str) -> TaskLocker: """ - 更新一个任务的锁, 也会给它续期. :param task_id: - :param lock: - :return: + :return: None if not locked """ pass diff --git a/ghostos/core/session/threads.py b/ghostos/core/session/threads.py index 9d79d801..2fb3299b 100644 --- a/ghostos/core/session/threads.py +++ b/ghostos/core/session/threads.py @@ -2,15 +2,16 @@ import time from abc import ABC, abstractmethod from pydantic import BaseModel, Field +from ghostos.common import EntityMeta from ghostos.core.messages import Message, copy_messages, Role from ghostos.core.moss.pycontext import PyContext -from ghostos.core.llms import Chat -from ghostos.core.session.events import Event, DefaultEventType +from ghostos.core.llms import Prompt +from ghostos.core.session.events import Event, EventTypes from ghostos.helpers import uuid from contextlib import contextmanager __all__ = [ - 'MsgThreadRepo', 'MsgThread', 'Turn', + 'GoThreads', 'GoThreadInfo', 'Turn', 'thread_to_chat', ] @@ -27,11 +28,12 @@ class Turn(BaseModel): default=None, description="event of the turn" ) - generates: List[Message] = Field( + added: List[Message] = Field( default_factory=list, description="The new messages that generated by ghost during this turn of chat or thinking." "Shall append to messages after updating.", ) + # todo: remove pycontext: PyContext = Field( default_factory=PyContext, description="The PyContext instance", @@ -46,7 +48,7 @@ def new(cls, event: Optional[Event], *, turn_id: Optional[str] = None, pycontext: Optional[PyContext] = None) -> "Turn": data = {"event": event} if turn_id is None and event is not None: - turn_id = event.id + turn_id = event.event_id if turn_id: data["turn_id"] = turn_id if pycontext is not None: @@ -54,30 +56,25 @@ def new(cls, event: Optional[Event], *, turn_id: Optional[str] = None, return cls(**data) def append(self, *messages: Message, pycontext: Optional[PyContext] = None) -> None: - self.generates.extend(messages) + self.added.extend(messages) if pycontext is not None: self.pycontext = pycontext def event_messages(self) -> Iterable[Message]: if not self.event: return [] - event = self.event - name = event.name - for message in self.iter_event_message(event): - if message.name is None: - message.name = name - yield message + yield from self.iter_event_message(self.event) @staticmethod def iter_event_message(event: Event) -> Iterable[Message]: if event is None: return [] - if DefaultEventType.CREATED.value != event.type and event.from_task_name and not event.from_self(): + if EventTypes.CREATED.value != event.type and event.from_task_name and not event.from_self(): reason = "" if event.reason: reason = f" Reason: {event.reason}" - yield Role.new_assistant_system( + yield Role.new_system( content=f"receive event {event.type} from task `{event.from_task_name}`.{reason}") # messages in middle @@ -87,18 +84,18 @@ def iter_event_message(event: Event) -> Iterable[Message]: # instruction after messages. if event.instruction: - yield Role.new_assistant_system(content=event.instruction) + yield Role.new_system(content=event.instruction) def messages(self) -> Iterable[Message]: yield from self.event_messages() - if self.generates: - yield from self.generates + if self.added: + yield from self.added def is_empty(self) -> bool: - return (self.event is None or self.event.is_empty()) and not self.generates + return (self.event is None or self.event.is_empty()) and not self.added -class MsgThread(BaseModel): +class GoThreadInfo(BaseModel): """ 对话历史. 存储时应该使用别的数据结构. @@ -107,12 +104,12 @@ class MsgThread(BaseModel): default_factory=uuid, description="The id of the thread, also a fork id", ) - system_prompt: str = Field(default="", description="record system prompt, for debugging") - extra: Dict[str, Any] = Field(default_factory=dict, description="extra information") - save_file: Optional[str] = Field( - default=None, - description="the path to save the thread information, usually for debugging purposes", + + extra: Dict[str, Any] = Field( + default_factory=dict, + description="extra information", ) + root_id: Optional[str] = Field( default=None, description="The id of the root thread if the thread is a fork", @@ -134,6 +131,7 @@ class MsgThread(BaseModel): description="the current turn", ) + @classmethod def new( cls, @@ -143,7 +141,7 @@ def new( thread_id: Optional[str] = None, root_id: Optional[str] = None, parent_id: Optional[str] = None, - ) -> "MsgThread": + ) -> "GoThreadInfo": """ 初始化一个 Thread. :param event: 首轮输入的信息. @@ -163,7 +161,7 @@ def new( data["root_id"] = root_id if parent_id is not None: data["parent_id"] = parent_id - return MsgThread(**data) + return GoThreadInfo(**data) def last_turn(self) -> Turn: """ @@ -195,7 +193,7 @@ def update_pycontext(self, pycontext: PyContext) -> None: self.new_turn(None) self.current.pycontext = pycontext - def update_history(self) -> "MsgThread": + def get_updated_copy(self) -> "GoThreadInfo": """ 更新 thread 的 current turn 到 history turns. :return: @@ -239,7 +237,7 @@ def new_turn( last_turn = self.last_turn() pycontext = last_turn.pycontext if turn_id is None and event is not None: - turn_id = event.id + turn_id = event.event_id new_turn = Turn.new(event=event, turn_id=turn_id, pycontext=pycontext) self.current = new_turn @@ -255,28 +253,47 @@ def append(self, *messages: Message, pycontext: Optional[PyContext] = None) -> N if messages or pycontext: self.current.append(*messages, pycontext=pycontext) - def get_generates(self) -> List[Message]: + def get_added(self) -> List[Message]: if self.current is None: return [] - return self.current.generates + return self.current.added def get_current_event(self) -> Optional[Event]: if self.current is None: return None return self.current.event - def fork(self, tid: Optional[str] = None) -> "MsgThread": + def fork(self, tid: Optional[str] = None) -> "GoThreadInfo": tid = tid if tid else uuid() root_id = self.root_id if self.root_id else self.id parent_id = self.id thread = self.model_copy(update=dict(id=tid, root_id=root_id, parent_id=parent_id), deep=True) return thread - def thread_copy(self, update: Optional[dict] = None) -> "MsgThread": + def thread_copy(self, update: Optional[dict] = None) -> "GoThreadInfo": return self.model_copy(update=update, deep=True) - -def thread_to_chat(chat_id: str, system: List[Message], thread: MsgThread) -> Chat: + def to_prompt(self, system: List[Message]) -> Prompt: + turn_id = self.last_turn().turn_id + history = list(self.get_history_messages()) + inputs = [] + appending = [] + current_turn = self.current + if current_turn is not None: + inputs = list(current_turn.event_messages()) + appending = current_turn.added + + prompt = Prompt( + description=f"created from thread {self.id} turn {turn_id}", + system=system, + history=copy_messages(history), + inputs=copy_messages(inputs), + appending=copy_messages(appending), + ) + return prompt + + +def thread_to_chat(chat_id: str, system: List[Message], thread: GoThreadInfo) -> Prompt: """ 将 thread 转换成基准的 chat. :param chat_id: @@ -290,27 +307,25 @@ def thread_to_chat(chat_id: str, system: List[Message], thread: MsgThread) -> Ch current_turn = thread.current if current_turn is not None: inputs = list(current_turn.event_messages()) - appending = current_turn.generates + appending = current_turn.added - chat = Chat( + chat = Prompt( id=chat_id, system=system, history=copy_messages(history), inputs=copy_messages(inputs), appending=copy_messages(appending), ) - # update thread system prompt - thread.system_prompt = chat.system_prompt() return chat -class MsgThreadRepo(ABC): +class GoThreads(ABC): """ the repository to save and load threads """ @abstractmethod - def get_thread(self, thread_id: str, create: bool = False) -> Optional[MsgThread]: + def get_thread(self, thread_id: str, create: bool = False) -> Optional[GoThreadInfo]: """ 获取一个 Thread 实例. 如果不存在的话, 返回 None. :param thread_id: thread_id @@ -320,11 +335,11 @@ def get_thread(self, thread_id: str, create: bool = False) -> Optional[MsgThread pass @abstractmethod - def save_thread(self, thread: MsgThread) -> None: + def save_thread(self, thread: GoThreadInfo) -> None: pass @abstractmethod - def fork_thread(self, thread: MsgThread) -> MsgThread: + def fork_thread(self, thread: GoThreadInfo) -> GoThreadInfo: pass @contextmanager diff --git a/evaluation/swe_bench_lite/__init__.py b/ghostos/core/wall.py similarity index 100% rename from evaluation/swe_bench_lite/__init__.py rename to ghostos/core/wall.py diff --git a/ghostos/demo/aifuncs/agentic.py b/ghostos/demo/aifuncs/agentic.py index acc4db3a..2b23ab56 100644 --- a/ghostos/demo/aifuncs/agentic.py +++ b/ghostos/demo/aifuncs/agentic.py @@ -26,10 +26,10 @@ class Moss(Parent): """useful to run AIFunc""" -# +# def __aifunc_instruction__(fn: AgentFn) -> str: return fn.request -# +# diff --git a/ghostos/demo/aifuncs/news.py b/ghostos/demo/aifuncs/news.py index 4acc9743..d60e4ade 100644 --- a/ghostos/demo/aifuncs/news.py +++ b/ghostos/demo/aifuncs/news.py @@ -26,7 +26,7 @@ class News(BaseModel): results: List[News] = Field(default_factory=list) -# +# def __aifunc_instruction__(fn: NewsAIFunc) -> str: @@ -38,4 +38,4 @@ def __aifunc_instruction__(fn: NewsAIFunc) -> str: example = NewsAIFunc(query="我想知道黑神话悟空这款游戏的媒体评分。") -# +# diff --git a/ghostos/demo/aifuncs/weather.py b/ghostos/demo/aifuncs/weather.py index 62a3aca6..91a00dd0 100644 --- a/ghostos/demo/aifuncs/weather.py +++ b/ghostos/demo/aifuncs/weather.py @@ -26,7 +26,7 @@ class WeatherAIFuncResult(AIFuncResult): wind_dir: Optional[float] = Field(default=None, description="the wind direction of the weather") -# +# def __aifunc_instruction__(fn: WeatherAIFunc) -> str: return "Your task is using get_weather function to get weather information fit the input" @@ -35,4 +35,4 @@ def __aifunc_instruction__(fn: WeatherAIFunc) -> str: example = WeatherAIFunc() -# +# diff --git a/ghostos/demo/src/examples/thoughts/hello_world.py b/ghostos/demo/src/examples/thoughts/hello_world.py index 8014520e..654d142e 100644 --- a/ghostos/demo/src/examples/thoughts/hello_world.py +++ b/ghostos/demo/src/examples/thoughts/hello_world.py @@ -6,7 +6,7 @@ class Moss(Parent): replier: Replier -# the content between mark are not visible in the prompt for LLM +# the content between mark are not visible in the prompt for LLM # todo: can define a moss thought in a moss file @@ -27,4 +27,4 @@ class Moss(Parent): instruction="say hello world", ) -# +# diff --git a/ghostos/framework/actions/moss_action.py b/ghostos/framework/actions/moss_action.py index c6ec1f77..4b5388f1 100644 --- a/ghostos/framework/actions/moss_action.py +++ b/ghostos/framework/actions/moss_action.py @@ -4,8 +4,8 @@ from typing import Optional, ClassVar from ghostos.container import Container from ghostos.core.ghosts import Action, Ghost -from ghostos.core.llms import Chat, FunctionalToken, ChatPreparer -from ghostos.core.messages import DefaultMessageTypes, Caller +from ghostos.core.llms import Prompt, FunctionalToken, PromptPipe +from ghostos.core.messages import MessageType, Caller from ghostos.core.moss import MossRuntime, moss_message from ghostos.core.ghosts.operators import Operator from ghostos.core.session import Session @@ -116,7 +116,7 @@ def identifier(self) -> Identifier: description=self._functional_token.description, ) - def prepare_chat(self, chat: Chat) -> Chat: + def process(self, chat: Prompt) -> Prompt: # update functional tokens function_token = self._functional_token chat.functional_tokens.append(function_token) @@ -124,7 +124,7 @@ def prepare_chat(self, chat: Chat) -> Chat: # update code prompt as system message code_prompt = self._moss_runtime.prompter().dump_context_prompt() moss_instruction = self.template.format(code=code_prompt) - moss_prompt = DefaultMessageTypes.DEFAULT.new_system( + moss_prompt = MessageType.DEFAULT.new_system( content=moss_instruction, ) chat.system.append(moss_prompt) @@ -133,8 +133,8 @@ def prepare_chat(self, chat: Chat) -> Chat: for name, member in inspect.getmembers(moss_instance): if name.startswith("_"): continue - if isinstance(member, ChatPreparer): - member.prepare_chat(chat) + if isinstance(member, PromptPipe): + member.process(chat) return chat diff --git a/ghostos/framework/chatpreparers/__init__.py b/ghostos/framework/chatpreparers/__init__.py index 3e3e2620..35177407 100644 --- a/ghostos/framework/chatpreparers/__init__.py +++ b/ghostos/framework/chatpreparers/__init__.py @@ -1 +1 @@ -from ghostos.framework.chatpreparers.assistant_preparer import OtherAgentOrTaskPreparer +from ghostos.framework.chatpreparers.assistant_preparer import OtherAgentOrTaskPipe diff --git a/ghostos/framework/chatpreparers/assistant_preparer.py b/ghostos/framework/chatpreparers/assistant_preparer.py index a513b729..add6c6d0 100644 --- a/ghostos/framework/chatpreparers/assistant_preparer.py +++ b/ghostos/framework/chatpreparers/assistant_preparer.py @@ -1,10 +1,10 @@ from typing import Optional from ghostos.core.messages import Message, Role -from ghostos.core.llms import ChatPreparer, Chat +from ghostos.core.llms import PromptPipe, Prompt from ghostos.core.session import TaskPayload -class OtherAgentOrTaskPreparer(ChatPreparer): +class OtherAgentOrTaskPipe(PromptPipe): """ 调整 assistant name, 如果一条 assistant 消息的 name 与当前 name 相同则去掉. 这样就会认为是自己的消息. @@ -15,7 +15,7 @@ def __init__(self, *, assistant_name: str, task_id: str = "", with_task_name: bo self._task_id = task_id self._with_task_name = with_task_name - def prepare_chat(self, chat: Chat) -> Chat: + def process(self, chat: Prompt) -> Prompt: def filter_fn(message: Message) -> Optional[Message]: if message.role != Role.ASSISTANT.value: return message diff --git a/ghostos/framework/eventbuses/memimpl.py b/ghostos/framework/eventbuses/memimpl.py index a02c3271..193cd728 100644 --- a/ghostos/framework/eventbuses/memimpl.py +++ b/ghostos/framework/eventbuses/memimpl.py @@ -20,7 +20,7 @@ def send_event(self, e: Event, notify: bool) -> None: self.notify_task(e.task_id) def _send_task_event(self, e: Event) -> None: - event_id = e.id + event_id = e.event_id task_id = e.task_id self._events[event_id] = e if task_id not in self._task_queues: diff --git a/ghostos/framework/ghostos/basic.py b/ghostos/framework/ghostos/basic.py index b6a72de4..ef1aa145 100644 --- a/ghostos/framework/ghostos/basic.py +++ b/ghostos/framework/ghostos/basic.py @@ -7,7 +7,7 @@ from ghostos.core.ghostos import AbsGhostOS from ghostos.core.ghosts import Ghost from ghostos.core.messages import Stream -from ghostos.core.session import SessionProcess, Task +from ghostos.core.session import GoProcess, GoTaskStruct from ghostos.contracts.shutdown import ShutdownProvider from ghostos.contracts.modules import Modules, DefaultModulesProvider from ghostos.framework.storage import FileStorageProvider @@ -80,8 +80,8 @@ def _on_initialized(self): def make_ghost( self, *, upstream: Stream, - process: SessionProcess, - task: Optional[Task] = None, + process: GoProcess, + task: Optional[GoTaskStruct] = None, task_id: Optional[str] = None, ) -> Ghost: pass diff --git a/ghostos/framework/ghostos/demo_os.py b/ghostos/framework/ghostos/demo_os.py index 5d114bc7..995c198a 100644 --- a/ghostos/framework/ghostos/demo_os.py +++ b/ghostos/framework/ghostos/demo_os.py @@ -2,7 +2,7 @@ from ghostos.core.ghosts import Ghost, GhostConf, Workspace, Shell from ghostos.core.messages import Stream -from ghostos.core.session import SessionProcess, Task +from ghostos.core.session import GoProcess, GoTaskStruct from ghostos.contracts.logger import LoggerItf from ghostos.contracts.configs import Configs, YamlConfig @@ -28,8 +28,8 @@ def _on_initialized(self): def make_ghost( self, *, upstream: Stream, - process: SessionProcess, - task: Optional[Task] = None, + process: GoProcess, + task: Optional[GoTaskStruct] = None, task_id: Optional[str] = None, ) -> Ghost: conf = self._entity_factory.force_new_entity(process.ghost_meta, GhostConf) @@ -43,8 +43,8 @@ def _make_ghost_instance( self, conf: GhostConf, upstream: Stream, - process: SessionProcess, - task: Optional[Task] = None, + process: GoProcess, + task: Optional[GoTaskStruct] = None, task_id: Optional[str] = None, ) -> Ghost: if isinstance(conf, DemoGhostConf): diff --git a/ghostos/framework/ghosts/basic.py b/ghostos/framework/ghosts/basic.py index 97158f71..757affd7 100644 --- a/ghostos/framework/ghosts/basic.py +++ b/ghostos/framework/ghosts/basic.py @@ -15,9 +15,9 @@ from ghostos.core.moss import MossCompiler from ghostos.core.messages import Caller from ghostos.core.session import ( - Session, Event, DefaultEventType, - EventBus, TaskRepo, GhostProcessRepo, MsgThreadRepo, Messenger, - SessionProcess, Task, MsgThread, + Session, Event, EventTypes, + EventBus, GoTasks, GoProcesses, GoThreads, Messenger, + GoProcess, GoTaskStruct, GoThreadInfo, ) from ghostos.framework.operators import OnEventOperator from ghostos.framework.multitasks import MultiTaskBasicImpl @@ -61,9 +61,9 @@ class BasicGhost(Ghost, ABC): Storage, Configs, EventBus, - GhostProcessRepo, - TaskRepo, - MsgThreadRepo, + GoProcesses, + GoTasks, + GoThreads, Pool, LLMs, Shutdown, @@ -94,9 +94,9 @@ def __init__( workspace: Workspace, entity_factory: EntityFactory, upstream: Stream, - process: SessionProcess, + process: GoProcess, max_operator_runs: int, - task: Optional[Task] = None, + task: Optional[GoTaskStruct] = None, task_id: Optional[str] = None, ): # init ghost container, validate it first @@ -165,10 +165,10 @@ def _bootstrap_ghost_container(self): # register session drivers: session_function_providers = { - TaskRepo: self._session.tasks, - GhostProcessRepo: self._session.processes, + GoTasks: self._session.tasks, + GoProcesses: self._session.processes, Messenger: self._session.messenger, - MsgThreadRepo: self._session.threads, + GoThreads: self._session.threads, EventBus: self._session.eventbus, } for contract, maker in session_function_providers.items(): @@ -194,16 +194,16 @@ def make_session( self, logger: LoggerItf, upstream: Stream, - process: SessionProcess, + process: GoProcess, root_thought: Thought, - task: Optional[Task] = None, + task: Optional[GoTaskStruct] = None, task_id: Optional[str] = None, ) -> Session: container = self.container() identifier = self.conf().identifier() - processes = container.force_fetch(GhostProcessRepo) - tasks = container.force_fetch(TaskRepo) - threads = container.force_fetch(MsgThreadRepo) + processes = container.force_fetch(GoProcesses) + tasks = container.force_fetch(GoTasks) + threads = container.force_fetch(GoThreads) pool = container.force_fetch(Pool) eventbus = container.force_fetch(EventBus) # task and thread init. @@ -218,7 +218,7 @@ def make_session( if not task: identifier = root_thought.identifier() meta = root_thought.to_entity_meta() - task = Task.new( + task = GoTaskStruct.new( task_id=task_id, session_id=process.session_id, process_id=process.process_id, @@ -236,7 +236,7 @@ def make_session( }) thread = threads.get_thread(task.thread_id) if thread is None: - thread = MsgThread.new(None, thread_id=task.thread_id) + thread = GoThreadInfo.new(None, thread_id=task.thread_id) return BasicSession( ghost_name=identifier.name, ghost_role=self.role(), @@ -290,12 +290,12 @@ def on_inputs(self, inputs: Inputs) -> Optional["Event"]: return None task = self.session().task() if task.is_new(): - event = DefaultEventType.CREATED.new( + event = EventTypes.CREATED.new( task_id=self.session().task().task_id, messages=inputs.messages, ) else: - event = DefaultEventType.INPUT.new( + event = EventTypes.REQUEST.new( task_id=self.session().task().task_id, messages=inputs.messages, ) diff --git a/ghostos/framework/ghosts/demo.py b/ghostos/framework/ghosts/demo.py index b6bc57ee..edd9da38 100644 --- a/ghostos/framework/ghosts/demo.py +++ b/ghostos/framework/ghosts/demo.py @@ -1,7 +1,7 @@ from typing import Optional, List from ghostos.common import Identifier from ghostos.core.ghosts import GhostConf, Shell, Workspace -from ghostos.core.session import SessionProcess, Task +from ghostos.core.session import GoProcess, GoTaskStruct from ghostos.contracts.modules import Modules from ghostos.core.messages import Stream from ghostos.framework.ghosts.basic import BasicGhost, InputsPipe @@ -59,10 +59,10 @@ def __init__( container: Container, entity_factory: EntityFactory, workspace: Workspace, - process: SessionProcess, + process: GoProcess, upstream: Optional[Stream] = None, shell: Optional[Shell] = None, - task: Optional[Task] = None, + task: Optional[GoTaskStruct] = None, task_id: Optional[str] = None, ): self._conf = conf diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index b28d27f1..16fd0d9a 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -10,12 +10,12 @@ from openai.types.chat.chat_completion_chunk import ChatCompletionChunk from ghostos.core.messages import ( - Message, OpenAIMessageParser, DefaultOpenAIMessageParser, DefaultMessageTypes, + Message, OpenAIMessageParser, DefaultOpenAIMessageParser, MessageType, CompletionUsagePayload, ) from ghostos.core.llms import ( LLMs, LLMDriver, LLMApi, ModelConf, ServiceConf, OPENAI_DRIVER_NAME, - Chat, + Prompt, FunctionalToken, ) from ghostos.container import Bootstrapper, Container @@ -123,7 +123,7 @@ def text_completion(self, prompt: str) -> str: # # todo: log # raise GhostOSIOError("failed to get text embedding", e) - def _chat_completion(self, chat: Chat, stream: bool) -> Union[ChatCompletion, Iterable[ChatCompletionChunk]]: + def _chat_completion(self, chat: Prompt, stream: bool) -> Union[ChatCompletion, Iterable[ChatCompletionChunk]]: # todo: try catch chat = self.parse_chat(chat) include_usage = ChatCompletionStreamOptionsParam(include_usage=True) if stream else NOT_GIVEN @@ -146,7 +146,7 @@ def _chat_completion(self, chat: Chat, stream: bool) -> Union[ChatCompletion, It **self._model.kwargs, ) - def chat_completion(self, chat: Chat) -> Message: + def chat_completion(self, chat: Prompt) -> Message: message: ChatCompletion = self._chat_completion(chat, stream=False) pack = self._parser.from_chat_completion(message.choices[0].message) # add completion usage @@ -159,7 +159,7 @@ def chat_completion(self, chat: Chat) -> Message: pack.chunk = False return pack - def chat_completion_chunks(self, chat: Chat) -> Iterable[Message]: + def chat_completion_chunks(self, chat: Prompt) -> Iterable[Message]: chunks: Iterable[ChatCompletionChunk] = self._chat_completion(chat, stream=True) messages = self._parser.from_chat_completion_chunks(chunks) first = True @@ -169,12 +169,12 @@ def chat_completion_chunks(self, chat: Chat) -> Iterable[Message]: first = False yield chunk - def parse_chat(self, chat: Chat) -> Chat: + def parse_chat(self, chat: Prompt) -> Prompt: if not chat.functional_tokens: return chat prompt = FunctionalTokenPrompt(self._functional_token_prompt) content = prompt.format_tokens(chat.functional_tokens) - message = DefaultMessageTypes.DEFAULT.new_system(content=content) + message = MessageType.DEFAULT.new_system(content=content) chat.system.append(message) return chat @@ -205,7 +205,7 @@ class LitellmAdapter(OpenAIAdapter): adapter class wrap openai api to ghostos.blueprint.kernel.llms.LLMApi """ - def _chat_completion(self, chat: Chat, stream: bool) -> ChatCompletion: + def _chat_completion(self, chat: Prompt, stream: bool) -> ChatCompletion: messages = chat.get_messages() messages = self._parser.parse_message_list(messages) response = litellm.completion( diff --git a/ghostos/framework/llms/test_case.py b/ghostos/framework/llms/test_case.py index a9115c8d..9cbb0830 100644 --- a/ghostos/framework/llms/test_case.py +++ b/ghostos/framework/llms/test_case.py @@ -3,8 +3,8 @@ import datetime from typing import List, Optional, Dict from pydantic import BaseModel, Field -from ghostos.core.llms import LLMs, Chat, ModelConf, ServiceConf -from ghostos.core.messages import Message, DefaultMessageTypes +from ghostos.core.llms import LLMs, Prompt, ModelConf, ServiceConf +from ghostos.core.messages import Message, MessageType # 测试用, 不直接对外开放. @@ -21,7 +21,7 @@ class ChatCompletionTestResult(BaseModel): class ChatCompletionTestCase(BaseModel): - chat: Chat + chat: Prompt apis: List[APIInfo] results: List[ChatCompletionTestResult] = Field(default_factory=list) @@ -39,7 +39,7 @@ def run_test_cases(cases: ChatCompletionTestCase, llms: LLMs) -> Dict[str, Messa return result -def run_test_case(api_info: APIInfo, chat: Chat, llms: LLMs, result: Dict[str, Message]) -> None: +def run_test_case(api_info: APIInfo, chat: Prompt, llms: LLMs, result: Dict[str, Message]) -> None: api = None if api_info.api: api = llms.get_api(api_info.api) @@ -58,7 +58,7 @@ def run_test_case(api_info: APIInfo, chat: Chat, llms: LLMs, result: Dict[str, M try: message = api.chat_completion(chat) except Exception as e: - message = DefaultMessageTypes.ERROR.new(content=str(e)) + message = MessageType.ERROR.new(content=str(e)) finally: end = time.time() duration = end - start diff --git a/ghostos/framework/messages/buffers.py b/ghostos/framework/messages/buffers.py index fd7162b4..662c4d4b 100644 --- a/ghostos/framework/messages/buffers.py +++ b/ghostos/framework/messages/buffers.py @@ -1,7 +1,7 @@ import time from typing import Iterable, Optional, List, Dict, Set -from ghostos.core.messages import Message, Caller, DefaultMessageTypes, Role, Payload, Attachment, Buffer, Flushed +from ghostos.core.messages import Message, Caller, MessageType, Role, Payload, Attachment, Buffer, Flushed from ghostos.core.llms import FunctionalToken from ghostos.helpers import uuid @@ -104,7 +104,7 @@ def _buff(self, pack: "Message") -> Iterable[Message]: return [] # 不深拷贝的话, 加工逻辑就会交叉污染? # pack = origin.model_copy(deep=True) - if DefaultMessageTypes.is_protocol_message(pack): + if MessageType.is_protocol_message(pack): # final 包不进行 buffer. yield pack return diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index 16e38496..b890acd9 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -2,10 +2,10 @@ from ghostos.container import Container, Provider from ghostos.core.session.messenger import Messenger, Buffed from ghostos.core.messages import ( - Message, Payload, Attachment, Role, DefaultMessageTypes, + Message, Payload, Attachment, Role, MessageType, Buffer, Stream, ) -from ghostos.core.session.threads import MsgThread +from ghostos.core.session.threads import GoThreadInfo from ghostos.core.llms import FunctionalToken from ghostos.framework.messages.buffers import DefaultBuffer from ghostos.helpers import uuid @@ -28,7 +28,7 @@ def __init__( self, *, depth: int = 0, upstream: Optional[Stream] = None, - thread: Optional["MsgThread"] = None, + thread: Optional["GoThreadInfo"] = None, name: Optional[str] = None, role: Optional[str] = None, buffer: Optional[Buffer] = None, @@ -51,7 +51,7 @@ def __init__( :param logger: """ self._depth = depth - self._thread: Optional[MsgThread] = thread + self._thread: Optional[GoThreadInfo] = thread # self._streaming_id: str = uuid() self._name = name self._logger = logger @@ -112,14 +112,14 @@ def deliver(self, pack: "Message") -> bool: if self.stopped(): return False - elif DefaultMessageTypes.is_final(pack): + elif MessageType.is_final(pack): # 下游发送的 final 包, 上游会装作已经发送成功. return True with self._locker: # 下游返回 error, 会导致全链路的 messenger 因为 error 而停止. # 所以 error 类型的消息, 链路里只能有一个. - if DefaultMessageTypes.ERROR.match(pack): + if MessageType.ERROR.match(pack): # receive error pack will stop the current streaming. self._stop(pack) return True @@ -183,7 +183,7 @@ def _deliver_to_upstream(self, delivery: Iterable[Message]) -> bool: if self._stopped: return False for item in delivery: - if not DefaultMessageTypes.is_protocol_message(item) and item.chunk and not self._accept_chunks: + if not MessageType.is_protocol_message(item) and item.chunk and not self._accept_chunks: continue # 如果发送不成功, 直接中断. # if self._depth == 0: @@ -223,8 +223,8 @@ def _stop(self, final: Optional[Message]) -> None: self._stopped = True if self._destroyed: return - if final is None or not DefaultMessageTypes.is_protocol_message(final): - final = DefaultMessageTypes.final() + if final is None or not MessageType.is_protocol_message(final): + final = MessageType.final() self._deliver_to_upstream([final]) self.destroy() @@ -242,7 +242,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): return self.flush() if exc_val: - self._stop(DefaultMessageTypes.ERROR.new(content=str(exc_val))) + self._stop(MessageType.ERROR.new(content=str(exc_val))) self.destroy() def destroy(self) -> None: diff --git a/ghostos/framework/multitasks/basic.py b/ghostos/framework/multitasks/basic.py index edf5142b..a85dc82e 100644 --- a/ghostos/framework/multitasks/basic.py +++ b/ghostos/framework/multitasks/basic.py @@ -1,8 +1,8 @@ from typing import Tuple from ghostos.core.ghosts import MultiTask, Operator, Ghost, Thought, NewTask -from ghostos.core.llms import Chat +from ghostos.core.llms import Prompt from ghostos.core.messages import MessageKind, Role -from ghostos.core.session.events import DefaultEventType +from ghostos.core.session.events import EventTypes from ghostos.framework.operators import WaitOnTasksOperator from ghostos.helpers import yaml_pretty_dump @@ -12,7 +12,7 @@ class MultiTaskBasicImpl(MultiTask): def __init__(self, ghost: Ghost): self._ghost = ghost - def prepare_chat(self, chat: Chat) -> Chat: + def process(self, chat: Prompt) -> Prompt: children = self._ghost.session().get_task_briefs(children=True) if not children: return chat @@ -67,7 +67,7 @@ def send_task(self, task_name: str, *messages: MessageKind) -> None: tasks = session.get_task_briefs(children=True) for task in tasks: if task.name == task_name: - event = DefaultEventType.INPUT.new( + event = EventTypes.REQUEST.new( task_id=task.id, from_task_id=from_task_id, messages=messages, @@ -80,7 +80,7 @@ def cancel_task(self, task_name: str, reason: str) -> None: tasks = session.get_task_briefs(children=True) for task in tasks: if task.name == task_name: - event = DefaultEventType.CANCELING.new( + event = EventTypes.CANCEL.new( task_id=task.id, from_task_id=from_task_id, messages=[], diff --git a/ghostos/framework/operators/action_ops.py b/ghostos/framework/operators/action_ops.py index 53a3fa2e..de1df766 100644 --- a/ghostos/framework/operators/action_ops.py +++ b/ghostos/framework/operators/action_ops.py @@ -7,9 +7,9 @@ MessageKind, MessageKindParser, Role, ) from ghostos.core.session import ( - DefaultEventType, + EventTypes, TaskState, - Task, + GoTaskStruct, ) __all__ = [ @@ -30,7 +30,7 @@ class ActionOperator(Operator): 3. 如果父任务存在, 向父任务发送消息. """ task_state: ClassVar[str] = TaskState.WAITING.value - callback_event_type: ClassVar[str] = DefaultEventType.WAIT_CALLBACK.value + callback_event_type: ClassVar[str] = EventTypes.WAIT_CALLBACK.value def __init__( self, *, @@ -49,7 +49,7 @@ def send_replies(self, g: "Ghost") -> None: session = g.session() self.messages = session.send_messages(*self.messages) - def get_callback_task_id(self, task: Task) -> Optional[str]: + def get_callback_task_id(self, task: GoTaskStruct) -> Optional[str]: if self.callback_task_id is not None: return self.callback_task_id return task.parent @@ -98,7 +98,7 @@ def destroy(self) -> None: class WaitsOperator(ActionOperator): task_state: ClassVar[str] = TaskState.WAITING.value - callback_event_type: ClassVar[str] = DefaultEventType.WAIT_CALLBACK.value + callback_event_type: ClassVar[str] = EventTypes.WAIT_CALLBACK.value def __init__(self, *, reason: str, messages: List[MessageKind], callback_task_id: Optional[str] = None): super().__init__(reason=reason, messages=messages, callback_task_id=callback_task_id) @@ -114,7 +114,7 @@ class FailOperator(ActionOperator): 5. 自己继续执行 on_finished 事件, 可以创建独立的任务去理解. """ task_state: ClassVar[str] = TaskState.FAILED.value - callback_event_type: ClassVar[str] = DefaultEventType.FAILURE_CALLBACK.value + callback_event_type: ClassVar[str] = EventTypes.FAILURE_CALLBACK.value def __init__( self, *, @@ -139,7 +139,7 @@ def next_operator(self, g: "Ghost") -> Optional[Operator]: session = g.session() task = session.task() # finish 没有后续. 但还是要执行一个反思事件. - event = DefaultEventType.FAILED.new( + event = EventTypes.FAILED.new( task_id=task.task_id, messages=[], ) @@ -157,7 +157,7 @@ class FinishOperator(ActionOperator): 5. 自己继续执行 on_finished 事件, 可以创建独立的任务去理解. """ task_state: ClassVar[str] = TaskState.FINISHED.value - callback_event_type: ClassVar[str] = DefaultEventType.FINISH_CALLBACK.value + callback_event_type: ClassVar[str] = EventTypes.FINISH_CALLBACK.value def __init__( self, *, @@ -183,7 +183,7 @@ def next_operator(self, g: "Ghost") -> Optional[Operator]: # finish 没有后续. 但还是要执行一个反思事件. session = g.session() task = session.task() - event = DefaultEventType.FINISHED.new( + event = EventTypes.FINISHED.new( task_id=task.task_id, messages=[], ) @@ -197,7 +197,7 @@ class WaitOnTasksOperator(ActionOperator): wait on children tasks """ task_state: ClassVar[str] = TaskState.RUNNING.value - callback_event_type: ClassVar[str] = DefaultEventType.NOTIFY_CALLBACK.value + callback_event_type: ClassVar[str] = EventTypes.NOTIFY.value def __init__( self, *, @@ -229,7 +229,7 @@ class ThinkOperator(ActionOperator): 运行下一轮思考. """ task_state: ClassVar[str] = TaskState.RUNNING.value - callback_event_type: ClassVar[str] = DefaultEventType.NOTIFY_CALLBACK.value + callback_event_type: ClassVar[str] = EventTypes.NOTIFY.value def __init__( self, *, @@ -256,7 +256,7 @@ def next_operator(self, g: "Ghost") -> Optional["Operator"]: utils = g.utils() utils.send_task_event( task_id=task.task_id, - event_type=DefaultEventType.OBSERVE.value, + event_type=EventTypes.ROTATE.value, reason=self.reason, instruction=self.instruction, messages=observations, diff --git a/ghostos/framework/operators/event_ops.py b/ghostos/framework/operators/event_ops.py index 24109209..092fd18b 100644 --- a/ghostos/framework/operators/event_ops.py +++ b/ghostos/framework/operators/event_ops.py @@ -5,7 +5,7 @@ ) from ghostos.core.session import ( TaskState, - DefaultEventType, + EventTypes, ) __all__ = [ @@ -84,7 +84,7 @@ def handle_event(self, g: "Ghost") -> Optional["Operator"]: session = g.session() task = session.task() # 思考轮次设置为 0. - task.think_turns = 0 + # task.think_turns = 0 thread = session.thread() thread.new_turn(self.event) session.update_task(task, thread, update_history=False) @@ -116,7 +116,7 @@ def handle_event(self, g: "Ghost") -> Optional["Operator"]: session = g.session() task = session.task() # 思考轮次设置为 0. - task.think_turns += 1 + # task.think_turns += 1 thread = session.thread() thread.new_turn(self.event) if task.think_too_much(): @@ -180,7 +180,7 @@ class OnInputOperator(OnUpstreamEventOperator): """ 接受到上游的输入. """ - event_type: ClassVar[str] = DefaultEventType.INPUT.value + event_type: ClassVar[str] = EventTypes.REQUEST.value default_state: ClassVar[str] = TaskState.WAITING.value @@ -188,12 +188,12 @@ class OnCreatedOperator(OnUpstreamEventOperator): """ 接受到创建任务的消息. """ - event_type: ClassVar[str] = DefaultEventType.CREATED.value + event_type: ClassVar[str] = EventTypes.CREATED.value default_state: ClassVar[str] = TaskState.WAITING.value class OnCancelingOperator(OnUpstreamEventOperator): - event_type = DefaultEventType.CANCELING.value + event_type = EventTypes.CANCEL.value default_state: ClassVar[str] = TaskState.CANCELLED.value def handle_event(self, g: "Ghost") -> Optional["Operator"]: @@ -219,14 +219,14 @@ def default_action(self, g: "Ghost") -> Optional["Operator"]: # --- self event operators --- # class OnObserveOperator(OnSelfEventOperator): - event_type: ClassVar[str] = DefaultEventType.OBSERVE.value + event_type: ClassVar[str] = EventTypes.ROTATE.value default_state: ClassVar[str] = TaskState.WAITING.value # --- call back operators --- # class OnFinishCallbackOperator(OnCallbackEventOperator): - event_type: ClassVar[str] = DefaultEventType.FINISH_CALLBACK + event_type: ClassVar[str] = EventTypes.FINISH_CALLBACK def handle_event(self, g: "Ghost") -> Optional["Operator"]: session = g.session() @@ -242,12 +242,12 @@ def handle_event(self, g: "Ghost") -> Optional["Operator"]: class OnFailureCallbackOperator(OnCallbackEventOperator): - event_type: ClassVar[str] = DefaultEventType.FAILURE_CALLBACK + event_type: ClassVar[str] = EventTypes.FAILURE_CALLBACK class OnWaitCallbackOperator(OnCallbackEventOperator): - event_type: ClassVar[str] = DefaultEventType.WAIT_CALLBACK + event_type: ClassVar[str] = EventTypes.WAIT_CALLBACK class OnNotifyCallbackOperator(OnCallbackEventOperator): - event_type: ClassVar[str] = DefaultEventType.NOTIFY_CALLBACK + event_type: ClassVar[str] = EventTypes.NOTIFY diff --git a/ghostos/framework/processes/__init__.py b/ghostos/framework/processes/__init__.py index b128b65d..9dcbdad2 100644 --- a/ghostos/framework/processes/__init__.py +++ b/ghostos/framework/processes/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.session import GhostProcessRepo +from ghostos.core.session import GoProcesses from ghostos.framework.processes.storage_processes import StorageProcessImplProvider, WorkspaceProcessesProvider diff --git a/ghostos/framework/processes/storage_processes.py b/ghostos/framework/processes/storage_processes.py index c740c771..da667df5 100644 --- a/ghostos/framework/processes/storage_processes.py +++ b/ghostos/framework/processes/storage_processes.py @@ -1,17 +1,17 @@ from typing import Optional, Dict, Type import yaml -from ghostos.core.session import SessionProcess -from ghostos.core.session.processes import GhostProcessRepo +from ghostos.core.session import GoProcess +from ghostos.core.session.processes import GoProcesses from ghostos.contracts.storage import Storage from ghostos.contracts.logger import LoggerItf from ghostos.contracts.workspace import Workspace from threading import Lock from ghostos.container import Provider, Container -__all__ = ['StorageGhostProcessRepoImpl', 'StorageProcessImplProvider', 'WorkspaceProcessesProvider'] +__all__ = ['StorageGoProcessesImpl', 'StorageProcessImplProvider', 'WorkspaceProcessesProvider'] -class StorageGhostProcessRepoImpl(GhostProcessRepo): +class StorageGoProcessesImpl(GoProcesses): session_map_name = "sessions.yml" def __init__(self, storage: Storage, logger: LoggerItf): @@ -31,12 +31,12 @@ def _get_session_process_map(self) -> Dict[str, str]: def _get_process_filename(process_id: str) -> str: return f"{process_id}.process.yml" - def get_process(self, process_id: str) -> Optional[SessionProcess]: + def get_process(self, process_id: str) -> Optional[GoProcess]: filename = self._get_process_filename(process_id) if self._storage.exists(filename): content = self._storage.get(filename) data = yaml.safe_load(content) - process = SessionProcess(**data) + process = GoProcess(**data) return process return None @@ -45,14 +45,14 @@ def _save_session_process_map(self, session_map: Dict[str, str]) -> None: filename = self.session_map_name self._storage.put(filename, content.encode("utf-8")) - def get_session_process(self, session_id: str) -> Optional[SessionProcess]: + def get_session_process(self, session_id: str) -> Optional[GoProcess]: m = self._get_session_process_map() process_id = m.get(session_id, None) if process_id is None: return None return self.get_process(process_id) - def save_process(self, process: SessionProcess) -> None: + def save_process(self, process: GoProcess) -> None: session_id = process.session_id process_id = process.process_id with self._lock: @@ -64,35 +64,35 @@ def save_process(self, process: SessionProcess) -> None: self._storage.put(filename, content.encode("utf-8")) -class StorageProcessImplProvider(Provider[GhostProcessRepo]): +class StorageProcessImplProvider(Provider[GoProcesses]): def __init__(self, process_dir: str = "runtime/processes"): self.process_dir = process_dir def singleton(self) -> bool: return True - def contract(self) -> Type[GhostProcessRepo]: - return GhostProcessRepo + def contract(self) -> Type[GoProcesses]: + return GoProcesses - def factory(self, con: Container) -> Optional[GhostProcessRepo]: + def factory(self, con: Container) -> Optional[GoProcesses]: storage = con.force_fetch(Storage) logger = con.force_fetch(LoggerItf) processes_storage = storage.sub_storage(self.process_dir) - return StorageGhostProcessRepoImpl(processes_storage, logger) + return StorageGoProcessesImpl(processes_storage, logger) -class WorkspaceProcessesProvider(Provider[GhostProcessRepo]): +class WorkspaceProcessesProvider(Provider[GoProcesses]): def __init__(self, process_dir: str = "processes"): self.process_dir = process_dir def singleton(self) -> bool: return True - def contract(self) -> Type[GhostProcessRepo]: - return GhostProcessRepo + def contract(self) -> Type[GoProcesses]: + return GoProcesses - def factory(self, con: Container) -> Optional[GhostProcessRepo]: + def factory(self, con: Container) -> Optional[GoProcesses]: workspace = con.force_fetch(Workspace) logger = con.force_fetch(LoggerItf) processes_storage = workspace.runtime().sub_storage(self.process_dir) - return StorageGhostProcessRepoImpl(processes_storage, logger) + return StorageGoProcessesImpl(processes_storage, logger) diff --git a/evaluation/swe_bench_lite/ai_funcs/__init__.py b/ghostos/framework/prompts/__init__.py similarity index 100% rename from evaluation/swe_bench_lite/ai_funcs/__init__.py rename to ghostos/framework/prompts/__init__.py diff --git a/ghostos/framework/prompts/storage_impl.py b/ghostos/framework/prompts/storage_impl.py new file mode 100644 index 00000000..2cbd6bd9 --- /dev/null +++ b/ghostos/framework/prompts/storage_impl.py @@ -0,0 +1,45 @@ +from typing import Optional + +from ghostos.contracts.storage import Storage +from ghostos.contracts.workspace import Workspace +from ghostos.core.llms import Prompt +from ghostos.core.llms.prompt import PromptStorage +from ghostos.container import Provider, Container, INSTANCE +import json + + +class PromptStorageImpl(PromptStorage): + + def __init__(self, storage: Storage): + self._storage = storage + + @staticmethod + def _get_filename(prompt_id: str) -> str: + filename = f"{prompt_id}.prompt.json" + return filename + + def save(self, prompt: Prompt) -> None: + data = prompt.model_dump_json(indent=2) + filename = self._get_filename(prompt.id) + self._storage.put(filename, data.encode()) + + def get(self, prompt_id: str) -> Optional[Prompt]: + filename = self._get_filename(prompt_id) + if self._storage.exists(filename): + content = self._storage.get(filename) + data = json.loads(content) + return Prompt(**data) + return None + + +class PromptStorageProvider(Provider[PromptStorage]): + def __init__(self, relative_path: str = "prompts"): + self._relative_path = relative_path + + def singleton(self) -> bool: + return True + + def factory(self, con: Container) -> Optional[INSTANCE]: + ws = con.force_fetch(Workspace) + storage = ws.runtime().sub_storage(self._relative_path) + return PromptStorageImpl(storage) diff --git a/ghostos/framework/repliers/basic.py b/ghostos/framework/repliers/basic.py index 2693aaeb..09d34f71 100644 --- a/ghostos/framework/repliers/basic.py +++ b/ghostos/framework/repliers/basic.py @@ -3,14 +3,14 @@ from ghostos.core.ghosts import Operator from ghostos.core.ghosts.schedulers import Replier from ghostos.core.messages import Role -from ghostos.core.session import Task +from ghostos.core.session import GoTaskStruct from ghostos.framework.operators import WaitsOperator, ThinkOperator, FinishOperator from ghostos.helpers import yaml_pretty_dump class ReplierImpl(Replier): - def __init__(self, task: Task, event_from_task: Optional[str] = None): + def __init__(self, task: GoTaskStruct, event_from_task: Optional[str] = None): callback_task_id = task.parent if event_from_task and event_from_task != task.task_id: callback_task_id = event_from_task @@ -59,7 +59,7 @@ def think(self, observations: Optional[Dict[str, Any]] = None, instruction: str # 用什么协议没想明白, function ? tool? system ? content = "# observe values: \n" + content - msg = Role.new_assistant_system( + msg = Role.new_system( content=content, ) messages.append(msg) diff --git a/ghostos/framework/session/basic.py b/ghostos/framework/session/basic.py index e7b58631..a098b927 100644 --- a/ghostos/framework/session/basic.py +++ b/ghostos/framework/session/basic.py @@ -1,15 +1,15 @@ from typing import Optional, Callable, List, Iterable, Dict from ghostos.core.messages import ( - MessageKind, Role, Stream, MessageKindParser, DefaultMessageTypes, + MessageKind, Role, Stream, MessageKindParser, MessageType, Buffer, Payload, Attachment, Message, ) from ghostos.core.session import ( Session, - SessionProcess, GhostProcessRepo, - MsgThread, MsgThreadRepo, - Task, TaskRepo, TaskPayload, TaskState, + GoProcess, GoProcesses, + GoThreadInfo, GoThreads, + GoTaskStruct, GoTasks, TaskPayload, TaskState, Messenger, - Event, EventBus, DefaultEventType, + Event, EventBus, EventTypes, TaskBrief, ) from ghostos.core.llms import FunctionalToken @@ -30,7 +30,7 @@ def run(self, callback: Callable[[], Iterable[MessageKind]]) -> None: try: messages = list(callback()) except Exception as e: - messages = [Role.new_assistant_system("", memory=str(e))] + messages = [Role.new_system("", memory=str(e))] if len(messages) > 0: self.event.messages = messages self.bus.send_event(self.event, notify=self.notify) @@ -47,34 +47,34 @@ def __init__( upstream: Stream, eventbus: EventBus, pool: Pool, - processes: GhostProcessRepo, - tasks: TaskRepo, - threads: MsgThreadRepo, + processes: GoProcesses, + tasks: GoTasks, + threads: GoThreads, logger: LoggerItf, # 当前任务信息. - process: SessionProcess, - task: Task, - thread: MsgThread, + process: GoProcess, + task: GoTaskStruct, + thread: GoThreadInfo, ): self._pool = pool self._upstream = upstream self._logger = logger - self._tasks: TaskRepo = tasks - self._processes: GhostProcessRepo = processes + self._tasks: GoTasks = tasks + self._processes: GoProcesses = processes self._ghost_name: str = ghost_name self._message_role: str = ghost_role - self._threads: MsgThreadRepo = threads + self._threads: GoThreads = threads self._eventbus: EventBus = eventbus # 需要管理的状态. - self._task: Task = task - self._process: SessionProcess = process - self._creating: List[Task] = [] - self._thread: MsgThread = thread + self._task: GoTaskStruct = task + self._process: GoProcess = process + self._creating: List[GoTaskStruct] = [] + self._thread: GoThreadInfo = thread self._firing_events: List[Event] = [] self._fetched_task_briefs: Dict[str, TaskBrief] = {} def id(self) -> str: - return self._task.session_id + return self._task.shell_id def alive(self) -> bool: return ( @@ -90,20 +90,20 @@ def refresh_lock(self) -> bool: return True return False - def process(self) -> "SessionProcess": + def process(self) -> "GoProcess": return self._process - def task(self) -> "Task": + def task(self) -> "GoTaskStruct": return self._task - def thread(self) -> "MsgThread": + def thread(self) -> "GoThreadInfo": return self._thread def messenger( self, *, sending: bool = True, saving: bool = True, - thread: Optional[MsgThread] = None, + thread: Optional[GoThreadInfo] = None, name: Optional[str] = None, buffer: Optional[Buffer] = None, payloads: Optional[Iterable[Payload]] = None, @@ -145,20 +145,20 @@ def send_messages(self, *messages: MessageKind, role: str = Role.ASSISTANT.value self._logger.info(f"send message by session [send_messages], sent: {len(sent)}, callers: {len(callers)}") return sent - def update_task(self, task: "Task", thread: Optional["MsgThread"], update_history: bool) -> None: + def update_task(self, task: "GoTaskStruct", thread: Optional["GoThreadInfo"], update_history: bool) -> None: self._task = task if thread is not None: self._task.thread_id = thread.id - self._thread = thread.update_history() + self._thread = thread.get_updated_copy() if update_history: - self._thread = self._thread.update_history() + self._thread = self._thread.get_updated_copy() - def update_thread(self, thread: "MsgThread", update_history: bool) -> None: + def update_thread(self, thread: "GoThreadInfo", update_history: bool) -> None: if update_history: - thread = thread.update_history() + thread = thread.get_updated_copy() self._thread = thread - def create_tasks(self, *tasks: "Task") -> None: + def create_tasks(self, *tasks: "GoTaskStruct") -> None: self._creating.extend(tasks) def fire_events(self, *events: "Event") -> None: @@ -176,11 +176,11 @@ def fire_events(self, *events: "Event") -> None: def future(self, name: str, call: Callable[[], Iterable[MessageKind]], reason: str) -> None: future_id = uuid() # 增加一个消息. - system = DefaultMessageTypes.DEFAULT.new_system( + system = MessageType.DEFAULT.new_system( content=f"async call `{name}` with id `{future_id}`, wait for future callback.", ) self.send_messages(system) - event = DefaultEventType.OBSERVE.new( + event = EventTypes.ROTATE.new( task_id=self._task.task_id, from_task_id=self._task.task_id, messages=[], @@ -195,7 +195,7 @@ def _do_quit(self) -> None: task = self._tasks.get_task(main_task_id, False) self._firing_events = [] for task_id in task.children: - event = DefaultEventType.KILLING.new( + event = EventTypes.KILL.new( task_id=task_id, messages=[], from_task_id=self._task.task_id, @@ -218,7 +218,7 @@ def _do_fire_events(self) -> None: for e in self._firing_events: # all the sub-tasks need notification notify = e.task_id != main_task_id - self._logger.info(f"fire event {e.type}: eid {e.id}; task_id {e.task_id}") + self._logger.info(f"fire event {e.type}: eid {e.event_id}; task_id {e.task_id}") bus.send_event(e, notify) self._firing_events = [] @@ -235,10 +235,10 @@ def _do_finish_task_and_thread(self) -> None: child = children[idx] if child.is_overdue() or TaskState.is_dead(child.task_state): task.remove_child(child.task_id) - task.update_turn() + task.new_turn() self._task = task self._fetched_task_briefs = {} - self._thread = self._thread.update_history() + self._thread = self._thread.get_updated_copy() self._tasks.save_task(task) self._threads.save_thread(self._thread) @@ -264,19 +264,19 @@ def get_task_briefs(self, *task_ids, children: bool = False) -> "List[TaskBrief] self._fetched_task_briefs[task_brief.task_id] = task_brief return result - def tasks(self) -> TaskRepo: + def tasks(self) -> GoTasks: return self._tasks - def processes(self) -> GhostProcessRepo: + def processes(self) -> GoProcesses: return self._processes - def threads(self) -> MsgThreadRepo: + def threads(self) -> GoThreads: return self._threads def eventbus(self) -> EventBus: return self._eventbus - def update_process(self, process: "SessionProcess") -> None: + def update_process(self, process: "GoProcess") -> None: self._process = process def quit(self) -> None: @@ -319,11 +319,11 @@ def fail(self, err: Optional[Exception]) -> None: if locked: self._tasks.unlock_task(self._task.task_id, locked) self._task.lock = None - self._upstream.deliver(DefaultMessageTypes.ERROR.new(content=str(err))) + self._upstream.deliver(MessageType.ERROR.new(content=str(err))) self._logger.error(err) def done(self) -> None: locked = self._task.lock if locked: self._tasks.unlock_task(self._task.task_id, locked) - self._upstream.deliver(DefaultMessageTypes.final()) + self._upstream.deliver(MessageType.final()) diff --git a/ghostos/framework/streams/array.py b/ghostos/framework/streams/array.py index 7f449782..e7b46b14 100644 --- a/ghostos/framework/streams/array.py +++ b/ghostos/framework/streams/array.py @@ -1,7 +1,7 @@ from typing import Tuple, Optional, Dict, List, Iterable, Callable from ghostos.core.messages import ( Message, Stream, Receiver, Received, - DefaultMessageTypes, + MessageType, ) import time @@ -38,7 +38,7 @@ def __init__(self, idle: float): def add_item(self, item: Message) -> bool: if self._stopped: return False - if DefaultMessageTypes.is_protocol_message(item): + if MessageType.is_protocol_message(item): self.stop(item) return True @@ -97,7 +97,7 @@ def __enter__(self) -> Iterable[Received]: while idx < len(self._msg_ids): yield self._received[self._msg_ids[idx]] idx += 1 - if self._final and DefaultMessageTypes.ERROR.match(self._final): + if self._final and MessageType.ERROR.match(self._final): yield _ArrayReceived(self._final, idle=self.idle) self._iterating = False @@ -137,7 +137,7 @@ def deliver(self, pack: "Message") -> bool: return False if not self._timeleft.alive(): e = TimeoutError(f"Timeout after {self._timeleft.passed()}") - self._receiver.stop(DefaultMessageTypes.ERROR.new(content=str(e))) + self._receiver.stop(MessageType.ERROR.new(content=str(e))) raise e if pack.chunk and not self._accept_chunks: return True @@ -151,7 +151,7 @@ def deliver(self, pack: "Message") -> bool: def __exit__(self, exc_type, exc_val, exc_tb): item = None if exc_val: - item = DefaultMessageTypes.ERROR.new(content=str(exc_val)) + item = MessageType.ERROR.new(content=str(exc_val)) if not self._stopped: self._receiver.stop(item) self.stop() @@ -179,12 +179,12 @@ def __init__(self, head: Message, idle: Callable) -> None: self._items: List[Dict] = [head.model_dump(exclude_defaults=True)] self._stopped = False self._tail: Optional[Message] = None - if head.is_complete() or DefaultMessageTypes.is_protocol_message(head): + if head.is_complete() or MessageType.is_protocol_message(head): self._tail = head.as_tail() self._destroyed = False def add_item(self, item: Message) -> None: - if item.is_complete() or DefaultMessageTypes.is_protocol_message(item): + if item.is_complete() or MessageType.is_protocol_message(item): self._tail = item.as_tail() else: self._items.append(item.model_dump(exclude_defaults=True)) diff --git a/ghostos/framework/streams/queuestream.py b/ghostos/framework/streams/queuestream.py index 00a288a8..18e5d2c3 100644 --- a/ghostos/framework/streams/queuestream.py +++ b/ghostos/framework/streams/queuestream.py @@ -1,6 +1,6 @@ from typing import Iterable, Optional -from ghostos.core.messages import Stream, Message, DefaultMessageTypes +from ghostos.core.messages import Stream, Message, MessageType from queue import Queue __all__ = ["QueueStream"] @@ -20,7 +20,7 @@ def __init__(self, queue: Queue, accept_chunks: bool = True): def deliver(self, pack: "Message") -> bool: if self._stopped: return False - if DefaultMessageTypes.is_protocol_message(pack): + if MessageType.is_protocol_message(pack): return True elif self._accept_chunks and not pack.is_complete(): # 不发送间包, 只发送尾包. @@ -40,9 +40,9 @@ def stop(self, error: Optional[Exception]) -> None: return self._stopped = True if error: - final = DefaultMessageTypes.ERROR.new(content=str(error)) + final = MessageType.ERROR.new(content=str(error)) else: - final = DefaultMessageTypes.final() + final = MessageType.final() self._queue.put(final) self._queue.task_done() del self._queue diff --git a/ghostos/framework/taskflow/basic.py b/ghostos/framework/taskflow/basic.py index 4da0e6c1..84b95db8 100644 --- a/ghostos/framework/taskflow/basic.py +++ b/ghostos/framework/taskflow/basic.py @@ -1,6 +1,6 @@ from typing import Dict, Any from ghostos.core.ghosts import Taskflow, Operator -from ghostos.core.messages import DefaultMessageTypes +from ghostos.core.messages import MessageType from ghostos.framework.operators import ( ThinkOperator, FinishOperator, @@ -28,7 +28,7 @@ def observe(self, objects: Dict[str, Any], reason: str = "", instruction: str = # 用什么协议没想明白, function ? tool? system ? content = "observe values: \n" + content - msg = DefaultMessageTypes.DEFAULT.new_system( + msg = MessageType.DEFAULT.new_system( content=content, ) observation.append(msg) diff --git a/ghostos/framework/tasks/__init__.py b/ghostos/framework/tasks/__init__.py index 9ddd286f..fbd6c3a0 100644 --- a/ghostos/framework/tasks/__init__.py +++ b/ghostos/framework/tasks/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.session import TaskRepo +from ghostos.core.session import GoTasks from ghostos.framework.tasks.storage_tasks import StorageTasksImplProvider, WorkspaceTasksProvider diff --git a/ghostos/framework/tasks/storage_tasks.py b/ghostos/framework/tasks/storage_tasks.py index 63b48b43..2c20d8fe 100644 --- a/ghostos/framework/tasks/storage_tasks.py +++ b/ghostos/framework/tasks/storage_tasks.py @@ -1,23 +1,23 @@ from typing import Optional, List, Iterable, Dict, Type import yaml -from ghostos.core.session import TaskState, TaskBrief, Task, TaskRepo +from ghostos.core.session import TaskState, TaskBrief, GoTaskStruct, GoTasks from ghostos.contracts.workspace import Workspace from ghostos.contracts.logger import LoggerItf from ghostos.contracts.storage import Storage from ghostos.container import Provider, Container from ghostos.helpers import uuid -__all__ = ['StorageTaskRepoImpl', 'StorageTasksImplProvider', 'WorkspaceTasksProvider'] +__all__ = ['StorageGoTasksImpl', 'StorageTasksImplProvider', 'WorkspaceTasksProvider'] -class StorageTaskRepoImpl(TaskRepo): +class StorageGoTasksImpl(GoTasks): def __init__(self, storage: Storage, logger: LoggerItf): self._storage = storage self._logger = logger self._locks: Dict[str, str] = {} - def save_task(self, *tasks: Task) -> None: + def save_task(self, *tasks: GoTaskStruct) -> None: for task in tasks: filename = self._get_task_filename(task.task_id) content = yaml.safe_dump(task.model_dump(exclude_defaults=True)) @@ -28,20 +28,20 @@ def save_task(self, *tasks: Task) -> None: def _get_task_filename(task_id: str) -> str: return f"{task_id}.task.yml" - def _get_task(self, task_id: str) -> Optional[Task]: + def _get_task(self, task_id: str) -> Optional[GoTaskStruct]: filename = self._get_task_filename(task_id) if not self._storage.exists(filename): return None content = self._storage.get(filename) data = yaml.safe_load(content) - task = Task(**data) + task = GoTaskStruct(**data) return task def exists(self, task_id: str) -> bool: filename = self._get_task_filename(task_id) return self._storage.exists(filename) - def get_task(self, task_id: str, lock: bool) -> Optional[Task]: + def get_task(self, task_id: str, lock: bool) -> Optional[GoTaskStruct]: task = self._get_task(task_id) if task is None: return None @@ -55,7 +55,7 @@ def get_task(self, task_id: str, lock: bool) -> Optional[Task]: task.lock = None return task - def get_tasks(self, task_ids: List[str], states: Optional[List[TaskState]] = None) -> Iterable[Task]: + def get_tasks(self, task_ids: List[str], states: Optional[List[TaskState]] = None) -> Iterable[GoTaskStruct]: states = set(states) if states else None for task_id in task_ids: task = self.get_task(task_id, lock=False) @@ -87,7 +87,7 @@ def refresh_task_lock(self, task_id: str, lock: str) -> Optional[str]: return None -class StorageTasksImplProvider(Provider[TaskRepo]): +class StorageTasksImplProvider(Provider[GoTasks]): """ provide storage based Tasks """ @@ -98,17 +98,17 @@ def __init__(self, tasks_dir: str = "runtime/tasks"): def singleton(self) -> bool: return True - def contract(self) -> Type[TaskRepo]: - return TaskRepo + def contract(self) -> Type[GoTasks]: + return GoTasks - def factory(self, con: Container) -> Optional[TaskRepo]: + def factory(self, con: Container) -> Optional[GoTasks]: logger = con.force_fetch(LoggerItf) storage = con.force_fetch(Storage) tasks_storage = storage.sub_storage(self.tasks_dir) - return StorageTaskRepoImpl(tasks_storage, logger) + return StorageGoTasksImpl(tasks_storage, logger) -class WorkspaceTasksProvider(Provider[TaskRepo]): +class WorkspaceTasksProvider(Provider[GoTasks]): def __init__(self, namespace: str = "tasks"): self.namespace = namespace @@ -116,12 +116,12 @@ def __init__(self, namespace: str = "tasks"): def singleton(self) -> bool: return True - def contract(self) -> Type[TaskRepo]: - return TaskRepo + def contract(self) -> Type[GoTasks]: + return GoTasks - def factory(self, con: Container) -> Optional[TaskRepo]: + def factory(self, con: Container) -> Optional[GoTasks]: workspace = con.force_fetch(Workspace) runtime_storage = workspace.runtime() tasks_storage = runtime_storage.sub_storage(self.namespace) logger = con.force_fetch(LoggerItf) - return StorageTaskRepoImpl(tasks_storage, logger) + return StorageGoTasksImpl(tasks_storage, logger) diff --git a/ghostos/framework/threads/__init__.py b/ghostos/framework/threads/__init__.py index 9b0c6a58..e05d25ed 100644 --- a/ghostos/framework/threads/__init__.py +++ b/ghostos/framework/threads/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.session import MsgThreadRepo +from ghostos.core.session import GoThreads from ghostos.framework.threads.storage_threads import MsgThreadRepoByStorageProvider, MsgThreadsRepoByWorkSpaceProvider diff --git a/ghostos/framework/threads/storage_threads.py b/ghostos/framework/threads/storage_threads.py index 726a06e7..532f622d 100644 --- a/ghostos/framework/threads/storage_threads.py +++ b/ghostos/framework/threads/storage_threads.py @@ -1,5 +1,5 @@ from typing import Optional, Type -from ghostos.core.session import MsgThread, MsgThreadRepo, SimpleMsgThread +from ghostos.core.session import GoThreadInfo, GoThreads, SimpleMsgThread from ghostos.contracts.workspace import Workspace from ghostos.contracts.storage import Storage from ghostos.contracts.logger import LoggerItf @@ -8,10 +8,10 @@ import yaml import os -__all__ = ['MsgThreadRepoByStorage', 'MsgThreadRepoByStorageProvider', 'MsgThreadsRepoByWorkSpaceProvider'] +__all__ = ['GoThreadsByStorage', 'MsgThreadRepoByStorageProvider', 'MsgThreadsRepoByWorkSpaceProvider'] -class MsgThreadRepoByStorage(MsgThreadRepo): +class GoThreadsByStorage(GoThreads): def __init__( self, *, @@ -23,16 +23,16 @@ def __init__( self._logger = logger self._allow_saving_file = allow_saving_file - def get_thread(self, thread_id: str, create: bool = False) -> Optional[MsgThread]: + def get_thread(self, thread_id: str, create: bool = False) -> Optional[GoThreadInfo]: path = self._get_thread_filename(thread_id) if not self._storage.exists(path): return None content = self._storage.get(path) data = yaml.safe_load(content) - thread = MsgThread(**data) + thread = GoThreadInfo(**data) return thread - def save_thread(self, thread: MsgThread) -> None: + def save_thread(self, thread: GoThreadInfo) -> None: data = thread.model_dump(exclude_defaults=True) data_content = yaml_pretty_dump(data) path = self._get_thread_filename(thread.id) @@ -58,11 +58,11 @@ def save_thread(self, thread: MsgThread) -> None: def _get_thread_filename(thread_id: str) -> str: return thread_id + ".thread.yml" - def fork_thread(self, thread: MsgThread) -> MsgThread: + def fork_thread(self, thread: GoThreadInfo) -> GoThreadInfo: return thread.fork() -class MsgThreadRepoByStorageProvider(Provider[MsgThreadRepo]): +class MsgThreadRepoByStorageProvider(Provider[GoThreads]): def __init__(self, threads_dir: str = "runtime/threads"): self._threads_dir = threads_dir @@ -70,17 +70,17 @@ def __init__(self, threads_dir: str = "runtime/threads"): def singleton(self) -> bool: return True - def contract(self) -> Type[MsgThreadRepo]: - return MsgThreadRepo + def contract(self) -> Type[GoThreads]: + return GoThreads - def factory(self, con: Container) -> Optional[MsgThreadRepo]: + def factory(self, con: Container) -> Optional[GoThreads]: storage = con.force_fetch(Storage) threads_storage = storage.sub_storage(self._threads_dir) logger = con.force_fetch(LoggerItf) - return MsgThreadRepoByStorage(storage=threads_storage, logger=logger) + return GoThreadsByStorage(storage=threads_storage, logger=logger) -class MsgThreadsRepoByWorkSpaceProvider(Provider[MsgThreadRepo]): +class MsgThreadsRepoByWorkSpaceProvider(Provider[GoThreads]): def __init__(self, namespace: str = "threads"): self._namespace = namespace @@ -88,11 +88,11 @@ def __init__(self, namespace: str = "threads"): def singleton(self) -> bool: return True - def contract(self) -> Type[MsgThreadRepo]: - return MsgThreadRepo + def contract(self) -> Type[GoThreads]: + return GoThreads - def factory(self, con: Container) -> Optional[MsgThreadRepo]: + def factory(self, con: Container) -> Optional[GoThreads]: workspace = con.force_fetch(Workspace) logger = con.force_fetch(LoggerItf) threads_storage = workspace.runtime().sub_storage(self._namespace) - return MsgThreadRepoByStorage(storage=threads_storage, logger=logger) + return GoThreadsByStorage(storage=threads_storage, logger=logger) diff --git a/ghostos/helpers/__init__.py b/ghostos/helpers/__init__.py index dc1c5a8f..8ebdce12 100644 --- a/ghostos/helpers/__init__.py +++ b/ghostos/helpers/__init__.py @@ -21,7 +21,7 @@ from ghostos.helpers.hashes import md5 from ghostos.helpers.trans import gettext, ngettext, get_current_locale, GHOSTOS_DOMAIN -from ghostos.helpers.coding import reflect_module_code +from ghostos.helpers.coding import reflect_module_code, unwrap from ghostos.helpers.openai import get_openai_key if TYPE_CHECKING: diff --git a/ghostos/helpers/coding.py b/ghostos/helpers/coding.py index 6f7c3638..54c22a25 100644 --- a/ghostos/helpers/coding.py +++ b/ghostos/helpers/coding.py @@ -1,5 +1,13 @@ +from typing import TypeVar, Union, Callable from types import ModuleType -import inspect + +T = TypeVar("T") + + +def unwrap(value: Union[T, Callable[[], T]]) -> T: + if isinstance(value, Callable): + return value() + return value def reflect_module_code(module: ModuleType) -> str: diff --git a/ghostos/helpers/hashes.py b/ghostos/helpers/hashes.py index 11f5adab..c83d4365 100644 --- a/ghostos/helpers/hashes.py +++ b/ghostos/helpers/hashes.py @@ -9,3 +9,17 @@ def md5(input_string: str) -> str: # 获取16进制的哈希值 hash_value = md5_obj.hexdigest() return hash_value + + +def sha1(input_string: str) -> str: + sha1_obj = hashlib.sha1() + sha1_obj.update(input_string.encode('utf-8')) + hash_value = sha1_obj.hexdigest() + return hash_value + + +def sha256(input_string: str) -> str: + sha256_obj = hashlib.sha256() + sha256_obj.update(input_string.encode('utf-8')) + hash_value = sha256_obj.hexdigest() + return hash_value diff --git a/evaluation/swe_bench_lite/base/__init__.py b/ghostos/prototypes/__init__.py similarity index 100% rename from evaluation/swe_bench_lite/base/__init__.py rename to ghostos/prototypes/__init__.py diff --git a/ghostos/prototypes/aifunc/app.py b/ghostos/prototypes/aifunc/app.py index 51251539..5713dc4d 100644 --- a/ghostos/prototypes/aifunc/app.py +++ b/ghostos/prototypes/aifunc/app.py @@ -4,9 +4,9 @@ import yaml from typing import List, Dict -from ghostos.core.session import MsgThread +from ghostos.core.session import GoThreadInfo from logging.config import dictConfig -from ghostos.core.llms import Chat +from ghostos.core.llms import Prompt from ghostos.core.messages import Message from ghostos.core.moss import test_container from ghostos.core.aifunc import ( @@ -73,7 +73,7 @@ def on_message(self, message: Message) -> None: if value != "y": exit(0) - def on_chat(self, chat: Chat) -> None: + def on_chat(self, chat: Prompt) -> None: for message in chat.get_messages(): self.console.print(Panel( Markdown(message.get_content()), @@ -87,7 +87,7 @@ def on_chat(self, chat: Chat) -> None: def on_system_messages(self, messages: List[Message]) -> None: pass - def on_save(self, manager: AIFuncExecutor, thread: MsgThread) -> None: + def on_save(self, manager: AIFuncExecutor, thread: GoThreadInfo) -> None: current = thread.current if current: for message in current.messages(): diff --git a/ghostos/prototypes/console/app.py b/ghostos/prototypes/console/app.py index 94aea1e4..c6004875 100644 --- a/ghostos/prototypes/console/app.py +++ b/ghostos/prototypes/console/app.py @@ -4,7 +4,7 @@ import asyncio from typing import Optional, List -from ghostos.core.messages import Message, Role, DefaultMessageTypes +from ghostos.core.messages import Message, Role, MessageType from ghostos.core.ghosts import Inputs from ghostos.framework.streams import QueueStream from ghostos.framework.messages import TaskPayload @@ -83,7 +83,7 @@ def _start_background(self): if not handled: time.sleep(1) elif not self._debug: - self._console.print(f"handled event {handled.type}: task_id {handled.task_id}; event_id {handled.id};") + self._console.print(f"handled event {handled.type}: task_id {handled.task_id}; event_id {handled.event_id};") else: self._console.print(Panel( Markdown(f"```json\n{handled.model_dump_json(indent=2)}\n```"), @@ -107,7 +107,7 @@ async def _main(self): self._main_task_id = self._on_input(self._on_create_message) else: self._console.print("waiting for agent say hi...") - message = Role.new_assistant_system( + message = Role.new_system( self._welcome_user_message, ) self._main_task_id = self._on_message_input(message) @@ -230,12 +230,12 @@ def _print_message(self, message: Message): f"> task_name: {payload.task_name}\n\n", ]) if "" in content: - content = content.replace("", "\n```python\n# \n", ) + content = content.replace("", "\n```python\n# \n", ) if "" in content: - content = content.replace("", "\n# \n```\n", ) + content = content.replace("", "\n# \n```\n", ) markdown = self._markdown_output(prefix + content) # border style - if DefaultMessageTypes.ERROR.match(message): + if MessageType.ERROR.match(message): border_style = "red" elif payload is not None and payload.task_id == self._main_task_id: border_style = "blue" diff --git a/ghostos/prototypes/ghostfunc/driver.py b/ghostos/prototypes/ghostfunc/driver.py index 3c9a4a3f..fb7746ec 100644 --- a/ghostos/prototypes/ghostfunc/driver.py +++ b/ghostos/prototypes/ghostfunc/driver.py @@ -3,7 +3,7 @@ import yaml import importlib from ghostos.container import Container -from ghostos.core.session import MsgThread, DefaultEventType, thread_to_chat +from ghostos.core.session import GoThreadInfo, EventTypes, thread_to_chat from ghostos.core.moss import MossRuntime, MossCompiler, PyContext from ghostos.core.llms import LLMs, LLMApi from ghostos.core.messages import Role, Message @@ -21,7 +21,7 @@ class GhostFuncCache(BaseModel): """ modulename: str = Field(description="the module name that decorated function located") filename: Optional[str] = Field(default=None, description="the filename that decorated function located") - threads: Dict[str, MsgThread] = Field( + threads: Dict[str, GoThreadInfo] = Field( default_factory=dict, description="a map of function.__qualname__ to thread instance", ) @@ -130,7 +130,7 @@ def execute(self, args: List[Any], kwargs: Dict[str, Any]) -> Any: thread = self._init_thread() return self._run(thread, args, kwargs) - def _run(self, thread: MsgThread, args: List[Any], kwargs: Dict[str, Any]) -> Any: + def _run(self, thread: GoThreadInfo, args: List[Any], kwargs: Dict[str, Any]) -> Any: """ run the ghost func with the origin function's args and kwargs. :param thread: @@ -162,14 +162,14 @@ def _init_prompt(self, context_code: str) -> str: target_source=self._target_source, ) - def _init_thread(self) -> MsgThread: + def _init_thread(self) -> GoThreadInfo: pycontext = self._init_pycontext() moss_runtime = self._moss_runtime(pycontext) context_code = moss_runtime.prompter().dump_context_prompt() instruction = self._init_prompt(context_code) system = Role.SYSTEM.new(content=instruction) - e = DefaultEventType.OBSERVE.new(task_id="", messages=[system], from_task_id="") - return MsgThread.new( + e = EventTypes.ROTATE.new(task_id="", messages=[system], from_task_id="") + return GoThreadInfo.new( event=e, pycontext=pycontext, ) @@ -193,17 +193,17 @@ def _get_llm_api(self) -> LLMApi: def _start_with_generated_code( self, generated: str, - thread: MsgThread, + thread: GoThreadInfo, pycontext: PyContext, args: List[Any], kwargs: Dict[str, Any], - ) -> Tuple[MsgThread, Any]: + ) -> Tuple[GoThreadInfo, Any]: result, ok = self._run_code(generated, thread, pycontext, args, kwargs) if ok: return thread, result return self._think(thread, args, kwargs) - def _think(self, thread: MsgThread, args: List[Any], kwargs: Dict[str, Any]) -> Tuple[MsgThread, Any]: + def _think(self, thread: GoThreadInfo, args: List[Any], kwargs: Dict[str, Any]) -> Tuple[GoThreadInfo, Any]: turns = 0 while True: result, ok = self._run_turn(thread, args, kwargs) @@ -213,7 +213,7 @@ def _think(self, thread: MsgThread, args: List[Any], kwargs: Dict[str, Any]) -> if turns > self._max_turns: raise RuntimeError(f"Exceed max turns {self._max_turns} turns, still not success") - def _run_turn(self, thread: MsgThread, args: List[Any], kwargs: Dict[str, Any]) -> Tuple[Any, bool]: + def _run_turn(self, thread: GoThreadInfo, args: List[Any], kwargs: Dict[str, Any]) -> Tuple[Any, bool]: pycontext = thread.last_turn().pycontext chat = thread_to_chat(thread.id, [], thread) llm_api = self._get_llm_api() @@ -223,7 +223,7 @@ def _run_turn(self, thread: MsgThread, args: List[Any], kwargs: Dict[str, Any]) code, ok = self._unwrap_message_code(message) if not ok: thread.new_turn( - event=DefaultEventType.OBSERVE.new( + event=EventTypes.ROTATE.new( task_id="", from_task_id="", messages=[Role.SYSTEM.new(content=code)], @@ -235,7 +235,7 @@ def _run_turn(self, thread: MsgThread, args: List[Any], kwargs: Dict[str, Any]) def _run_code( self, code: str, - thread: MsgThread, + thread: GoThreadInfo, pycontext: PyContext, args: List[Any], kwargs: Dict[str, Any], @@ -250,7 +250,7 @@ def _run_code( if not self._ask_confirm_error(thread, e): message = Role.SYSTEM.new(content=f"Error occur: {e}") thread.new_turn( - event=DefaultEventType.OBSERVE.new(task_id="", messages=[message], from_task_id="") + event=EventTypes.ROTATE.new(task_id="", messages=[message], from_task_id="") ) return None, False finally: @@ -260,12 +260,12 @@ def _run_code( if not ok: message = Role.SYSTEM.new(content=executed.std_output) thread.new_turn( - event=DefaultEventType.OBSERVE.new(task_id="", messages=[message], from_task_id=""), + event=EventTypes.ROTATE.new(task_id="", messages=[message], from_task_id=""), ) return None, False return result, True - def _ask_confirm_error(self, thread: MsgThread, error: Exception) -> bool: + def _ask_confirm_error(self, thread: GoThreadInfo, error: Exception) -> bool: chat = thread_to_chat(thread.id, [], thread) chat.appending.append( Role.SYSTEM.new( @@ -286,7 +286,7 @@ def _unwrap_message_code(message: Message) -> Tuple[str, bool]: splits = code.split('', 2) return splits[0], True - def _save_thread(self, thread: MsgThread) -> None: + def _save_thread(self, thread: GoThreadInfo) -> None: self._cache.threads[self._target_qualname] = thread def destroy(self) -> None: diff --git a/ghostos/prototypes/mosstemp/template.py b/ghostos/prototypes/mosstemp/template.py index 5f9a203c..d09a7ee7 100644 --- a/ghostos/prototypes/mosstemp/template.py +++ b/ghostos/prototypes/mosstemp/template.py @@ -22,7 +22,7 @@ def example_hello_world_main(moss: Moss) -> Optional[Operator]: # todo: the example codes pass -# the content between mark are not visible in the prompt for LLM +# the content between mark are not visible in the prompt for LLM from typing import TYPE_CHECKING @@ -63,4 +63,4 @@ def example_hello_world_main(moss: Moss) -> Optional[Operator]: llm_api_name="", ) -# +# diff --git a/ghostos/prototypes/realtime/abcd.py b/ghostos/prototypes/realtime/abcd.py index cdf95dc0..11f5dd5c 100644 --- a/ghostos/prototypes/realtime/abcd.py +++ b/ghostos/prototypes/realtime/abcd.py @@ -5,6 +5,7 @@ Self, Union, ) +from ghostos.core.messages import Message import time from enum import Enum from pydantic import BaseModel @@ -12,12 +13,6 @@ from contextlib import contextmanager -class Message(Protocol): - msg_id: str - type: str - role: str - seq: Literal["head", "chunk", "complete"] - class State(ABC): state_name: ClassVar[str] @@ -27,15 +22,7 @@ def conversation(self) -> ConversationProtocol: pass @abstractmethod - def status(self) -> str: - """ - if there are sub statuses of this State - :return: - """ - pass - - @abstractmethod - def operate(self, op: Operator) -> Tuple[Literal["", "queued", "blocked", "illegal"], str | None]: + def operate(self, op: Operator) -> Tuple[OperationType, str | None]: """ :param op: :return: accept level | error message @@ -73,7 +60,6 @@ def tick(self) -> Union[State, None]: @abstractmethod def join(self): """ - clear/recycle/release resources when a state is overdue """ pass @@ -123,7 +109,6 @@ class RealtimeAgent(Protocol[S]): @abstractmethod def run_util_stop( self, - conversation: Optional[ConversationProtocol] = None, *shells: Shell, ) -> None: pass @@ -136,18 +121,11 @@ def id(self) -> str: pass @abstractmethod - def messages(self) -> Iterable[Message]: - pass - - @abstractmethod - def append(self, message: Message) -> None: + def messages(self) -> List[Message]: pass @abstractmethod - def save(self): - """ - save to local storage or do nothing. - """ + def add(self, message: Message) -> None: pass @@ -160,40 +138,33 @@ def with_name(self, name: str) -> Self: return self.model_copy(update={"name": name}, deep=True) -class Ghost(Protocol): +class Ghost(Protocol[S]): @abstractmethod - def alive(self) -> bool: + def operate(self, op: Operator) -> Tuple[OperationType, str | None]: + """ + :param op: + :return: accept level | error message + """ pass @abstractmethod - def status(self) -> str: + def state(self) -> S: pass @abstractmethod - def operate(self, op: Operator) -> Tuple[str, bool]: + def messages(self) -> Iterable[Message]: pass - @abstractmethod - def allow(self, op: Type[Operator]) -> bool: - pass - @abstractmethod - def session(self) -> S: - pass +class Shell(Protocol): @abstractmethod - def messages(self) -> Iterable[Message]: + def name(self) -> str: pass - -class Shell(Protocol): - id: str - name: str - description: str - @abstractmethod - def functions(self) -> Iterable[Function]: + def functions(self) -> List[Function]: pass @abstractmethod @@ -201,7 +172,7 @@ def subscribing(self) -> List[str]: pass @abstractmethod - def on_sync(self, ghost: Ghost) -> ChanOut[Union[dict, None]]: + def on_sync(self, ghost: Ghost) -> ChanIn[Union[dict, None]]: """ sync the ghost with shell, and return a channel that the agent publish the subscribed event to the shell. @@ -265,4 +236,7 @@ class Operator(BaseModel, ABC): is a protocol defined by the agent. """ type: str - shell_id: str = "" + shell: str = "" + + +OperationType = Literal["", "queued", "blocked", "illegal"] diff --git a/ghostos/prototypes/realtime/openai/agent.py b/ghostos/prototypes/realtime/openai/agent.py index bd46417d..a0d3ed3a 100644 --- a/ghostos/prototypes/realtime/openai/agent.py +++ b/ghostos/prototypes/realtime/openai/agent.py @@ -1,38 +1,158 @@ from __future__ import annotations -from typing import Self, Literal, List, Optional, Union, Dict -from enum import Enum -from ghostos.prototypes.realtime.abcd import SessionProtocol, Function, RealtimeAgent, S, ConversationProtocol, Shell, \ - Runtime -from ghostos.helpers import uuid -from pydantic import BaseModel, Field -from .protocols import SessionObj, SessionObjBase +import time +from typing import List, Optional, Dict, Iterable, Tuple, Callable, Union +from threading import Thread +from ghostos.prototypes.realtime.abcd import ( + Function, RealtimeAgent, + Shell, + Ghost, Message, + Operator, OperationType, ChanIn, + ConversationProtocol, +) +from ghostos.container import Container +from ghostos.contracts.logger import LoggerItf, get_logger +from concurrent.futures import ThreadPoolExecutor +from queue import Queue +from .protocols import StateName, ServerEventType +from .configs import AgentConf +from .states import AbsState, ConnectingState, StateCtx +from .broadcast import SimpleBroadcaster, Broadcaster -class Session(BaseModel, SessionProtocol): - instructions: str = Field(description="Instructions") - shell_funcs: Dict[str, List[Function]] = Field(default_factory=dict) - temperature: float = Field(default=0.8) - max_response_output_tokens: Union[int, Literal['inf']] = Field(default='inf') +class Agent(RealtimeAgent): - def to_session_obj(self) -> SessionObj: - raise NotImplementedError("todo") + def __init__( + self, + conf: AgentConf, + container: Container, + conversation: ConversationProtocol, + proxy: Optional[Callable] = None, + ): + self._container = Container(parent=container) + self._conf = conf + self._conversation = conversation + self._state: AbsState | None = None + self._container.set(RealtimeAgent, self) + self._container.set(ConversationProtocol, self._conversation) + self._proxy = proxy + self._logger = container.get(LoggerItf) + self._pool = ThreadPoolExecutor(max_workers=2) + if self._logger is None: + self._logger = get_logger() + self._closed: bool = False + self._started = False -class Conf(BaseModel): - """ - conf of the openai realtime agent - """ - name: str = Field() - instructions: str = Field() - session: SessionObjBase + def run_util_stop(self, *shells: Shell) -> None: + if self._started: + raise RuntimeError("agent already started") + _funcs: Dict[str, List[Function]] = {} + _broadcast: Broadcaster = SimpleBroadcaster() + # bind shells. + for shell in shells: + self._add_shell(shell, _broadcast, _funcs) -class Agent(RealtimeAgent[Session]): + _ctx = StateCtx( + conf=self._conf, + container=self._container, + funcs=_funcs, + conversation=self._conversation, + broadcaster=_broadcast, + session=None, + connection=None, + connect_sock=self._proxy, + logger=self._logger, + ) - def run_util_stop( - self, - session: Session, - conversation: Optional[ConversationProtocol] = None, - *shells: Shell, - ) -> Runtime: - pass + self._state = ConnectingState(_ctx) + while not self._closed: + state = self._state + new_state = state.tick() + if new_state is None: + time.sleep(0.05) + else: + # destroy + self._pool.submit(state.join) + # renew the state + self._state = new_state + if new_state.state_name == StateName.stopped: + # stop the world + break + # recycle + _broadcast.close() + if self._state is not None: + self._state.join() + self._pool.shutdown() + + def _add_shell(self, shell: Shell, _broadcast: Broadcaster, _funcs: Dict[str, List[Function]]) -> None: + """ + initialize shell data + """ + name = shell.name() + if name in _funcs: + raise KeyError(f"Shell `{name}` already exists") + _funcs[name] = shell.functions() + event_types = shell.subscribing() + ghost = self.GhostAdapter(self, name) + chan_in = shell.on_sync(ghost) + _broadcast.subscribe(name, chan_in, event_types) + + class GhostAdapter(Ghost[AbsState]): + """ + Adapter to wrap the agent to the ghost + """ + + def __init__(self, agent: Agent, shell_name: str): + self._agent = agent + self._shell_name = shell_name + + def operate(self, op: Operator) -> Tuple[OperationType, str | None]: + if self._agent._state is None: + return "illegal", "agent is not ready" + op.shell = self._shell_name + return self._agent._state.operate(op) + + def state(self) -> AbsState: + return self._agent._state + + def messages(self) -> Iterable[Message]: + return self.state().conversation().messages() + +# class ConversationShell(Shell): +# """ +# non-block conversation item updater +# """ +# +# def __init__(self, conversation: Conversation): +# self._conversation = conversation +# self._recv_queue = Queue() +# self._closed = False +# self._main_thread = Thread(target=self._main) +# +# def name(self) -> str: +# return "__conversation__" +# +# def functions(self) -> List[Function]: +# return [] +# +# def subscribing(self) -> List[str]: +# return ServerEventType.conversation_item_events() +# +# def on_sync(self, ghost: Ghost) -> ChanIn[Union[dict, None]]: +# self._main_thread.start() +# return self._recv_queue +# +# def _main(self): +# while not self._closed: +# e = self._recv_queue.get(block=True) +# if e is None: +# self._closed = True +# break +# self._add_conversation(e) +# +# def _add_conversation(self, e: dict) -> None: +# raise NotImplementedError("todo") +# +# def destroy(self): +# self._main_thread.join() diff --git a/ghostos/prototypes/realtime/openai/broadcast.py b/ghostos/prototypes/realtime/openai/broadcast.py index 0f37a01f..73643061 100644 --- a/ghostos/prototypes/realtime/openai/broadcast.py +++ b/ghostos/prototypes/realtime/openai/broadcast.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from abc import ABC, abstractmethod from copy import deepcopy from ghostos.prototypes.realtime.abcd import ChanIn @@ -22,7 +22,7 @@ def subscribe( pass @abstractmethod - def publish(self, topic: str, data: dict): + def publish(self, topic: str, data: Union[dict, None]): pass @abstractmethod @@ -58,7 +58,7 @@ def subscribe( self.topic_to_subscribers[topic] = subscribers return None - def publish(self, topic: str, data: dict): + def publish(self, topic: str, data: Union[dict, None]): if self._closed: raise RuntimeError("Broadcaster already closed") if topic not in self.topic_to_subscribers: @@ -70,9 +70,9 @@ def publish(self, topic: str, data: dict): if self._closed: break chan = self.subscriber_channels[subscriber] - copied = deepcopy(data) + # copied = deepcopy(data) try: - chan.put(copied, block=False, timeout=0.5) + chan.put(data, block=False, timeout=0.5) except TimeoutError as e: raise RuntimeError(f"Failed to publish because subscriber {subscriber} chan timed out: {e}") except Exception as e: diff --git a/ghostos/prototypes/realtime/openai/conversation.py b/ghostos/prototypes/realtime/openai/conversation.py index ff245cc3..8e7ef044 100644 --- a/ghostos/prototypes/realtime/openai/conversation.py +++ b/ghostos/prototypes/realtime/openai/conversation.py @@ -1,18 +1,40 @@ -from typing import Iterable +from typing import Iterable, List, Dict +from abc import ABC, abstractmethod -from ghostos.prototypes.realtime.abcd import ConversationProtocol, Message +from ghostos.prototypes.realtime.abcd import ConversationProtocol +from ghostos.core.messages import Message -class Conversation(ConversationProtocol): +class AbsConversation(ConversationProtocol, ABC): + message_index: Dict[int, str] + message_map: Dict[str, Message] - def id(self) -> str: - pass + def __init__( + self, + message_index: Dict[int, str], + message_map: Dict[str, Message], + ): + self.message_index = message_index + self.message_map = message_map - def messages(self) -> Iterable[Message]: - pass + def messages(self) -> List[Message]: + keys = self.message_index.keys() + sorted_keys = sorted(keys) + messages = [] + for index in sorted_keys: + msg_id = self.message_index[index] + message = self.message_map.get(msg_id) + messages.append(message) + return messages - def append(self, message: Message) -> None: - pass + def add(self, message: Message) -> None: + msg_id = message.msg_id + index = message.index + if index is not None: + self.message_index[index] = msg_id + self.message_map[msg_id] = message + self.save() + @abstractmethod def save(self): - pass \ No newline at end of file + pass diff --git a/ghostos/prototypes/realtime/openai/protocols.py b/ghostos/prototypes/realtime/openai/protocols.py index 3e91d4f9..80cf9e70 100644 --- a/ghostos/prototypes/realtime/openai/protocols.py +++ b/ghostos/prototypes/realtime/openai/protocols.py @@ -1,4 +1,4 @@ -from typing import Self, Literal, List, Optional, Union, Dict, Protocol, ClassVar +from typing import Self, Literal, List, Optional, Union, Dict, Protocol, ClassVar, Set from abc import ABC, abstractmethod from enum import Enum from ghostos.prototypes.realtime.abcd import Function @@ -11,7 +11,7 @@ class StateName(str, Enum): stopped = "stopped" # --- blocking - connected = "connected" + connecting = "connecting" session_updating = "session_updating" # --- interruptible @@ -38,7 +38,7 @@ class OperatorName(str, Enum): function_output = "function_output" # --- blocking - session_updating = "session_updating" + session_update = "session_updating" # --- idempotent or illegal create_response = "create_response" @@ -71,10 +71,32 @@ class ServerEventType(str, Enum): # system rate_limits_updated = "rate_limits.updated" + @classmethod + def conversation_item_events(cls) -> List["ServerEventType"]: + return [ + cls.conversation_item_created, + cls.conversation_item_deleted, + cls.audio_transcript_created, + cls.audio_transcript_failed, + cls.response_output_item_done, + ] + @classmethod def get_type(cls, event: dict) -> Self: return cls(event["type"]) + @classmethod + def get_event_id(cls, event: dict) -> str: + return event.get("event_id", "") + + @classmethod + def get_response_id(cls, event: dict) -> Union[str, None]: + if "response" in event: + return event["response"].get("id", None) + if "response_id" in event: + return event["response_id"] + return None + def match(self, event: dict) -> bool: return "type" in event and event["type"] == self.value @@ -147,10 +169,10 @@ def _to_openai_event(self, data: dict) -> dict: pass -class ClientSessionUpdate(ClientEvent): +class ClientSessionUpdateEvent(ClientEvent): type = ClientEventType.session_update.value - shell_funcs: Dict[str, List[Function]] = Field(default_factory=dict) session: OpenAISessionObj def _to_openai_event(self, data: dict) -> dict: + data['session'] = self.session.model_dump() return data diff --git a/ghostos/prototypes/realtime/openai/states.py b/ghostos/prototypes/realtime/openai/states.py new file mode 100644 index 00000000..199b5216 --- /dev/null +++ b/ghostos/prototypes/realtime/openai/states.py @@ -0,0 +1,458 @@ +from __future__ import annotations +from abc import abstractmethod, ABC +from typing import ( + Self, Literal, List, Optional, Union, Dict, Protocol, ClassVar, Type, Callable, Tuple, + Iterable, +) +from enum import Enum +from ghostos.prototypes.realtime.abcd import ( + ConversationProtocol, + State, + OperationType, Operator, +) +from ghostos.helpers import uuid +from ghostos.contracts.logger import LoggerItf, get_logger +from ghostos.core.messages import Message, MessageType +from ghostos.container import Container +from pydantic import BaseModel, Field +from collections import deque +from .protocols import * +from .conversation import AbsConversation +from .ws import OpenAIWebsocketsConf, OpenAIWSConnection +from .configs import AgentConf +from .broadcast import SimpleBroadcaster, Broadcaster +from .utils import parse_message_to_client_event, parse_server_event_to_message + + +class StateCtx: + + def __init__( + self, + conf: AgentConf, + container: Container, + funcs: Dict[str, List[Function]], + conversation: ConversationProtocol, + broadcaster: Broadcaster, + logger: LoggerItf, + session: Optional[OpenAISessionObj], + connection: Optional[OpenAIWSConnection], + connect_sock: Optional[Callable], + ): + self.conf: AgentConf = conf + self.container = container + self.logger: LoggerItf = logger + self.funcs: Dict[str, List[Function]] = funcs + self.conversation: ConversationProtocol = conversation + self.broadcaster: Broadcaster = broadcaster + self.session: Optional[OpenAISessionObj] = session + self.connection: Optional[OpenAIWSConnection] = connection + self.connect_sock: Optional[Callable] = connect_sock + + def get_session_obj(self) -> Optional[OpenAISessionObj]: + """ + if the created session exists, return it + otherwise try to create a new session from conf. + """ + if self.session is not None: + return self.session + if self.conf.session: + tools = [] + for shell_name, funcs in self.funcs.items(): + for fn in funcs: + name = f"{shell_name}.{fn.name}" + target = fn.with_name(name) + tools.append(target) + obj = self.conf.session.model_copy(deep=True) + obj.tools = tools + return obj + return None + + def recv_from_server_nowait(self) -> Union[dict, None]: + if self.connection is None: + return None + return self.connection.recv(timeout=0) + + def publish_event(self, event: Union[dict, None]): + type_ = ServerEventType.get_type(event) + self.broadcaster.publish(type_, event) + + def send_to_server(self, event: ClientEvent): + if not self.connection: + raise RuntimeError("No connection to send event") + self.connection.send(event.to_openai_event()) + + def messages(self) -> List[Message]: + raise NotImplementedError("todo") + + +class AbsState(State, ABC): + """ + base state class + """ + + prior_ops: ClassVar[Set[OperatorType]] + pending_ops: ClassVar[Set[OperatorType]] + block_ops: ClassVar[Dict[str, OperatorType]] + + include_events: ClassVar[Union[Set[ServerEventType], None]] = None + exclude_events: ClassVar[Union[Set[ServerEventType], None]] = None + + def __init__( + self, + ctx: StateCtx, + ): + self._ctx = ctx + self._op_queue = deque() + self._inited = False + + @abstractmethod + def _on_state_created(self) -> None: + pass + + def _run_operator(self, op: AbsOperator) -> State: + return op.run(self._ctx) + + def conversation(self) -> ConversationProtocol: + return self._ctx.conversation + + def operate(self, op: AbsOperator) -> Tuple[OperationType, Union[str, None]]: + if not self._inited: + self._on_state_created() + + type_ = op.type + if type_ in self.block_ops: + return "blocked", self.block_ops[type_] + elif type_ in self.prior_ops: + self._op_queue.insert(0, op) + return "", None + elif type_ in self.pending_ops: + self._op_queue.append(op) + return "queued", None + else: + return "illegal", "operation not allowed" + + def run_operator(self) -> Union[State, None]: + if not self._inited: + self._inited = True + # block when ticked. + self._on_state_created() + if len(self._op_queue) == 0: + return None + op = self._op_queue.popleft() + return self._run_operator(op) + + def run_server_event(self) -> Union[State, None]: + e = self._ctx.recv_from_server_nowait() + if e is None: + return None + + # ignore event + e = self._filter_server_event(e) + if e is None: + return None + + # broadcast first. + self._ctx.publish_event(e) + # add conversation item + self._filter_conversation_message(e) + + # handle server event that should add message to conversation. + return self._handle_server_event(e) + + def _filter_server_event(self, e: dict) -> Union[dict, None]: + # ignore checks + type_ = ServerEventType.get_type(e) + if self.include_events and type_ not in self.include_events: + # ignore + return None + if self.exclude_events and type_ in self.exclude_events: + return None + return e + + def _filter_conversation_message(self, e: dict) -> None: + # add conversation item. + message = parse_server_event_to_message(e) + if message is not None: + self._ctx.conversation.add(message) + + def _handle_server_event(self, event: dict) -> Union[State, None]: + # handle later + type_ = ServerEventType.get_type(event) + method = f"_on_{type_.name}" + if hasattr(self, method): + _new_state = getattr(self, method)(event) + if _new_state: + return _new_state + # default is ignore + return None + + def _on_response_created(self, event: dict) -> Union[State, None]: + return RespondingState(self._ctx) + + def join(self): + del self._ctx + + +class ConnectingState(AbsState): + """ + create websocket connection + """ + state_name = StateName.connecting + + prior_ops = { + OperatorName.stop, + OperatorName.session_update, + } + block_ops = { + OperatorName.reconnect, + } + include_events = { + ServerEventType.session_created, + } + + def _on_state_created(self): + if self._ctx.connection is not None: + # close the old one + self._ctx.connection.close() + self._ctx.connection = None + + socket = None + if self._ctx.connect_sock: + socket = self._ctx.connect_sock() + # always connect at first. + self._connection = OpenAIWSConnection( + self._ctx.conf.ws_conf, + sock=socket, + logger=self._ctx.logger, + ) + + def _on_session_created(self, event: dict) -> Union[State, None]: + e = ServerSessionCreated(**event) + + # align the session + session_obj = self._ctx.get_session_obj() + # shall update session + if session_obj: + # update session + send = ClientSessionUpdateEvent(session=session_obj) + self._ctx.send_to_server(send) + # update conversation + messages = self._ctx.messages() + for item in messages: + e = parse_message_to_client_event(item) + if e is not None: + self._ctx.send_to_server(e) + + # new state. + return SessionUpdatingState(self._ctx) + else: + # use default session. + self._ctx.session = e.session + return IdleState(self._ctx) + + +class SessionUpdatingState(AbsState): + """ + updating session. + """ + state_name = StateName.session_updating + + prior_ops = { + OperatorName.stop, + OperatorName.reconnect, + + } + pending_ops = { + OperatorName.create_response, + OperatorName.text_input, + OperatorName.function_output, + OperatorName.input_audio, + OperatorName.start_listening, + } + block_ops = { + OperatorName.response_cancel: "not responding", + OperatorName.truncate_listening: "not listening", + OperatorName.session_update: "updating session", + } + + def _on_state_created(self) -> None: + session = self._ctx.get_session_obj() + update = ClientSessionUpdateEvent(session=session) + self._ctx.send_to_server(update) + + +class IdleState(AbsState): + state_name = StateName.idle + + prior_ops = { + OperatorName.stop, + OperatorName.reconnect, + OperatorName.response_cancel, + OperatorName.input_audio, + OperatorName.start_listening, + } + pending_ops = { + OperatorName.create_response, + OperatorName.session_update, + OperatorName.text_input, + OperatorName.function_output, + } + block_ops = { + OperatorName.truncate_listening: "not listening", + } + + def _on_state_created(self) -> None: + return None + + +class RespondingState(AbsState): + state_name = StateName.responding + + prior_ops = { + OperatorName.stop, + OperatorName.reconnect, + OperatorName.response_cancel, + OperatorName.input_audio, + OperatorName.start_listening, + } + pending_ops = { + OperatorName.session_update, + OperatorName.text_input, + OperatorName.function_output, + } + block_ops = { + OperatorName.create_response: "responding", + OperatorName.truncate_listening: "not listening", + } + + def __init__(self, ctx: StateCtx, response_id: str): + super().__init__(ctx) + self._response_id = response_id + + def _on_state_created(self): + return + + +class StoppedState(AbsState): + state_name = StateName.stopped + prior_ops = {} + pending_ops = {} + block_ops = { + OperatorName.stop: "is stopped" + } + + def _on_state_created(self) -> None: + return None + + def join(self): + if self._ctx.connection is not None: + self._ctx.connection.close() + + +class ListeningState(AbsState): + prior_ops = { + OperatorName.stop, + OperatorName.reconnect, + OperatorName.create_response, + OperatorName.truncate_listening, + OperatorName.function_output, + } + pending_ops = { + OperatorName.session_update, + OperatorName.text_input, + } + block_ops = { + OperatorName.input_audio: "is listening", + OperatorName.response_cancel: "listening", + OperatorName.start_listening: "is listening", + } + + def _on_state_created(self) -> None: + pass + + +class FailedState(AbsState): + prior_ops = { + OperatorName.stop, + OperatorName.reconnect, + } + pending_ops = set() + block_ops = { + OperatorName.response_cancel: "failed, reconnect or stop", + OperatorName.input_audio: "failed, reconnect or stop", + OperatorName.start_listening: "failed, reconnect or stop", + OperatorName.session_update: "failed, reconnect or stop", + OperatorName.text_input: "failed, reconnect or stop", + OperatorName.function_output: "failed, reconnect or stop", + OperatorName.create_response: "failed, reconnect or stop", + OperatorName.truncate_listening: "failed, reconnect or stop", + } + include_events = { + ServerEventType.error, + } + + def _on_state_created(self) -> None: + if self._ctx.connection is not None: + self._ctx.connection.close() + + +# --- operators --- # + +class OperatorType(str, Enum): + stop = "stop" + reconnect = "reconnect" + function_output = "function_output" + update_session = "update_session" + + +class AbsOperator(Operator): + + def on_accept(self, ctx: StateCtx): + return + + @abstractmethod + def run(self, ctx: StateCtx) -> Union[State, None]: + """ + default action of the operator. + """ + pass + + +class Stop(AbsOperator): + type = OperatorType.stop + + def run(self, ctx: StateCtx) -> Union[State, None]: + return StoppedState(ctx) + + +class Reconnect(AbsOperator): + type = OperatorType.reconnect + + def run(self, ctx: StateCtx) -> Union[State, None]: + return ConnectingState(ctx) + + +class FunctionOutput(AbsOperator): + type = OperatorType.function_output + call_id: str + output: str + + def on_accept(self, ctx: StateCtx): + # message = MessageType.FUNCTION_OUTPUT.new( + # role="", + # ) + pass + + def run(self, ctx: StateCtx) -> Union[State, None]: + pass + + +class UpdateSession(Operator): + type = OperatorType.update_session + + instruction: Optional[str] = Field( + default=None, + description="Instruction of the session", + ) + tool_choice: str = Field(default="auto") + tools: List[Function] = Field(default_factory=list) diff --git a/ghostos/prototypes/realtime/openai/utils.py b/ghostos/prototypes/realtime/openai/utils.py new file mode 100644 index 00000000..72384142 --- /dev/null +++ b/ghostos/prototypes/realtime/openai/utils.py @@ -0,0 +1,15 @@ +from typing import Union +from ghostos.core.messages import Message +from .protocols import ClientEvent + +__all__ = ['parse_message_to_client_event', 'parse_server_event_to_message'] + + +def parse_message_to_client_event(message: Message) -> Union[ClientEvent, None]: + # raise NotImplementedError("todo") + return None + + +def parse_server_event_to_message(event: dict) -> Union[Message, None]: + # raise NotImplementedError("todo") + return None diff --git a/evaluation/swe_bench_lite/tools/__init__.py b/ghostos/prototypes/realtime_console/__init__.py similarity index 100% rename from evaluation/swe_bench_lite/tools/__init__.py rename to ghostos/prototypes/realtime_console/__init__.py diff --git a/ghostos/prototypes/streamlitapp/widgets.py b/ghostos/prototypes/streamlitapp/widgets.py index e6edb9d3..baadba1f 100644 --- a/ghostos/prototypes/streamlitapp/widgets.py +++ b/ghostos/prototypes/streamlitapp/widgets.py @@ -2,7 +2,7 @@ from typing import List, Union, Dict, Iterable, Tuple, Optional import streamlit_antd_components as sac from ghostos.core.aifunc import ExecFrame, ExecStep -from ghostos.core.messages import Message, Role, DefaultMessageTypes +from ghostos.core.messages import Message, Role, MessageType from ghostos.core.moss import PyContext from ghostos.prototypes.streamlitapp.utils.route import Router from ghostos.prototypes.streamlitapp.utils.session import Singleton @@ -54,7 +54,7 @@ def render_messages(messages: Iterable[Message]): def render_message(msg: Message, debug: bool): if not msg.is_complete(): return - if DefaultMessageTypes.ERROR.match(msg): + if MessageType.ERROR.match(msg): with st.chat_message("user"): st.caption(_("Error")) st.error(msg.get_content()) diff --git a/ghostos/scripts/aifunc_test.py b/ghostos/scripts/aifunc_test.py index 89fd6b8c..83d4d9e1 100644 --- a/ghostos/scripts/aifunc_test.py +++ b/ghostos/scripts/aifunc_test.py @@ -4,9 +4,9 @@ import yaml from typing import List, Dict -from ghostos.core.session import MsgThread +from ghostos.core.session import GoThreadInfo from ghostos.scripts.logconf import prepare_logger -from ghostos.core.llms import Chat +from ghostos.core.llms import Prompt from ghostos.core.messages import Message from ghostos.core.moss import test_container from ghostos.core.aifunc import DefaultAIFuncExecutorImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncExecutor @@ -100,7 +100,7 @@ def on_message(self, message: Message) -> None: if value != "y": exit(0) - def on_chat(self, chat: Chat) -> None: + def on_chat(self, chat: Prompt) -> None: for message in chat.get_messages(): self.console.print(Panel( Markdown(message.get_content()), @@ -114,7 +114,7 @@ def on_chat(self, chat: Chat) -> None: def on_system_messages(self, messages: List[Message]) -> None: pass - def on_save(self, manager: AIFuncExecutor, thread: MsgThread) -> None: + def on_save(self, manager: AIFuncExecutor, thread: GoThreadInfo) -> None: current = thread.current if current: for message in current.messages(): diff --git a/ghostos/scripts/swe_test.py b/ghostos/scripts/swe_test.py index 6a1d3303..e966cfc2 100644 --- a/ghostos/scripts/swe_test.py +++ b/ghostos/scripts/swe_test.py @@ -4,9 +4,9 @@ import yaml from typing import List, Dict -from ghostos.core.session import MsgThread +from ghostos.core.session import GoThreadInfo from ghostos.scripts.logconf import prepare_logger -from ghostos.core.llms import Chat +from ghostos.core.llms import Prompt from ghostos.core.messages import Message from ghostos.core.moss import test_container from ghostos.core.aifunc import DefaultAIFuncExecutorImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncExecutor @@ -88,7 +88,7 @@ def on_message(self, message: Message) -> None: if value != "y": exit(0) - def on_chat(self, chat: Chat) -> None: + def on_chat(self, chat: Prompt) -> None: for message in chat.get_messages(): self.console.print(Panel( Markdown(message.get_content()), @@ -102,7 +102,7 @@ def on_chat(self, chat: Chat) -> None: def on_system_messages(self, messages: List[Message]) -> None: pass - def on_save(self, manager: AIFuncExecutor, thread: MsgThread) -> None: + def on_save(self, manager: AIFuncExecutor, thread: GoThreadInfo) -> None: current = thread.current if current: for message in current.messages(): diff --git a/ghostos/thoughts/basic.py b/ghostos/thoughts/basic.py index ff83daa8..331e0c79 100644 --- a/ghostos/thoughts/basic.py +++ b/ghostos/thoughts/basic.py @@ -2,10 +2,10 @@ from abc import ABC, abstractmethod from ghostos.core.session import Event, thread_to_chat from ghostos.core.ghosts import Ghost, Operator, Action -from ghostos.core.llms import LLMApi, ChatPreparer, Chat, prepare_chat +from ghostos.core.llms import LLMApi, PromptPipe, Prompt, run_prompt_pipeline from ghostos.core.messages import Role from ghostos.core.ghosts.thoughts import T, BasicThoughtDriver -from ghostos.framework.chatpreparers import OtherAgentOrTaskPreparer +from ghostos.framework.chatpreparers import OtherAgentOrTaskPipe class LLMThoughtDriver(Generic[T], BasicThoughtDriver[T], ABC): @@ -21,12 +21,12 @@ def get_llmapi(self, g: Ghost) -> LLMApi: """ pass - def chat_preparers(self, g: Ghost, e: Event) -> Iterable[ChatPreparer]: + def chat_preparers(self, g: Ghost, e: Event) -> Iterable[PromptPipe]: """ return chat preparers that filter chat messages by many rules. """ assistant_name = g.identifier().name - yield OtherAgentOrTaskPreparer( + yield OtherAgentOrTaskPipe( assistant_name=assistant_name, task_id=g.session().task().task_id, ) @@ -45,7 +45,7 @@ def instruction(self, g: Ghost, e: Event) -> str: """ pass - def initialize_chat(self, g: Ghost, e: Event) -> Chat: + def initialize_chat(self, g: Ghost, e: Event) -> Prompt: session = g.session() thread = session.thread() system_prompt = g.system_prompt() @@ -53,7 +53,7 @@ def initialize_chat(self, g: Ghost, e: Event) -> Chat: content = "\n\n".join([system_prompt, thought_instruction]) # system prompt from thought system_messages = [Role.SYSTEM.new(content=content.strip())] - chat = thread_to_chat(e.id, system_messages, thread) + chat = thread_to_chat(e.event_id, system_messages, thread) return chat def think(self, g: Ghost, e: Event) -> Optional[Operator]: @@ -66,7 +66,7 @@ def think(self, g: Ghost, e: Event) -> Optional[Operator]: # prepare chat, filter messages. preparers = self.chat_preparers(g, e) - chat = prepare_chat(chat, preparers) + chat = run_prompt_pipeline(chat, preparers) # prepare actions actions = list(self.actions(g, e)) @@ -74,7 +74,7 @@ def think(self, g: Ghost, e: Event) -> Optional[Operator]: # prepare chat by actions for action in actions: - chat = action.prepare_chat(chat) + chat = action.process(chat) # prepare llm api llm_api = self.get_llmapi(g) diff --git a/ghostos/thoughts/directory_editor_thought.py b/ghostos/thoughts/directory_editor_thought.py index e925186d..1e3134a4 100644 --- a/ghostos/thoughts/directory_editor_thought.py +++ b/ghostos/thoughts/directory_editor_thought.py @@ -35,7 +35,7 @@ class Moss(Parent): """ -# +# # the codes between the moss xml marks are not visible to LLM from ghostos.libraries.file_editor import DirectoryEditorImpl @@ -43,7 +43,7 @@ class Moss(Parent): # using TYPE_CHECKING to avoid reflect invalid importing to prompt. if TYPE_CHECKING: from ghostos.core.ghosts import Ghost - from ghostos.core.session import Event, Session, MsgThread + from ghostos.core.session import Event, Session, GoThreadInfo from ghostos.core.llms import LLMApi from ghostos.core.moss import MossCompiler @@ -128,7 +128,7 @@ def __magic_moss_thought_instruction__(thought: DirectoryEditorThought, g: "Ghos return instruction -def __magic_moss_thought_thread__(thought: DirectoryEditorThought, session: "Session", thread: "MsgThread") -> "MsgThread": +def __magic_moss_thought_thread__(thought: DirectoryEditorThought, session: "Session", thread: "GoThreadInfo") -> "GoThreadInfo": """ optional magic function that prepare the thread info, such as modify thread.save_file """ @@ -137,4 +137,4 @@ def __magic_moss_thought_thread__(thought: DirectoryEditorThought, session: "Ses thread.save_file = join(thought.directory, ".directory_editor_thought.thread.yml") return thread -# +# diff --git a/ghostos/thoughts/file_editor_thought.py b/ghostos/thoughts/file_editor_thought.py index ab365c6b..3af53428 100644 --- a/ghostos/thoughts/file_editor_thought.py +++ b/ghostos/thoughts/file_editor_thought.py @@ -1,7 +1,7 @@ from ghostos.core.ghosts import ModelThought, Ghost from ghostos.core.llms import LLMApi from ghostos.core.moss import PyContext, MossCompiler -from ghostos.core.session import Event, Session, MsgThread +from ghostos.core.session import Event, Session, GoThreadInfo from ghostos.thoughts.moss_thought import BasicMossThoughtDriver, LLMThoughtDriver from ghostos.thoughts import file_editor_moss from ghostos.libraries.file_editor import FileEditorImpl, FileEditor @@ -81,7 +81,7 @@ def prepare_moss_compiler(self, g: Ghost, compiler: MossCompiler) -> MossCompile compiler.register(provide(FileEditor)(lambda c: self.file_editor())) return compiler - def prepare_thread(self, session: Session, thread: MsgThread) -> MsgThread: + def prepare_thread(self, session: Session, thread: GoThreadInfo) -> GoThreadInfo: if self.thought.debug: filepath = self.thought.filepath saving_path = filepath + ".thread.yml" diff --git a/ghostos/thoughts/magic_moss_thought.py b/ghostos/thoughts/magic_moss_thought.py index 9cb1ff66..e57f3dbe 100644 --- a/ghostos/thoughts/magic_moss_thought.py +++ b/ghostos/thoughts/magic_moss_thought.py @@ -7,7 +7,7 @@ from ghostos.core.moss import PyContext, MossCompiler from ghostos.core.ghosts import Ghost, Action from ghostos.core.llms import LLMApi -from ghostos.core.session import Event, Session, MsgThread +from ghostos.core.session import Event, Session, GoThreadInfo from ghostos.container import Provider import inspect from pydantic import Field @@ -27,7 +27,7 @@ class MagicMossThought(ModelThought, ABC): debug: bool = Field(default=False, description="if the debug mode is on") -# +# def __magic_moss_thought_instruction__(thought: MagicMossThought, g: "Ghost", e: "Event") -> str: """ @@ -79,7 +79,7 @@ def __magic_moss_thought_compiling__(thought: MagicMossThought, g: "Ghost", comp return compiler -def __magic_moss_thought_thread__(thought: MagicMossThought, session: Session, thread: MsgThread) -> MsgThread: +def __magic_moss_thought_thread__(thought: MagicMossThought, session: Session, thread: GoThreadInfo) -> GoThreadInfo: """ optional magic function that prepare the thread info, such as modify thread.save_file :param thought: @@ -100,7 +100,7 @@ def __on_inputs__(driver: MagicMossThoughtDriver, g: Ghost, e: Event) -> Optiona pass -# +# class MagicMossThoughtDriver(LLMThoughtDriver[MagicMossThought], BasicMossThoughtDriver): @@ -135,7 +135,7 @@ def prepare_moss_compiler(self, g: Ghost, compiler: MossCompiler) -> MossCompile fn = __magic_moss_thought_compiling__ return fn(self.thought, g, compiler) - def prepare_thread(self, session: Session, thread: MsgThread) -> MsgThread: + def prepare_thread(self, session: Session, thread: GoThreadInfo) -> GoThreadInfo: thread = super().prepare_thread(session, thread) fn = self.get_magic_func_of_the_module(__magic_moss_thought_thread__.__name__) if fn is None: diff --git a/ghostos/thoughts/moss_thought.py b/ghostos/thoughts/moss_thought.py index 04bdac93..e6a8835a 100644 --- a/ghostos/thoughts/moss_thought.py +++ b/ghostos/thoughts/moss_thought.py @@ -3,7 +3,7 @@ from ghostos.core.ghosts import Ghost, Action, ModelThought, Operator from ghostos.core.llms import LLMApi -from ghostos.core.session import Event, MsgThread +from ghostos.core.session import Event, GoThreadInfo from ghostos.core.moss import MossCompiler, MossRuntime, PyContext from ghostos.thoughts.basic import LLMThoughtDriver from ghostos.framework.actions import MossAction @@ -53,7 +53,7 @@ def get_moss_runtime(self, g: Ghost) -> MossRuntime: default_pycontext = self.init_pycontext() compiler = compiler.join_context(default_pycontext) # bind msg thread - compiler.bind(MsgThread, thread) + compiler.bind(GoThreadInfo, thread) # join thread pycontext = thread.get_pycontext() compiler = compiler.join_context(pycontext) diff --git a/ghostos/thoughts/pymodule_editor.py b/ghostos/thoughts/pymodule_editor.py index 12792a50..c9026d0f 100644 --- a/ghostos/thoughts/pymodule_editor.py +++ b/ghostos/thoughts/pymodule_editor.py @@ -3,7 +3,7 @@ from ghostos.core.ghosts import ModelThought, Ghost from ghostos.core.llms import LLMApi from ghostos.core.moss import PyContext, MossCompiler -from ghostos.core.session import Event, Session, MsgThread +from ghostos.core.session import Event, Session, GoThreadInfo from ghostos.thoughts.basic import LLMThoughtDriver from ghostos.thoughts.moss_thought import BasicMossThoughtDriver from ghostos.thoughts import pymodule_editor_moss @@ -76,7 +76,7 @@ def new_task_id(self, g: Ghost) -> str: # task_id in a same process will always be the same return md5(task_id) - def prepare_thread(self, session: Session, thread: MsgThread) -> MsgThread: + def prepare_thread(self, session: Session, thread: GoThreadInfo) -> GoThreadInfo: """ save the thread where I'm convenient to see it :param session: diff --git a/pyproject.toml b/pyproject.toml index 57790fda..7936640e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ python-dotenv = "^1.0.1" babel = "^2.16.0" websockets = "^13.1" pysocks = "^1.7.1" +requests = {extras = ["socks"], version = "^2.32.3"} [tool.poetry.scripts] init = "ghostos.scripts.init:main" diff --git a/tests/framework/eventbuses/test_mem_impl.py b/tests/framework/eventbuses/test_mem_impl.py index bd007eb7..b4eaeeaa 100644 --- a/tests/framework/eventbuses/test_mem_impl.py +++ b/tests/framework/eventbuses/test_mem_impl.py @@ -1,10 +1,10 @@ from ghostos.framework.eventbuses.memimpl import MemEventBusImpl -from ghostos.core.session.events import DefaultEventType +from ghostos.core.session.events import EventTypes def test_mem_impl_send_pop_event(): bus = MemEventBusImpl() - e = DefaultEventType.INPUT.new("foo", []) + e = EventTypes.REQUEST.new("foo", []) bus.send_event(e, notify=True) task_id = bus.pop_task_notification() assert task_id is not None diff --git a/tests/framework/messenger/test_messenger.py b/tests/framework/messenger/test_messenger.py index 32185e83..540bcd38 100644 --- a/tests/framework/messenger/test_messenger.py +++ b/tests/framework/messenger/test_messenger.py @@ -1,12 +1,12 @@ from ghostos.framework.messengers import Messenger, DefaultMessenger from ghostos.framework.streams import EmptyStream -from ghostos.core.session.threads import MsgThread +from ghostos.core.session.threads import GoThreadInfo from ghostos.core.messages import Message from ghostos.core.llms import FunctionalToken def test_default_messenger_baseline(): - thread = MsgThread() + thread = GoThreadInfo() messenger = DefaultMessenger(thread=thread) content = "hello world" for c in content: @@ -14,8 +14,8 @@ def test_default_messenger_baseline(): success = messenger.deliver(msg) assert success messenger.flush() - assert len(thread.current.generates) == 1 - assert thread.current.generates[0].content == content + assert len(thread.current.added) == 1 + assert thread.current.added[0].content == content def test_messenger_with_random_token(): @@ -26,7 +26,7 @@ def test_messenger_with_random_token(): visible=False, )] - thread = MsgThread() + thread = GoThreadInfo() messenger = DefaultMessenger(thread=thread, functional_tokens=functional_tokens) contents = ["he", "llo >mo", "ss: w", "orld"] @@ -43,8 +43,8 @@ def test_messenger_with_random_token(): assert caller.name == "moss" assert caller.arguments == " world" - assert len(thread.last_turn().generates) == 1 - assert len(thread.last_turn().generates[0].callers) == 1 + assert len(thread.last_turn().added) == 1 + assert len(thread.last_turn().added[0].callers) == 1 def test_messenger_with_single_message(): @@ -56,7 +56,7 @@ def test_messenger_with_single_message(): visible=False, )] - thread = MsgThread() + thread = GoThreadInfo() messenger = DefaultMessenger(thread=thread, functional_tokens=functional_tokens) content = "def main():\n pass" @@ -76,7 +76,7 @@ def test_messenger_with_func_token_visible(): visible=True, )] - thread = MsgThread() + thread = GoThreadInfo() messenger = DefaultMessenger( thread=thread, functional_tokens=functional_tokens, diff --git a/tests/framework/streams/test_arr_connection.py b/tests/framework/streams/test_arr_connection.py index 4559a09c..d34f959b 100644 --- a/tests/framework/streams/test_arr_connection.py +++ b/tests/framework/streams/test_arr_connection.py @@ -3,7 +3,7 @@ from ghostos.core.messages import Message from ghostos.framework.streams import new_connection, Stream from ghostos.framework.messengers import DefaultMessenger -from ghostos.core.session import MsgThread +from ghostos.core.session import GoThreadInfo from ghostos.core.llms import FunctionalToken from threading import Thread @@ -112,7 +112,7 @@ def test_new_connection_with_messenger_sync(): content = "hello world, ha ha ha ha" with stream: - messenger = DefaultMessenger(upstream=stream, thread=MsgThread()) + messenger = DefaultMessenger(upstream=stream, thread=GoThreadInfo()) with messenger: for c in content: messenger.deliver(Message.new_chunk(content=c)) @@ -131,7 +131,7 @@ def test_new_connection_with_messenger_async(): def send_data(s: Stream): with s: - messenger = DefaultMessenger(upstream=s, thread=MsgThread()) + messenger = DefaultMessenger(upstream=s, thread=GoThreadInfo()) with messenger: for c in content: messenger.deliver(Message.new_chunk(content=c)) @@ -154,7 +154,7 @@ def test_new_connection_with_functional_tokens(): stream, retriever = new_connection(timeout=5, accept_chunks=True) content = "hello worldhello" - msg_thread = MsgThread() + msg_thread = GoThreadInfo() def send_data(s: Stream): with s: @@ -188,5 +188,5 @@ def send_data(s: Stream): assert len(messages) == 1 assert len(messages[0].callers) == 1 assert messages[0].callers[0].arguments == "hello" - assert len(msg_thread.last_turn().generates[0].callers) == 1 + assert len(msg_thread.last_turn().added[0].callers) == 1 t.join() diff --git a/tests/framework/tasks/test_storage_impl.py b/tests/framework/tasks/test_storage_impl.py index 1dde32c0..4f49a0a5 100644 --- a/tests/framework/tasks/test_storage_impl.py +++ b/tests/framework/tasks/test_storage_impl.py @@ -1,14 +1,14 @@ from ghostos.framework.storage import MemStorage -from ghostos.framework.tasks.storage_tasks import StorageTaskRepoImpl +from ghostos.framework.tasks.storage_tasks import StorageGoTasksImpl from ghostos.framework.logger import FakeLogger -from ghostos.core.session import Task +from ghostos.core.session import GoTaskStruct from ghostos.entity import EntityMeta def test_storage_tasks_impl(): storage = MemStorage() - tasks = StorageTaskRepoImpl(storage, FakeLogger()) - task = Task.new( + tasks = StorageGoTasksImpl(storage, FakeLogger()) + task = GoTaskStruct.new( task_id="task_id", session_id="session_id", process_id="process_id", diff --git a/tests/python/test_class.py b/tests/python/test_class.py index 7f3f13a7..071b3ef1 100644 --- a/tests/python/test_class.py +++ b/tests/python/test_class.py @@ -210,7 +210,25 @@ def test_generic_class_is_same(): class Foo(Generic[T]): def __init__(self, val: T): self.val = val + assert Foo[int] is Foo[int] obj1 = Foo[int](1) obj2 = Foo[int](2) assert type(obj1) is type(obj2) + + +def test_protocol_and_abc(): + from abc import ABC + from typing import Protocol + + class _Foo(Protocol): + foo = 1 + + class _Bar(_Foo, ABC): + pass + + class _Baz(_Bar): + pass + + b = _Baz() + assert b.foo == 1 diff --git a/tests/python/test_func.py b/tests/python/test_func.py index bffd322c..bb74ef16 100644 --- a/tests/python/test_func.py +++ b/tests/python/test_func.py @@ -1,3 +1,11 @@ def test_func_set_attr(): setattr(test_func_set_attr, "__test__", "test") assert test_func_set_attr.__test__ == "test" + + +def test_func_args(): + def foo(bar: int, baz: str, *args, **kwargs) -> bool: + pass + + assert foo.__annotations__['bar'] is int + assert foo.__annotations__['return'] is bool diff --git a/tests/python/test_inspect.py b/tests/python/test_inspect.py index ebc9c926..dff46aa4 100644 --- a/tests/python/test_inspect.py +++ b/tests/python/test_inspect.py @@ -149,3 +149,21 @@ class Child(Parent): source = inspect.getsource(Child) assert "foo" not in source + + +class SomeClass: + foo: int = 123 + + __add_info: str = "" + + +class SubClass(SomeClass): + bar: int = 456 + + +SubClass.__add_info = "test" + + +def test_getsource_without_added_code(): + code = inspect.getsource(SubClass) + assert "__add_info" not in code diff --git a/tests/python/test_pydantic.py b/tests/python/test_pydantic.py index 56f939e0..9846b945 100644 --- a/tests/python/test_pydantic.py +++ b/tests/python/test_pydantic.py @@ -146,3 +146,21 @@ class Foo(BaseModel): f = Foo(foo="test".encode()) assert f.foo.decode() == "test" + + +def test_multi_type_attr(): + class Foo(BaseModel): + foo: int = 0 + + class Bar(BaseModel): + bar: str = "" + + class Baz(BaseModel): + baz: List[BaseModel] + + b = Baz(baz=[Foo(), Bar()]) + data = b.model_dump(serialize_as_any=True) + assert data == {"baz": [{"foo": 0}, {"bar": ""}]} + + unmarshalled = Baz(**data) + assert not isinstance(unmarshalled.baz[0], Foo) diff --git a/tests/test_abc.py b/tests/test_abc.py index c4baed62..e40ee152 100644 --- a/tests/test_abc.py +++ b/tests/test_abc.py @@ -1,7 +1,7 @@ -from ghostos.common import PromptAble, PromptAbleClass +from ghostos.common import Prompter, PrompterClass import inspect def test_is_abstract(): - assert inspect.isabstract(PromptAble) - assert inspect.isabstract(PromptAbleClass) + assert inspect.isabstract(Prompter) + assert inspect.isabstract(PrompterClass) From e4cc3d16cec65c8d1bdd665b995b08123767adb0 Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 8 Nov 2024 17:19:44 +0800 Subject: [PATCH 059/148] dev: saving processing mass refactoring --- ghostos/common.py | 19 +- ghostos/core/abcd/__init__.py | 2 + ghostos/core/abcd/concepts.py | 627 ++++++++++++++++++ ghostos/core/abcd/ghostos.py | 335 ---------- ghostos/core/abcd/ghosts.py | 8 +- ghostos/core/abcd/moss_agent.py | 170 ----- ghostos/core/abcd/utils.py | 101 ++- ghostos/core/agents/agent.py | 62 -- ghostos/core/agents/moss_agent.py | 133 ++++ ghostos/core/agents/utils.py | 33 + ghostos/core/aifunc/driver.py | 2 +- ghostos/core/aifunc/interfaces.py | 2 +- ghostos/core/ghost_dev_pattern/__init__.py | 0 ghostos/core/ghost_dev_pattern/concepts.py | 196 ------ ghostos/core/ghost_dev_pattern/ghost.py | 105 --- ghostos/core/ghost_dev_pattern/runtime.py | 62 -- ghostos/core/ghost_dev_pattern/template.py | 28 - ghostos/core/ghost_dev_pattern/think.py | 2 - ghostos/core/ghost_dev_pattern/thoughts.py | 59 -- ghostos/core/ghost_dev_pattern/variables.py | 22 - ghostos/core/ghostos.py | 2 +- ghostos/core/ghostos2.py | 2 +- ghostos/core/ghosts/actions.py | 2 +- ghostos/core/ghosts/ghost.py | 2 +- ghostos/core/ghosts/operators.py | 2 +- ghostos/core/ghosts/thoughts.py | 2 +- ghostos/core/ghosts/utils.py | 4 +- ghostos/core/moss/__init__.py | 9 +- ghostos/core/moss/abc.py | 49 +- ghostos/core/moss/examples/baseline.py | 4 +- ghostos/core/moss/impl.py | 8 +- ghostos/core/moss/lifecycle.py | 8 +- ghostos/core/moss/pycontext.py | 8 +- ghostos/core/moss/test_suites.py | 8 +- ghostos/core/runtime/__init__.py | 10 + ghostos/core/{session => runtime}/events.py | 13 +- .../core/{session => runtime}/messenger.py | 0 .../core/{session => runtime}/processes.py | 0 ghostos/core/runtime/runtime.py | 27 + .../{session => runtime}/simple_thread.py | 2 +- ghostos/core/{session => runtime}/tasks.py | 20 +- ghostos/core/{session => runtime}/threads.py | 2 +- ghostos/core/session/__init__.py | 10 - ghostos/core/session/session.py | 255 ------- .../src/examples/moss_codes/run_test_suite.py | 4 +- ghostos/framework/actions/moss_action.py | 2 +- .../chatpreparers/assistant_preparer.py | 2 +- ghostos/framework/eventbuses/__init__.py | 2 +- ghostos/framework/eventbuses/memimpl.py | 4 +- ghostos/framework/ghostos/basic.py | 2 +- ghostos/framework/ghostos/demo_os.py | 2 +- ghostos/framework/ghosts/basic.py | 4 +- ghostos/framework/ghosts/demo.py | 2 +- ghostos/framework/messages/__init__.py | 2 +- ghostos/framework/messengers/__init__.py | 2 +- ghostos/framework/messengers/defaults.py | 4 +- ghostos/framework/multitasks/basic.py | 2 +- ghostos/framework/operators/action_ops.py | 2 +- ghostos/framework/operators/event_ops.py | 2 +- ghostos/framework/processes/__init__.py | 2 +- .../framework/processes/storage_processes.py | 4 +- ghostos/framework/repliers/basic.py | 2 +- ghostos/framework/session/basic.py | 2 +- ghostos/framework/tasks/__init__.py | 2 +- ghostos/framework/tasks/storage_tasks.py | 2 +- ghostos/framework/threads/__init__.py | 2 +- ghostos/framework/threads/storage_threads.py | 2 +- ghostos/helpers/__init__.py | 2 +- ghostos/prototypes/aifunc/app.py | 2 +- ghostos/prototypes/ghostfunc/driver.py | 2 +- ghostos/scripts/aifunc_test.py | 2 +- ghostos/scripts/swe_test.py | 141 ---- ghostos/thoughts/basic.py | 2 +- ghostos/thoughts/chat.py | 2 +- ghostos/thoughts/directory_editor_thought.py | 2 +- ghostos/thoughts/file_editor_thought.py | 2 +- ghostos/thoughts/magic_moss_thought.py | 2 +- ghostos/thoughts/moss_thought.py | 2 +- ghostos/thoughts/pymodule_editor.py | 2 +- tests/framework/eventbuses/test_mem_impl.py | 2 +- tests/framework/messenger/test_messenger.py | 2 +- .../framework/streams/test_arr_connection.py | 2 +- tests/framework/tasks/test_storage_impl.py | 4 +- tests/python/test_dict.py | 16 + 84 files changed, 1061 insertions(+), 1601 deletions(-) create mode 100644 ghostos/core/abcd/concepts.py delete mode 100644 ghostos/core/abcd/ghostos.py delete mode 100644 ghostos/core/abcd/moss_agent.py delete mode 100644 ghostos/core/agents/agent.py create mode 100644 ghostos/core/agents/moss_agent.py create mode 100644 ghostos/core/agents/utils.py delete mode 100644 ghostos/core/ghost_dev_pattern/__init__.py delete mode 100644 ghostos/core/ghost_dev_pattern/concepts.py delete mode 100644 ghostos/core/ghost_dev_pattern/ghost.py delete mode 100644 ghostos/core/ghost_dev_pattern/runtime.py delete mode 100644 ghostos/core/ghost_dev_pattern/template.py delete mode 100644 ghostos/core/ghost_dev_pattern/think.py delete mode 100644 ghostos/core/ghost_dev_pattern/thoughts.py delete mode 100644 ghostos/core/ghost_dev_pattern/variables.py create mode 100644 ghostos/core/runtime/__init__.py rename ghostos/core/{session => runtime}/events.py (97%) rename ghostos/core/{session => runtime}/messenger.py (100%) rename ghostos/core/{session => runtime}/processes.py (100%) create mode 100644 ghostos/core/runtime/runtime.py rename ghostos/core/{session => runtime}/simple_thread.py (97%) rename ghostos/core/{session => runtime}/tasks.py (94%) rename ghostos/core/{session => runtime}/threads.py (99%) delete mode 100644 ghostos/core/session/__init__.py delete mode 100644 ghostos/core/session/session.py delete mode 100644 ghostos/scripts/swe_test.py diff --git a/ghostos/common.py b/ghostos/common.py index ab8d5770..4ef5fa12 100644 --- a/ghostos/common.py +++ b/ghostos/common.py @@ -11,15 +11,17 @@ import pickle __all__ = [ - 'Identifier', 'Identifiable', 'get_identifier', - 'get_defined_prompt', - 'to_entity_meta', 'from_entity_meta', 'get_entity', - 'EntityMeta', 'Entity', 'EntityType', + 'get_identifier', + 'Identifier', 'Identifiable', 'Identical', 'IdenticalClass', 'identify_class', 'identify_class_id', 'IdenticalObject', + 'to_entity_meta', 'from_entity_meta', 'get_entity', + 'EntityMeta', 'Entity', 'EntityType', + + 'get_defined_prompt', 'Prompter', 'PrompterClass', ] @@ -270,6 +272,11 @@ def to_entity_meta(value: Union[EntityType, Any]) -> EntityMeta: type=generate_import_path(value.__class__), content=value.model_dump_json(exclude_defaults=True).encode(), ) + elif inspect.isfunction(value): + return EntityMeta( + type=generate_import_path(value), + content=bytes(), + ) else: content = pickle.dumps(value) return EntityMeta( @@ -298,7 +305,9 @@ def from_entity_meta(meta: EntityMeta) -> Any: if issubclass(cls, EntityMeta): return meta - + elif inspect.isfunction(cls): + return cls + # method is prior elif hasattr(cls, "__from_entity_meta__"): return getattr(cls, "__from_entity_meta__")(meta) diff --git a/ghostos/core/abcd/__init__.py b/ghostos/core/abcd/__init__.py index e69de29b..6ae63ca6 100644 --- a/ghostos/core/abcd/__init__.py +++ b/ghostos/core/abcd/__init__.py @@ -0,0 +1,2 @@ +from .concepts import Ghost, GhostDriver, Operator, Session, GhostOS +from .ghosts import Agent diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py new file mode 100644 index 00000000..fc73ee06 --- /dev/null +++ b/ghostos/core/abcd/concepts.py @@ -0,0 +1,627 @@ +from __future__ import annotations +from typing import ( + Type, Generic, Protocol, ClassVar, TypeVar, + Tuple, Optional, Iterable, List, Self, Union, Dict, +) + +from abc import ABC, abstractmethod +from ghostos.common import Identifiable, Entity, EntityType, EntityMeta, to_entity_meta, from_entity_meta +from ghostos.core.runtime import ( + TaskState, +) +from ghostos.core.runtime.events import Event +from ghostos.core.runtime.tasks import GoTaskStruct, TaskBrief +from ghostos.core.runtime.threads import GoThreadInfo +from ghostos.core.messages import MessageKind, Message, Stream, Caller, Payload +from ghostos.container import Container +from ghostos.helpers import generate_import_path +from pydantic import BaseModel +import json + +""" +# Core Concepts of GhostOS framework. + +The word `Ghost` is picked from `Ghost In the Shell` movie. +The Ghost can perform as both conversational object or an async function. +Ghost is the abstract of atomic state machine unit in the GhostOS. + +for example, llm-based `Agent` is a state machine, an implementation of Ghost in GhostOS. + +Why Agent is a state machine? +1. Agent receives an event at a time, not parallel, or face brain split. +2. Agent keep it state in the system prompt and messages, by nature language. +3. Agent take actions that matching expectation. +So Agent is an AI-State-Machine, defined from prompt, not code; executed by Model, not Interpreter. + +About the Ghost Abstract: +1. it is a class. +2. the ghost class can construct ghost instance. +3. any ghost instance can run as a conversational task +4. a conversational task runs in turns, receiving event and replying messages. +5. the conversational task is stateful, accept one event at a time. +6. the conversational task reach the end when it is canceled, done or failed +7. all the ghost has a Goal model to describe its current achievement. +8. The Ghost Class shall be simple and clear to the AI models, when they are creating ghosts themselves. + +and the Most valuable features about ghost are: +1. ghosts shall be fractal, can be called by other ghosts. +2. ghost shall be defined by code, which can be generated by meta-agents. +""" + +__all__ = ("Ghost", "Session", "GhostDriver", "Props", "GhostOS", "Operator", "StateValue") + + +class Ghost(Identifiable, EntityType, ABC): + """ + the class defines the model of a kind of ghosts. + four parts included: + 1. configuration of the Ghost, which is Ghost.__init__. we can predefine many ghost instance for special scenes. + 2. context is always passed by the Caller of a ghost instance. each ghost class has it defined context model. + 3. goal is the static output (other than conversation messages) of a ghost instance. + 4. driver is + """ + + Props: ClassVar[Union[Type[Props], None]] + """ props is the model of properties that passed from caller, and alternative during runtime""" + + Artifact: ClassVar[Union[Type, None]] + """ the model of the ghost's artifact, is completing during runtime""" + + Driver: Type[GhostDriver] = None + """ separate ghost's methods to the driver class, make sure the ghost is simple and clear to other ghost""" + + +G = TypeVar("G", bound=Ghost) + + +class GhostDriver(Generic[G], ABC): + """ + Ghost class is supposed to be a data class without complex methods definitions. + so it seems much clear when prompt to the LLM or user-level developer. + when LLM is creating a ghost class or instance, we expect it only see the code we want it to see, + without knowing the details codes of it, for safety / fewer tokens / more focus or other reasons. + + so the methods of the ghost class defined in this class. + only core developers should know details about it. + """ + + def __init__(self, ghost: G) -> None: + self.ghost = ghost + + @abstractmethod + def get_goal(self, session: Session) -> Optional[G.Artifact]: + """ + generate the ghost goal from session_state + may be the Goal Model is a SessionStateValue that bind to it. + + The AI behind a ghost is not supposed to operate the session object, + but work on the goal through functions or Moss Injections. + """ + pass + + @abstractmethod + def on_event(self, session: Session, event: Event) -> Union[Operator, None]: + """ + all the state machine is only handling session event with the predefined operators. + """ + pass + + +class Operator(Protocol): + """ + Operator to operating the GhostOS through the Session encapsulation. + + The Operator is just like the primitives of any coding language. + for example, GhostOS have some operators work like python's `return`, `yield`, `await` . + + I'm not capable to develop a real OS or a new coding language for AI, + GhostOS is built above python with the additional complexities. + + Operators should be predefined, offer to user-level developer, or AI-models. + """ + + @abstractmethod + def run(self, session: Session) -> Union[Operator, None]: + """ + :return: None means stop the loop, otherwise keep going. + + operator returns an operator is a way to encapsulate repetitive codes. + """ + pass + + @abstractmethod + def destroy(self): + """ + Python gc is not trust-worthy + Especially A keep B, B keep C, C keep A, father and child keep each other. + I prefer to del the object attributes in the end of the object lifecycle. + """ + pass + + +class Props(Payload, Entity, ABC): + """ + is strong-typed model for runtime alternative properties of a ghost. + """ + key = "ghost_props" + """props is also a Payload class, which can be bound to event or messages""" + + __children__: List[Props] + """ children is fractal sub context nodes""" + + def with_children(self, *children: Props) -> Props: + self.__children__.extend(children) + return self + + @abstractmethod + def self_prompt(self, container: Container, depth: int = 0) -> str: + """ + generate prompt by self, without children + :param container: + :param depth: + :return: + """ + pass + + def get_prompt(self, container: Container, depth: int = 0) -> str: + self_prompt = self.self_prompt(container, depth=depth) + prompts = [self_prompt] + for child in self.__children__: + prompts.append(child.get_prompt(container, depth=depth + 1)) + return "\n\n".join([prompt.rstrip() for prompt in prompts]) + + def __to_entity_meta__(self) -> EntityMeta: + type_ = generate_import_path(self.__class__) + ctx_data = self.model_dump(exclude_defaults=True) + children_data = [] + for child in self.__children__: + children_data.append(to_entity_meta(child)) + data = {"ctx": ctx_data, "children": children_data} + content = json.dumps(data) + return EntityMeta(type=type_, content=content.encode()) + + @classmethod + def __from_entity_meta__(cls, meta: EntityMeta) -> Self: + data = json.loads(meta["content"]) + ctx_data = data["ctx"] + children_data = data["children"] + result = cls(**ctx_data) + children = [] + for child in children_data: + children.append(from_entity_meta(child)) + return result.with_children(*children) + + +class GhostOS(Protocol): + + @abstractmethod + def container(self) -> Container: + """ + root container for GhostOS + """ + pass + + @abstractmethod + def send_event(self, event: Event) -> None: + """ + send an event into the loop. + the event always has a task_id, so the task shall be created first. + """ + pass + + @abstractmethod + def converse( + self, + ghost: G, + context: G.Props, + ) -> Conversation[G]: + """ + create a top-level conversation with a ghost. + top-level means task depth is 0. + So it never locked until the conversation is created. + """ + pass + + @abstractmethod + def call( + self, + ghost: G, + props: G.Props, + instructions: Optional[Iterable[Message]] = None, + *, + timeout: float = 0.0, + ) -> Tuple[Union[G.Artifact, None], TaskState]: + """ + run a ghost task until it stopped, + """ + pass + + @abstractmethod + def background_run_event( + self, + *, + timeout: float = 0.0, + ) -> Union[Event, None]: + """ + run the event loop for the ghosts in the Shell. + 1. pop task notification. + 2. try to converse the task + 3. if failed, pop another task notification. + 4. if success, pop task event and handle it until no event found. + 5. send a task notification after handling, make sure someone check the task events are empty. + only the tasks that depth > 0 have notifications. + background run itself is blocking method, run it in a separate thread for parallel execution. + :param timeout: + :return: the handled event + """ + pass + + +class Conversation(Protocol[G]): + """ + interface for operate on synchronized (task is locked) ghost + """ + + @abstractmethod + def session(self) -> Session: + """ + Session of the Conversation + """ + pass + + @abstractmethod + def is_done(self) -> bool: + """ + weather the conversation is done or not + """ + pass + + @abstractmethod + def respond( + self, + inputs: Iterable[Message], + props: Optional[G.Props] = None, + *, + history: Optional[Iterable[Message]] = None, + ) -> Iterable[Message]: + """ + create response immediately by inputs. the inputs will change to event. + """ + pass + + @abstractmethod + def respond_event(self, event: Event) -> Iterable[Message]: + """ + create response to the event immediately + :param event: + :return: + """ + pass + + @abstractmethod + def pop_event(self) -> Optional[Event]: + """ + pop event of the current task + """ + pass + + @abstractmethod + def fail(self, error: Exception) -> bool: + """ + exception occur + :return: catch the exception or not + """ + pass + + @abstractmethod + def close(self): + """ + close the conversation + """ + pass + + @abstractmethod + def closed(self) -> bool: + """ + closed + """ + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.close(): + return + if exc_val is not None: + return self.fail(exc_val) + else: + self.close() + return None + + +class Messenger(Stream, ABC): + """ + Messenger is a bridge of message streams + Messenger finish when the flush method is called. + Each messenger can nest sub messengers, when sub messenger is finished, + the parent messenger is not finished until the flush is called. + + why this is an abstract base class? + there may be more abilities during streaming are needed, + this project can only provide a basic one. + """ + + @abstractmethod + def flush(self) -> Tuple[List[Message], List[Caller]]: + """ + flush the buffed messages, finish the streaming of this messenger. + the message buffer shall join all the chunks to message item. + after the messenger is flushed, it can not send any new message. + """ + pass + + +class StateValue(ABC): + """ + session state value + """ + + @abstractmethod + def get(self, session: Session) -> Optional[Self]: + pass + + @abstractmethod + def bind(self, session: Session) -> None: + pass + + def get_or_bind(self, session: Session) -> Self: + value = self.get(session) + if value is None: + value = self + self.bind(session) + return value + + +class Session(Protocol[G]): + """ + Session 管理了一个有状态的会话. 所谓 "有状态的会话", 通常指的是: + shell + ghost + 多轮对话/多轮思考 运行中的状态. + + Session 则提供了 Ghost 的 Task 运行时状态统一管理的 API. + 通常每个运行中的 Task 都会创建一个独立的 Session. + Session 在运行周期里不会立刻调用底层 IO 存储消息, 而是要等一个周期正常结束. + 这是为了减少运行时错误对状态机造成的副作用. + """ + + class Scope(BaseModel): + """ + scope of the session. + """ + task_id: str + parent_task_id: Optional[str] = None + + scope: Scope + """the running scope of the session""" + + state: Dict[str, Union[Dict, BaseModel]] + """session state that keep session state values""" + + container: Container + """Session level container""" + + task: GoTaskStruct + """current task""" + + thread: GoThreadInfo + """thread info of the task""" + + @abstractmethod + def is_alive(self) -> bool: + """ + Session 对自身任务进行状态检查. + 如果这个任务被取消或终止, 则返回 false. + 基本判断逻辑: + 1. 消息上游流没有终止. + 2. task 持有了锁. + 3. 设置的超时时间没有过. + """ + pass + + @abstractmethod + def ghost(self) -> G: + """ + current ghost instance + :return: + """ + pass + + @abstractmethod + def get_props(self) -> G.Props: + """ + current context for the ghost + """ + pass + + @abstractmethod + def get_artifact(self) -> G.Artifact: + """ + :return: the current state of the ghost goal + """ + pass + + @abstractmethod + def goal(self) -> G.Artifact: + pass + + @abstractmethod + def refresh(self) -> Self: + """ + refresh the session, update overdue time and task lock. + """ + pass + + @abstractmethod + def messenger( + self, *, + remember: bool = True, + ) -> "Messenger": + """ + Task 当前运行状态下, 向上游发送消息的 Messenger. + 每次会实例化一个 Messenger, 理论上不允许并行发送消息. 但也可能做一个技术方案去支持它. + Messenger 未来要支持双工协议, 如果涉及多流语音还是很复杂的. + """ + pass + + @abstractmethod + def respond( + self, + messages: Iterable[MessageKind], + remember: bool = True, + ) -> Tuple[List[Message], List[Caller]]: + """ + 发送消息, 但不影响运行状态. + """ + pass + + # --- 基本操作 --- # + @abstractmethod + def self_finish(self, status: str = "", *replies: MessageKind) -> Operator: + """ + finish self task + :param status: describe status of the task + :param replies: replies to parent task or user + """ + pass + + @abstractmethod + def self_fail(self, status: str, *replies: MessageKind) -> Operator: + """ + self task failed. + :param status: describe status of the task + :param replies: replies to parent task or user + """ + pass + + @abstractmethod + def self_wait(self, status: str, *replies: MessageKind) -> Operator: + """ + wait for the parent task or user to provide more information or further instruction. + :param status: describe current status + :param replies: question, inform or + """ + pass + + # --- subtask 相关 --- # + + @abstractmethod + def cancel_subtask(self, ghost: G, reason: str = "") -> None: + """ + 取消子任务. + :param ghost: + :param reason: + :return: + """ + pass + + def send_subtask(self, ghost: G, *messages: MessageKind, ctx: Optional[G.Props] = None) -> None: + """ + 发送消息给子任务. 如果子任务不存在, 会创建. + 子任务会通过 event 与父任务通讯. + :param ghost: + :param messages: + :param ctx: + :return: + """ + pass + + def create_subtask(self, ghost: G, ctx: G.Props, instruction: str) -> None: + """ + 创建子任务并运行. + :param ghost: + :param ctx: + :param instruction: + :return: + """ + pass + + def call(self, ghost: G, ctx: G.Props) -> G.Artifact: + """ + 创建一个子任务, 阻塞并等待它完成. + :param ghost: + :param ctx: + :return: the Goal of the task. if the final state is not finish, throw an exception. + """ + pass + + # --- 更底层的 API. --- # + + @abstractmethod + def create_tasks(self, *tasks: "GoTaskStruct") -> None: + """ + 创建多个 task. 只有 session.done() 的时候才会执行. + """ + pass + + @abstractmethod + def fire_events(self, *events: "Event") -> None: + """ + 发送多个事件. 这个环节需要给 event 标记 callback. + 在 session.done() 时才会真正执行. + """ + pass + + @abstractmethod + def get_task_briefs(self, *task_ids) -> List[TaskBrief]: + """ + 获取多个任务的简介. + :param task_ids: 可以指定要获取的 task id + """ + pass + + @abstractmethod + def save(self) -> None: + """ + 完成 session, 需要清理和真正保存状态. + 需要做的事情包括: + 1. 推送 events, events 要考虑 task 允许的栈深问题. 这个可以后续再做. + 2. 保存 task. task 要对自己的子 task 做垃圾回收. 并且保留一定的子 task 数, 包含 dead task. + 3. 保存 thread + 4. 保存 processes. + 5. 考虑到可能发生异常, 要做 transaction. + 6. 退出相关的逻辑只能在 finish 里实现. + :return: + """ + pass + + @abstractmethod + def fail(self, err: Optional[Exception]) -> bool: + """ + 任务执行异常的处理. 需要判断任务是致命的, 还是可以恢复. + :param err: + :return: + """ + pass + + @abstractmethod + def done(self) -> None: + pass + + @abstractmethod + def destroy(self) -> None: + """ + 手动清理数据, 方便垃圾回收. + """ + pass + + def __enter__(self) -> "Session": + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + intercept = None + if exc_val is not None: + intercept = self.fail(exc_val) + else: + self.done() + self.destroy() + return intercept diff --git a/ghostos/core/abcd/ghostos.py b/ghostos/core/abcd/ghostos.py deleted file mode 100644 index 970af6a1..00000000 --- a/ghostos/core/abcd/ghostos.py +++ /dev/null @@ -1,335 +0,0 @@ -from __future__ import annotations -from typing import ( - Protocol, Optional, Iterable, TypeVar, Any, Union, Self, ClassVar, Type, Callable, Generic, Dict, -) -from abc import ABC, abstractmethod -from ghostos.container import Container, Provider -from ghostos.common import Identifiable, EntityType, Identifier -from ghostos.core.session.session import Session, SessionProps -from ghostos.core.session.tasks import GoTaskStruct, TaskState, GoTasks -from ghostos.core.session.events import Event -from ghostos.core.messages import Message - - -class Ghost(Identifiable, EntityType, Protocol): - """ - """ - # - # The word `Ghost` is picked from `Ghost In the Shell` movie. - # The Ghost can perform as both conversational object or an async function. - # Ghost is the abstract of atomic state machine unit in the GhostOS. - # - # for example, llm-based `Agent` is a state machine, an implementation of Ghost in GhostOS. - # - # Why Agent is a state machine? - # 1. Agent receives an event at a time, not parallel, or face brain split. - # 2. Agent keep it state in the system prompt and messages, by nature language. - # 3. Agent take actions that matching expectation. - # So Agent is an AI-State-Machine, defined from prompt, not code; executed by Model, not Interpreter. - # - # About the Ghost Abstract: - # 1. it is a class. - # 2. the ghost class can construct ghost instance. - # 3. any ghost instance can run as a conversational task - # 4. a conversational task runs in turns, receiving event and replying messages. - # 5. the conversational task is stateful, accept one event at a time. - # 6. the conversational task reach the end when it is canceled, done or failed - # 7. all the ghost has a Goal model to describe its current achievement. - # 8. The Ghost Class shall be simple and clear to the AI models, when they are creating ghosts themselves. - # - # and the Most valuable features about ghost are: - # 1. ghosts shall be fractal, can be called by other ghosts. - # 2. ghost shall be defined by code, which can be generated by meta-agents. - # - - Goal: ClassVar[Union[Type, None]] - """ the model of the ghost's goal""" - - __ghost_driver__: Type[GhostDriver] = None - - -G = TypeVar("G", bound=Ghost) - - -class GhostDriver(Generic[G], ABC): - """ - Ghost class is supposed to be a data class without complex methods definitions. - so it seems much clear when prompt to the LLM or user-level developer. - when LLM is creating a ghost class or instance, we expect it only see the code we want it to see, - without knowing the details codes of it, for safety / fewer tokens / more focus or other reasons. - - so the methods of the ghost class defined in this class. - only core developers should know details about it. - """ - - def __init__(self, ghost: G) -> None: - self.ghost = ghost - - @abstractmethod - def create(self, parent_session: Session) -> GoTaskStruct: - """ - create task in given - :param parent_session: - :return: - """ - pass - - @abstractmethod - def get_goal(self, session: Session) -> Optional[G.Goal]: - """ - generate the ghost goal from session_state - may be the Goal Model is a SessionStateValue that bind to it. - - The AI behind a ghost is not supposed to operate the session object, - but work on the goal through functions or Moss Injections. - """ - pass - - @abstractmethod - def on_event(self, session: Session, event: Event) -> Union[Operator, None]: - """ - all the state machine is only handling session event with the predefined operators. - """ - pass - - -class Operator(Protocol): - """ - Operator to operating the GhostOS through the Session encapsulation. - - The Operator is just like the primitives of any coding language. - for example, GhostOS have some operators work like python's `return`, `yield`, `await` . - - I'm not capable to develop a real OS or a new coding language for AI, - GhostOS is built above python with the additional complexities. - - Operators should be predefined, offer to user-level developer, or AI-models. - """ - - @abstractmethod - def run(self, session: Session) -> Union[Operator, None]: - """ - :return: None means stop the loop, otherwise keep going. - - operator returns an operator is a way to encapsulate repetitive codes. - """ - pass - - @abstractmethod - def destroy(self): - """ - Python gc is not trust-worthy - Especially A keep B, B keep C, C keep A, father and child keep each other. - I prefer to del the object attributes in the end of the object lifecycle. - """ - pass - - -class GhostOS(Protocol): - - @abstractmethod - def container(self) -> Container: - """ - root container for GhostOS - """ - pass - - @abstractmethod - def connect( - self, - shell_id: str = "local", - process_id: Optional[str] = None, - properties: Optional[Dict[str, Any]] = None, - *providers: Provider, - ) -> Shell: - """ - The word 'Shell' is picked from `Ghost In the Shell` movie. - Shell is the body of an AI or something. - this method create or connect a shell that can communicate with the ghosts inside it. - - :param shell_id: id of the runtime instance keep all the shell level runtime objects. - :param providers: register shell level providers. only this shell instance have them. - :param process_id: once a shell instance is recreated, - all the process level runtime objects will be abandoned. - such as tasks, threads, events etc. - but the shell level memory will keep existence. - :param properties: the properties of the ghost instance, inherited by every task created in the shell. - :return: a connection to the limbo where all the ghosts running inside - """ - pass - - -class Shell(Protocol): - """ - shell basically is an event loop run all the ghost (agentic State Machine). - """ - - @abstractmethod - def container(self) -> Container: - """ - shell has its own container with providers. - in case ghostos start multiple shell at same time - """ - pass - - @abstractmethod - def send_event(self, event: Event) -> None: - """ - send an event into the loop. - the event always has a task_id, so the task shall be created first. - """ - pass - - @abstractmethod - def quit(self): - """ - quit the shell connection. - """ - pass - - @abstractmethod - def get_task(self, task_id: str, lock: bool) -> Optional[GoTaskStruct]: - """ - get a task instance by id - :param task_id: - :param lock: if True, try to lock the task before getting. - :return: None if the task is not exists or is locked. - """ - pass - - @abstractmethod - def sync_task(self, task_id: str) -> Optional[Conversation]: - """ - lock a task then create a conversation. - :param task_id: - :return: - """ - pass - - @abstractmethod - def sync(self, ghost: Ghost) -> Conversation: - """ - create a top-level conversation with a ghost. - top-level means task depth is 0. - So it never locked until the conversation is created. - :param ghost: - :return: - """ - pass - - @abstractmethod - def create_task(self, ghost: Ghost, parent_task: Optional[str] = None) -> GoTaskStruct: - pass - - @abstractmethod - def run_task( - self, - task_id: str, - timeout: float = 0.0, - loop: int = 0, - ) -> Union[Any, TaskState]: - """ - run a task until it is end. which means: - - reach timeout - - reach max loop times - - state is Done, Canceled, or Failed. - - :param task_id: use task_id to lock the task before running. if failed, raise TaskIsLockedError - :param timeout: - :param loop: - :return: - """ - pass - - @abstractmethod - def run_ghost( - self, - ghost: Ghost, *, - timeout: float = 0.0, - loop: int = 0, - ) -> Union[Any, TaskState]: - """ - run a ghost task until it stopped, - :param ghost: the ghost is used to generate a task, actually. - :param timeout: if timeout > 0, throw TimeoutError after timeout. - :param loop: how many times to run the ghost event loop. < 1 means no limitations. - :return: [Ghost.Goal, TaskState] - """ - pass - - @abstractmethod - def background_run( - self, - *, - timeout: float = 0.0, - max_events_handling: int = 0, - stop_check: Optional[Callable[[], bool]] = None, - ): - """ - run the event loop for the ghosts in the Shell. - loop is: - 1. pop task notification. - 2. try to converse the task - 3. if failed, pop another task notification. - 4. if success, pop task event and handle it until no event found. - 5. send a task notification after handling, make sure someone check the task events are empty. - only the tasks that depth > 0 have notifications. - background run itself is blocking method, run it in a separate thread for parallel execution. - :param timeout: - :param max_events_handling: - :param stop_check: check stop signal from outside function. - :return: - """ - pass - - -class Conversation(Protocol): - """ - interface for operate on synchronized (task is locked) ghost - """ - - @abstractmethod - def ghost(self) -> Ghost: - pass - - @abstractmethod - def session(self) -> Session: - pass - - @abstractmethod - def is_done(self) -> bool: - pass - - @abstractmethod - def create_response(self, inputs: Iterable[Message]) -> Iterable[Message]: - pass - - @abstractmethod - def handle_event(self, event: Event) -> Iterable[Message]: - pass - - @abstractmethod - def pop_event(self, event: Event) -> Optional[Event]: - pass - - @abstractmethod - def fail(self, error: Exception) -> bool: - pass - - @abstractmethod - def close(self): - pass - - @abstractmethod - def closed(self) -> bool: - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if exc_val is not None: - return self.fail(exc_val) - else: - self.close() - return None diff --git a/ghostos/core/abcd/ghosts.py b/ghostos/core/abcd/ghosts.py index e11f2eb9..c9f7ff28 100644 --- a/ghostos/core/abcd/ghosts.py +++ b/ghostos/core/abcd/ghosts.py @@ -1,14 +1,14 @@ from abc import ABC, abstractmethod from ghostos.common import Identifier -from .ghostos import Ghost, GhostDriver from pydantic import BaseModel +from .concepts import Ghost, GhostDriver """ Some ghost prototypes. """ -class Agent(BaseModel, Ghost, ABC): +class Agent(Ghost, ABC): """ Agent is the base abstract of LLM-based conversational AI entity. @@ -20,7 +20,7 @@ class Agent(BaseModel, Ghost, ABC): - system configurations, like thread truncating / authorities / welcome craft etc. """ - Goal = None + Artifact = None @abstractmethod def __identifier__(self) -> Identifier: @@ -51,7 +51,7 @@ class Thought(BaseModel, Ghost, ABC): Thought is a micro unit to processing thinking with current context; the Goal of the Thought is to produce a decision or suggestion, add them to the context. """ - Goal = str + Artifact = str @abstractmethod def __identifier__(self) -> Identifier: diff --git a/ghostos/core/abcd/moss_agent.py b/ghostos/core/abcd/moss_agent.py deleted file mode 100644 index c9f1b048..00000000 --- a/ghostos/core/abcd/moss_agent.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations -from typing import Tuple, Optional, Protocol, Any, Self, Iterable, List, Dict, Union, Callable, Any -from types import ModuleType - -from ghostos.common import Identifier, Identical, get_identifier -from ghostos.core.llms import Prompt, PromptPipe, LLMApi, LLMs -from ghostos.core.messages import Message, Caller, Role -from ghostos.core.session import Session, GoThreadInfo, Event -from ghostos.core.abcd.ghostos import Ghost, GhostDriver, Operator, G -from ghostos.core.session.session import SessionProps -from ghostos.core.moss import MossRuntime, MossCompiler, Moss -from ghostos.helpers import generate_import_path, import_from_path, join_import_module_and_spec, uuid, unwrap -from abc import ABC, abstractmethod -from pydantic import BaseModel, Field -import inspect - - -class MossAgent(BaseModel, Ghost): - """ - An Agent defined by a single python file - """ - - Goal = None - - module_name: str = Field( - description="the python module name of the MossAgent located." - ) - - name: str = Field( - default="", - description="The name of the agent, if empty, the source will be it's name if agent instance is missing", - ) - description: str = Field( - default="", - description="The description of the agent. can also defined by __description__ in the source file", - ) - id: Optional[str] = Field( - default=None, - description="if not none, the agent is singleton to the shell", - ) - - @abstractmethod - def __identifier__(self) -> Identifier: - # check if the module exists - agent_id = self.id - name = self.module_name - if self.name: - name = self.name - description = self.description - return Identifier( - id=agent_id, - name=name, - description=description, - ) - - -def __goal__(moss: Moss) -> Any: - return None - - -def __thought__(moss: Moss, props: dict) -> str: - pass - - -class MossAgentDriver(GhostDriver[MossAgent]): - """ - default moss agent driver. - """ - - def __init__(self, ghost: MossAgent): - super().__init__(ghost) - self._module = import_from_path(ghost.module_name) - - def get_goal(self, session: Session) -> Optional[G.Goal]: - if __goal__.__name__ in self._module.__dict__: - method = getattr(self._module, __goal__.__name__) - return method(self.ghost, session) - return __goal__(self.ghost, session) - - def instructions(self, session: Session) -> List[Message]: - if self.ghost.__instruction__: - instruction = self.ghost.__instruction__(self.ghost, session) - else: - instruction = self.ghost.instruction - return [Role.SYSTEM.new(content=instruction)] - - def truncate(self, session: Session, thread: GoThreadInfo) -> GoThreadInfo: - if self.ghost.__truncate__: - return self.ghost.__truncate__(self.ghost, session, thread) - return thread - - def on_event(self, session: Session, event: Event) -> Union[Operator, None]: - thread = session.thread() - thread = self.truncate(session, thread) - - # update event - ok = True - if self.ghost.__update_event__: - ok = self.ghost.__update_event__(self.ghost, session, thread, event) - else: - thread.new_turn(event) - session.update_thread(thread, False) - if not ok: - return None - - # prompt - system = self.instructions(session) - prompt = thread.to_prompt(system) - - thoughts = self.thoughts(session) - for t in thoughts: - prompt, op = t.think(session, prompt) - if op is not None: - return op - return self.action(session, prompt) - - def thoughts(self, session: Session) -> Iterable[Thought]: - if self.ghost.__thoughts__: - return self.ghost.__thoughts__(self.ghost, session) - return [] - - def actions(self, session: Session) -> Dict[str, Action]: - if self.ghost.__actions__: - return self.ghost.__actions__(self.ghost, session) - pass - - def llm_api(self, session: Session) -> LLMApi: - if self.ghost.__llm_api__: - return self.ghost.__llm_api__(self.ghost, session) - return session.container().force_fetch(LLMs).get_api("") - - def action(self, session: Session, prompt: Prompt) -> Optional[Operator]: - actions = self.actions(session) - for action in actions.values(): - prompt = action.process(prompt) - - llm_api = self.llm_api(session) - messenger = session.messenger() - llm_api.deliver_chat_completion( - prompt, - messenger, - ) - messages, callers = messenger.flush() - for caller in callers: - if caller.name in actions: - action = actions[caller.name] - op = action.callback(session, caller) - if op is not None: - return op - return None - - -MossAgent.__ghost_driver__ = MossAgentDriver - - -class Action(PromptPipe, ABC): - @abstractmethod - def name(self) -> str: - pass - - @abstractmethod - def callback(self, session: Session, caller: Caller) -> Optional[Operator]: - pass - - -class Thought(ABC): - - @abstractmethod - def think(self, session: Session, prompt: Prompt) -> Tuple[Prompt, Optional[Operator]]: - pass diff --git a/ghostos/core/abcd/utils.py b/ghostos/core/abcd/utils.py index 0c23e672..9dfb673a 100644 --- a/ghostos/core/abcd/utils.py +++ b/ghostos/core/abcd/utils.py @@ -1,15 +1,21 @@ -from typing import TypeVar, Optional, Type +from typing import TypeVar, Optional, Type, Union from ghostos.helpers import import_class_from_path, generate_import_path, md5 from ghostos.common import get_identifier, to_entity_meta -from .ghostos import Ghost, GhostDriver +from ghostos.core.runtime import Runtime, GoTaskStruct +from .concepts import Ghost, GhostDriver + +__all__ = [ + 'get_ghost_task', 'get_or_create_ghost_task', + 'get_ghost_driver', 'get_ghost_driver_type', +] def get_ghost_driver_type(ghost: Ghost) -> Type[GhostDriver]: """ get ghost driver instance by default protocol """ - if ghost.__ghost_driver__ is not None: - return ghost.__ghost_driver__ + if ghost.Driver is not None: + return ghost.Driver name = ghost.__class__.__name__ module_name = ghost.__class__.__module__ import_path = f"{module_name}:{name}Driver" @@ -24,6 +30,8 @@ def get_ghost_driver(ghost: Ghost) -> GhostDriver: def is_ghost(value) -> bool: try: + if not isinstance(value, Ghost): + return False id_ = get_identifier(value) assert id_ is not None meta = to_entity_meta(value) @@ -35,29 +43,68 @@ def is_ghost(value) -> bool: return False -def make_ghost_task_id( - ghost: Ghost, +def make_unique_ghost_id( shell_id: str, - process_id: str, - parent_task_id: Optional[str], + **scope_ids: str, ) -> str: """ - default way to create ghost task ID - ghost itself can be a locator to its task instance, if the task_id is the same. - """ - identifier = get_identifier(ghost) - - # shell level id - # if the ghost_id is generated each time, the task id is alternative - # if the ghost_id is static, the task id is identical to shell. - if ghost_id := identifier.id: - unique_str = f"shell:{shell_id}:ghost_id:{ghost_id}" - return md5(unique_str) - - # parent scope unique task - # the task is unique to it parent by the name - self_name = identifier.name - cls_name = generate_import_path(ghost.__class__) - unique_str = (f"shell:{shell_id}:process:{process_id}" - f":parent:{parent_task_id}:cls{cls_name}:name:{self_name}") - return md5(unique_str) + make unique ghost id + :param shell_id: the shell id must exist. + :param scope_ids: + :return: md5 hash + """ + ids = f"shell:{shell_id}" + keys = sorted(scope_ids.keys()) + for key in keys: + scope = scope_ids[key] + ids += f":{key}:{scope}" + return md5(ids) + + +def get_or_create_ghost_task(runtime: Runtime, ghost: Ghost, parent_task_id: Optional[str]) -> GoTaskStruct: + """ + default way to find or create ghost task + :param runtime: + :param ghost: + :param parent_task_id: + :return: + """ + task = get_ghost_task(runtime, ghost, parent_task_id) + if task is None: + task = make_ghost_task(runtime, ghost, parent_task_id) + return task + + +def get_ghost_task(runtime: Runtime, ghost: Ghost, parent_task_id: Optional[str]) -> Union[GoTaskStruct, None]: + driver = get_ghost_driver(ghost) + task_id = driver.make_task_id(runtime, parent_task_id) + task = runtime.tasks.get_task(task_id) + if task is None: + return None + # update task's meta from ghost. + task.meta = to_entity_meta(ghost) + return task + + +def make_ghost_task(runtime: Runtime, ghost: Ghost, parent_task_id: Optional[str]) -> GoTaskStruct: + """ + default way to create a task + :param runtime: + :param ghost: + :param parent_task_id: + :return: + """ + driver = get_ghost_driver(ghost) + task_id = driver.make_task_id(runtime, parent_task_id) + id_ = get_identifier(ghost) + meta = to_entity_meta(ghost) + task_ = GoTaskStruct.new( + task_id=task_id, + shell_id=runtime.shell_id, + process_id=runtime.process_id, + name=id_.name, + description=id_.description, + meta=meta, + parent_task_id=parent_task_id + ) + return task_ diff --git a/ghostos/core/agents/agent.py b/ghostos/core/agents/agent.py deleted file mode 100644 index 0679eb86..00000000 --- a/ghostos/core/agents/agent.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import Optional, Iterable, List, Callable, Self -from abc import ABC, abstractmethod -from ghostos.common import Identical, Identifier -from ghostos.core.session import ( - Event, -) -from ghostos.core.messages import Message -from ghostos.container import Container, Contracts - - -class Agent(Identical, ABC): - - @abstractmethod - def identifier(self) -> Identifier: - pass - - @abstractmethod - def instruction(self) -> str: - pass - - @abstractmethod - def container(self) -> Container: - pass - - @abstractmethod - def run(self, history: List[Message], inputs: Iterable[Message]) -> Iterable[Message]: - pass - - -class StatefulAgent(Agent, ABC): - - @abstractmethod - def on_inputs(self, inputs: Iterable[Message]) -> Iterable[Message]: - pass - - @abstractmethod - def pop_event(self) -> Optional[Event]: - pass - - @abstractmethod - def handle_event(self, event: Event) -> None: - pass - - -class Realtime(ABC): - - @abstractmethod - def on_event(self, callback: Callable[[StatefulAgent, Event], Optional[Event]]) -> None: - pass - - @abstractmethod - def on_message(self, callback: Callable[[StatefulAgent, Message], None]) -> None: - pass - - @abstractmethod - def on_message_chunks( - self, - msg_type: str, - callback: Callable[[StatefulAgent, Iterable[Message]], None], - ) -> None: - pass - diff --git a/ghostos/core/agents/moss_agent.py b/ghostos/core/agents/moss_agent.py new file mode 100644 index 00000000..b92aed3c --- /dev/null +++ b/ghostos/core/agents/moss_agent.py @@ -0,0 +1,133 @@ +from typing import Union, Optional, Protocol, Dict, Any, TypeVar +from types import ModuleType +from abc import ABC, abstractmethod +from ghostos.common import Identifier +from pydantic import BaseModel, Field + +from ghostos.helpers import import_from_path, generate_import_path +from ghostos.core.abcd import GhostDriver, Operator, Agent +from ghostos.core.runtime import Event, Runtime +from ghostos.core.moss import MossCompiler, PyContext +from .utils import make_agent_task_id + + +class MossAgent(BaseModel, Agent): + """ + Basic Agent that turn a python module into a conversational agent. + """ + + Artifact = None + """ subclass of MossAgent could have a GoalType, default is None""" + + Props = None + """ subclass of MossAgent could have a ContextType, default is None""" + + moss_module: str = Field(description="Moss module name for the agent") + instruction: str = Field(description="The instruction that the agent should follow") + + persona: str = Field(default="", description="Persona for the agent, if not given, use global persona") + name: str = Field(default="", description="name of the agent") + description: str = Field(default="", description="description of the agent") + compile_module: Optional[str] = Field(None, description="Compile module name for the agent") + llmapi_name: str = Field(default="", description="name of the llm api, if none, use default one") + + def __identifier__(self) -> Identifier: + name = self.name if self.name else self.moss_module + return Identifier( + id=self.moss_module, + name=name, + description=self.description, + ) + +M = TypeVar("M", bound=MossAgent) + +class Moss(Protocol): + pass + + +# + +__agent__: Optional[MossAgent] = None +""" magic attr that predefine an agent of the module with given persona and instruction.""" + + +def __agent_moss_injections__(agent: MossAgent, session: Session) -> Dict[str, Any]: + """ + manually define some of the injections to the Moss Class. + if a property of Moss is not injected here, the session container will inject it by typehint. + :param agent: + :param session: + """ + return { + } + + +def __agent_context_prompt__(agent: MossAgent, moss: Moss) -> str: + return "" + + +def __agent_goal__(agent: MossAgent, moss: Moss): + """ + get the agent goal, default is None + """ + return None + + +def __agent_on_event_type__(agent: MossAgent, session: Session, moss: Moss): + pass + + +class MossAgentDriver(GhostDriver[MossAgent]): + + def make_task_id(self, runtime: Runtime, parent_task_id: Optional[str] = None) -> str: + return make_agent_task_id(runtime, self.ghost, parent_task_id) + + def get_module(self) -> ModuleType: + m = import_from_path(self.ghost.moss_module) + return m + + def get_goal(self, session: Session) -> Optional[MossAgent.GoalType]: + m = self.get_module() + fn = __agent_goal__ + if __agent_goal__.__name__ in m.__dict__: + fn = getattr(m, __agent_goal__.__name__) + return fn(self.ghost, session) + + def on_event(self, session: Session, event: Event) -> Union[Operator, None]: + compiler = self.get_compiler(session) + with compiler: + rtm = compiler.compile(self.ghost.compile_module) + with rtm: + moss = rtm.moss() + + def get_compiler(self, session: Session) -> MossCompiler: + pycontext = self.get_pycontext(session) + compiler = session.container().force_fetch(MossCompiler) + compiler = compiler.join_context(pycontext) + compiler = compiler.with_locals(Optional=Optional) + + # bind moss agent itself + compiler.bind(type(self.ghost), self.ghost) + + # bind agent level injections. + injection_fn = __agent_moss_injections__ + module = self.get_module() + if __agent_moss_injections__.__name__ in module.__dict__: + injection_fn = getattr(module, __agent_moss_injections__.__name__) + injections = injection_fn(self.ghost, session) + compiler = compiler.injects(**injections) + + return compiler + + def get_pycontext(self, session: Session) -> PyContext: + pycontext_key = generate_import_path(PyContext) + data = session.properties.get(pycontext_key, None) + if data is not None: + pycontext = PyContext(**data) + else: + pycontext = PyContext( + module=self.get_module(), + ) + return pycontext + +# diff --git a/ghostos/core/agents/utils.py b/ghostos/core/agents/utils.py new file mode 100644 index 00000000..363fd245 --- /dev/null +++ b/ghostos/core/agents/utils.py @@ -0,0 +1,33 @@ +from typing import Optional, Iterable, List, Callable, Self, ClassVar, Union, Type, TypeVar, Generic +from abc import ABC, abstractmethod +from ghostos.common import Identical, Identifier, EntityMeta, to_entity_meta, get_identifier +from ghostos.core.runtime import ( + Event, Session, GoTaskStruct, Runtime, +) +from ghostos.core.messages import Message +from ghostos.core.llms import Prompt, LLMApi +from ghostos.container import Container, Contracts +from ghostos.core.abcd.ghostos import Ghost, GhostDriver, Operator +from ghostos.core.abcd.ghosts import Agent +from ghostos.core.abcd.utils import make_unique_ghost_id + + +def make_agent_task_id(runtime: Runtime, agent: Agent, parent_task_id: Optional[str] = None) -> str: + """ + agent is singleton to its parent. + """ + parent_task_id = parent_task_id if parent_task_id else "" + id_ = get_identifier(agent) + task_id = make_unique_ghost_id( + runtime.shell_id, + process_id=runtime.process_id, + parent_task_id=parent_task_id, + agent_name=id_.name, + ) + return task_id + + +def update_session_with_event(session: Session, event: Event): + thread = session.thread() + thread.new_turn(event) + session.update_thread(thread) diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index fb65b5f3..9f99b4c3 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -11,7 +11,7 @@ ) from ghostos.core.llms import LLMs, Prompt from ghostos.core.moss.abc import MossRuntime -from ghostos.core.session import GoThreadInfo, EventTypes, GoThreads, thread_to_chat +from ghostos.core.runtime import GoThreadInfo, EventTypes, GoThreads, thread_to_chat from ghostos.core.messages import Role, Message, Stream from ghostos.container import Container diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index fa9d6378..855ac343 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -4,7 +4,7 @@ from ghostos.core.moss.decorators import cls_source_code from ghostos.core.moss import MossCompiler, PyContext from ghostos.core.llms import LLMApi, Prompt -from ghostos.core.session import GoThreadInfo +from ghostos.core.runtime import GoThreadInfo from ghostos.core.messages import Message, Stream, Payload from ghostos.common import Identifier from ghostos.helpers import generate_import_path, uuid diff --git a/ghostos/core/ghost_dev_pattern/__init__.py b/ghostos/core/ghost_dev_pattern/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/core/ghost_dev_pattern/concepts.py b/ghostos/core/ghost_dev_pattern/concepts.py deleted file mode 100644 index d3bc2acd..00000000 --- a/ghostos/core/ghost_dev_pattern/concepts.py +++ /dev/null @@ -1,196 +0,0 @@ -from abc import ABC, abstractmethod -from typing import ( - Protocol, TypeVar, Optional, Union, Literal, List, Type, Dict, TypedDict, Required, Any -) -from ghostos.core.moss import Moss -from pydantic import BaseModel - - -class Func(Protocol): - """ - AI Function definition in data-driven pattern. - """ - - Args: Type[BaseModel] - Returns: Optional[Type[BaseModel]] - - -F = TypeVar('F', bound=Func) - - -class Task(Protocol[F]): - """ - is the state instance of a Func execution - """ - args: F.Args - """ the calling arguments, which is changeable during execution""" - - returns: Union[F.Returns, None] - """ the return values, which is altering during execution""" - - status: Literal["new", "waiting", "running", "done", "pending", "cancelled", "aborted"] - """ the status of the execution """ - - description: str - """ describe the execution status""" - - -class State(BaseModel, ABC): - """ - the runtime private state properties model of the Func - """ - pass - - -S = TypeVar("S", bound=State) - - -class VarPtr(TypedDict): - """ - refer a global accessible variable to the pointer. - compatible to many type like int, str, boolean, float, or other identifiable types. - """ - - vid: Required[str] - """ unique id of the variable""" - - type: Required[str] - """ origin type of the variable, like int, str, or import path in `[module]:[attr]` pattern""" - - desc: Optional[str] - """ description of the variable""" - - -M = TypeVar("M", bound=Moss) - - -class OP(ABC): - """ - runtime operator of a Func. - can only be pre-defined by outer Operating System. - """ - - @abstractmethod - def run(self): - pass - - -class Context(Protocol[F, S, M]): - """ - the runtime context for an AI Entity-driven Func. - """ - - state: S - """ mutate self state properties """ - - task: Task[F] - """ self task instance """ - - moss: M - """ - the operating system for the AI Entity who driven this Func. - provide instance of libraries and tools. - """ - - subtasks: Dict[str, Task] - """ - the other Func state instances, that have been created by this Context. - """ - - @abstractmethod - def send(self, *messages: Union[str, VarPtr, Any]) -> None: - """ - send messages to the caller. - :param messages: str, var pointer or any value that can be converted to VarPtr. - :exception TypeError: if message can not convert to VarPtr - """ - pass - - @abstractmethod - def set_var(self, value: Any, vid: Optional[str] = None) -> VarPtr: - """ - - :param value: - :param vid: - :return: - """ - pass - - @abstractmethod - def get_var(self, vid: str, type_: Optional[Type[Any]] = None) -> Union[Any, None]: - """ - get a global variable by vid. - :param vid: id from VarPtr - :param type_: the expected type of the variable. - :return: None if the variable is not found, otherwise unpack the variable to it origin type - """ - pass - - # the functions below are the primitive operators of this functions. - - @abstractmethod - def done(self, returns: Union[F.Returns, None] = None, *messages: str) -> OP: - """ - end the task with confirmed return values. - :param returns: if not None, update the return value of self task - :param messages: if given, inform the caller with the messages. - """ - pass - - @abstractmethod - def abort(self, reason: str) -> OP: - """ - abort the task with given reason. - """ - pass - - @abstractmethod - def await_answer(self, question: str, suggestions: Optional[List[str]] = None) -> OP: - """ - ask a question to the caller, and wait for the answer. - :param question: ask for confirmation, choice, selection, clarification, etc. - :param suggestions: if you have any - """ - pass - - @abstractmethod - def ack(self) -> OP: - """ - acknowledge the messages and do nothing. - """ - pass - - @abstractmethod - def observe(self, **kwargs) -> OP: - """ - start an observation on the outputs before it is called. - :param kwargs: if given, repr each arg for observation. - """ - pass - - # the methods below can interact with other funcs. - - @abstractmethod - def create_subtask(self, name: str, args: Func.Args) -> None: - """ - call another Func with subtask name. - :param name: key to find the subtask in self subtasks - :param args: arguments instance of the calling Func - """ - pass - - @abstractmethod - def send_to_subtask(self, name: str, *messages: Union[str, VarPtr, Any]) -> None: - """ - send information to the subtask. - :param name: specify a subtask by name - :param messages: if not str or VarPtr, then must be some value that can be converted to VarPtr. - """ - pass - - @abstractmethod - def cancel_subtask(self, name: str, reason: str) -> None: - """ - cancel specified subtask by name with reason. - """ - pass diff --git a/ghostos/core/ghost_dev_pattern/ghost.py b/ghostos/core/ghost_dev_pattern/ghost.py deleted file mode 100644 index dcb24759..00000000 --- a/ghostos/core/ghost_dev_pattern/ghost.py +++ /dev/null @@ -1,105 +0,0 @@ -from abc import ABC, abstractmethod -from typing import ( - Protocol, Self, Generic, Type, TypeVar, Tuple, Callable, Union, Optional, List, Literal, Dict, - Iterable, ClassVar, -) -from .concepts import State, Func, Context, Task, OP -from .runtime import Runtime -from ghostos.container import Container, Contracts -from ghostos.core.moss import PyContext, Moss, MossCompiler -from ghostos.core.session import Session, Event -from ghostos.core.messages import Message -from ghostos.common import IDAbleClass, identify_class, Identifier, Identical -from ghostos.entity import EntityMeta -from pydantic import BaseModel, Field -from contextlib import contextmanager - -""" -Ghost 是面向开发者的抽象设计. -它是 Agent 运行时中最小有状态的思维单元. 开发者可以通过定义 Ghost, 来定义 Agent 可分形嵌套的思维能力. - -Ghost 的定位类似于前端 React 框架的 ReactComponent, 或是 MVC 开发框架里的 Controller. 包含的核心功能: -1. 响应一个 Task, 并且最终管理 Task 的输出, task.result -2. 管理 Task 运行时的状态, 也就是 Thought. 包含创建, 修改, 完成, 失败等. -3. 管理有状态的子任务, 也就是 Thought. 包含创建, 取消, 发送消息. -4. 响应运行过程中接受到的的事件. 作出行动. -5. 调用 LLM 作为资深的驱动. 由 Ghost 子类实现. 子类的任务包括: - - 提供 prompt - - 提供上下文. - - 提供工具, 通常是用 moss 提供的代码交互界面. - - 运行大模型. - - 执行大模型生成的 actions, 将操作反馈到 runtime. - - 保存自身状态, 等待下一轮. 或结束, 终止当前任务. - -类似React 框架中, Component 通过一个 JSX Element 被调用; Web API 中, controller 通过 URL 请求被调用; -在 GhostOS 中, Ghost 可以通过 GhostFunc 的形式, 以函数的姿态被调用. -""" - - -class Ghost(BaseModel, Identical, ABC): - - @abstractmethod - def identifier(self) -> Identifier: - pass - - @classmethod - def default(cls) -> Self: - pass - - __fsm__: ClassVar[str] = None - - -G = TypeVar("G", bound=Ghost) -F = TypeVar("F", bound=Func) -S = TypeVar("S", bound=State) -M = TypeVar("M", bound=Moss) -""" G.F.S.M. => ghost finite state machine""" - - -class GhostFSM(Protocol[G, F, S, M]): - state: S - task: Task[F] - - def __init__(self, ghost: G, task: Task[F], state: S): - self.ghost = ghost - self.task = task - self.state = state - - def identifier(self) -> Identifier: - return self.ghost.identifier() - - @contextmanager - def container(self, container: Container) -> Container: - container = Container(parent=container) - # bind state - container.set(State, self.state) - container.set(self.state.__class__, self.state) - # bind task - container.set(Task, self.task) - container.set(Ghost, self.ghost) - # bind ghost - container.set(self.ghost.__class__, self.ghost) - container.set(Ghost, self.ghost) - # bind fsm - container.set(GhostFSM, self) - container.set(self.__class__, self) - yield container - container.destroy() - - @classmethod - @abstractmethod - def on_create(cls, runtime: Runtime, ctx: Context[F, S, M]) -> OP: - pass - - @abstractmethod - def on_event(self, runtime: Runtime, ctx: Context[F, S, M], event: Event) -> OP: - pass - - @abstractmethod - def run( - self, - ctx: Context[F, S, M], - history: List[Message], - inputs: Iterable[Message], - ) -> Iterable[Message]: - pass diff --git a/ghostos/core/ghost_dev_pattern/runtime.py b/ghostos/core/ghost_dev_pattern/runtime.py deleted file mode 100644 index 4c29f30e..00000000 --- a/ghostos/core/ghost_dev_pattern/runtime.py +++ /dev/null @@ -1,62 +0,0 @@ -from abc import ABC, abstractmethod -from typing import ( - Protocol, Self, Generic, Type, TypeVar, Tuple, Callable, Union, Optional, List, Literal, Dict -) -from .concepts import Context, Task, Func -from ghostos.container import Container -from ghostos.core.session import Session, Event -from ghostos.entity import EntityMeta -from pydantic import BaseModel, Field - - -class StackFrame(BaseModel): - frame_id: str = Field( - description="Ghost Stack Frame ID", - ) - process_id: str = Field( - description="Ghost always run in a certain process" - ) - args: EntityMeta = Field( - description="the arguments passed to the ghost function" - ) - depth: int = Field( - default=0, - description="the depth of the stack" - ) - state: EntityMeta = Field( - description="the state data of the current ghost function stack frame", - ) - returns: Optional[EntityMeta] = Field( - default=None, - description="the return values of the ghost function destination", - ) - parent_id: Optional[str] = Field( - default=None, - description="parent stack frame ID which create this frame. None for root frame", - ) - event_id: Optional[str] = Field( - default=None, - description="the event id which create this frame. None for root frame", - ) - status: Literal["done", "running", "waiting", "pending", "aborted", "cancelled", "new"] = Field( - default="new", - description="the status of the stack frame", - ) - description: str = Field( - default="", - description="description of the stack frame current status" - ) - children: Dict[str, str] = Field( - default_factory=dict, - description="sub stack frame index. from sub task name to frame ids", - ) - - -class Runtime(Protocol): - container: Container - session: Session - - @abstractmethod - def get_frame(self, frame_id: str) -> Optional[StackFrame]: - pass - diff --git a/ghostos/core/ghost_dev_pattern/template.py b/ghostos/core/ghost_dev_pattern/template.py deleted file mode 100644 index 8916e8a4..00000000 --- a/ghostos/core/ghost_dev_pattern/template.py +++ /dev/null @@ -1,28 +0,0 @@ -from .concepts import Func, Context, State, OP -from typing import List, Union -from pydantic import BaseModel, Field -from ghostos.core.moss import Moss - - -class Chat(Func): - class Args(BaseModel): - instruction: str - - class Returns(BaseModel): - summary: str - - -class ChatState(State): - who_is_talking: str - notes: List[str] - - -class ChatMoss(Moss): - pass - - -def main(ctx: Context[Chat, ChatState, ChatMoss]) -> OP: - """ - instructions - """ - pass diff --git a/ghostos/core/ghost_dev_pattern/think.py b/ghostos/core/ghost_dev_pattern/think.py deleted file mode 100644 index 795fc2c0..00000000 --- a/ghostos/core/ghost_dev_pattern/think.py +++ /dev/null @@ -1,2 +0,0 @@ -from typing import Generic -from abc import ABC, abstractmethod diff --git a/ghostos/core/ghost_dev_pattern/thoughts.py b/ghostos/core/ghost_dev_pattern/thoughts.py deleted file mode 100644 index 4d82c7b2..00000000 --- a/ghostos/core/ghost_dev_pattern/thoughts.py +++ /dev/null @@ -1,59 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Protocol, Self, Optional, Union, Type, TypeVar, Any, Dict, TypedDict -from typing_extensions import Required -from enum import Enum -from ghostos.common import Serializable -from ghostos.core.moss import Moss - - -class Thought(Serializable, Protocol): - """ - Thought describe a stateful - """ - name: str - purpose: str - task: Task - status: str - status_reason: str - created: float - updated: float - - -class ThoughtStatus(str, Enum): - NEW = "new" - PENDING = "pending" - RUNNING = "running" - SLEEPING = "sleeping" - DONE = "done" - CANCELLED = "cancelled" - ABORTED = "aborted" - - -T = TypeVar("T") - - -class Thoughts(ABC): - - @abstractmethod - def get(self, name: str) -> Thought: - pass - - @abstractmethod - def create(self, *task: Task) -> Thought: - pass - - @abstractmethod - def update(self, *task: Task) -> Thought: - pass - - @abstractmethod - def delete(self, *task: Task) -> None: - pass - - @abstractmethod - def cancel(self, name: str, reason: str) -> None: - pass - - @abstractmethod - def send(self, name: str, *messages: str) -> None: - pass diff --git a/ghostos/core/ghost_dev_pattern/variables.py b/ghostos/core/ghost_dev_pattern/variables.py deleted file mode 100644 index c49c5542..00000000 --- a/ghostos/core/ghost_dev_pattern/variables.py +++ /dev/null @@ -1,22 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Protocol, Self, Optional, Union, Type, TypeVar, Any, Dict, TypedDict -from typing_extensions import Required - -T = TypeVar("T") - - -class VarPtr(TypedDict): - vid: Required[str] - type: Required[str] - desc: Optional[str] - - -class Variables(ABC): - - @abstractmethod - def get(self, vid: str, expect: Optional[Type[T]] = None, force: bool = False) -> Optional[T]: - pass - - @abstractmethod - def save(self, value: Any, vid: Optional[str] = None) -> VarPtr: - pass diff --git a/ghostos/core/ghostos.py b/ghostos/core/ghostos.py index a46cc570..bec494e9 100644 --- a/ghostos/core/ghostos.py +++ b/ghostos/core/ghostos.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from ghostos.entity import EntityMeta from ghostos.core.messages import Stream -from ghostos.core.session import EventBus, Event, GoTasks, GoTaskStruct, GoProcess, GoProcesses +from ghostos.core.runtime import EventBus, Event, GoTasks, GoTaskStruct, GoProcess, GoProcesses from ghostos.core.ghosts import Ghost, GhostConf, Inputs from ghostos.contracts.logger import LoggerItf from ghostos.contracts.shutdown import Shutdown diff --git a/ghostos/core/ghostos2.py b/ghostos/core/ghostos2.py index 74f66bde..ac8b7aa8 100644 --- a/ghostos/core/ghostos2.py +++ b/ghostos/core/ghostos2.py @@ -6,7 +6,7 @@ Ghost, GhostConf, Inputs, Shell, Thought ) -from ghostos.core.session import ( +from ghostos.core.runtime import ( Event, GoProcess, GoTaskStruct, GoTasks, diff --git a/ghostos/core/ghosts/actions.py b/ghostos/core/ghosts/actions.py index 5c400eeb..4a4f4d7e 100644 --- a/ghostos/core/ghosts/actions.py +++ b/ghostos/core/ghosts/actions.py @@ -5,7 +5,7 @@ from ghostos.core.llms import Prompt, LLMFunc, PromptPipe from ghostos.core.ghosts.operators import Operator from ghostos.core.messages.message import Caller -from ghostos.core.session import Session +from ghostos.core.runtime import Session from ghostos.common import Identical, Identifier from pydantic import BaseModel diff --git a/ghostos/core/ghosts/ghost.py b/ghostos/core/ghosts/ghost.py index 90651515..249d2fec 100644 --- a/ghostos/core/ghosts/ghost.py +++ b/ghostos/core/ghosts/ghost.py @@ -6,7 +6,7 @@ from ghostos.contracts.logger import LoggerItf from ghostos.contracts.modules import Modules from ghostos.contracts.configs import Configs -from ghostos.core.session import Session, Event +from ghostos.core.runtime import Session, Event from ghostos.core.messages import Message, Role from ghostos.core.moss import MossCompiler from ghostos.core.llms import LLMs diff --git a/ghostos/core/ghosts/operators.py b/ghostos/core/ghosts/operators.py index 2efe3951..3148760f 100644 --- a/ghostos/core/ghosts/operators.py +++ b/ghostos/core/ghosts/operators.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Optional, ClassVar, Dict, Type -from ghostos.core.session import Event +from ghostos.core.runtime import Event from ghostos.core.moss.decorators import cls_definition if TYPE_CHECKING: diff --git a/ghostos/core/ghosts/thoughts.py b/ghostos/core/ghosts/thoughts.py index bb1635f7..2d3a4c44 100644 --- a/ghostos/core/ghosts/thoughts.py +++ b/ghostos/core/ghosts/thoughts.py @@ -2,7 +2,7 @@ from typing import Optional, TypeVar, Generic, Type, Iterable from abc import ABC, abstractmethod from ghostos.entity import Entity, ModelEntity -from ghostos.core.session import Event, GoThreadInfo, Session +from ghostos.core.runtime import Event, GoThreadInfo, Session from ghostos.core.ghosts.ghost import Ghost from ghostos.core.ghosts.operators import Operator from ghostos.common import Identical, Identifier, PrompterClass diff --git a/ghostos/core/ghosts/utils.py b/ghostos/core/ghosts/utils.py index f43e259b..95d45b26 100644 --- a/ghostos/core/ghosts/utils.py +++ b/ghostos/core/ghosts/utils.py @@ -2,7 +2,7 @@ from ghostos.core.ghosts.ghost import Ghost from ghostos.core.ghosts.operators import Operator from ghostos.core.ghosts.thoughts import Thought, ThoughtDriver -from ghostos.core.session import ( +from ghostos.core.runtime import ( Event, EventTypes, GoTaskStruct, TaskState, GoTasks, ) @@ -53,7 +53,7 @@ def initialize(self) -> None: meta = root_thought.to_entity_meta() task = GoTaskStruct.new( task_id=task_id, - session_id=session.id(), + shell_id=session.id(), process_id=process.process_id, name=identifier.name, description=identifier.description, diff --git a/ghostos/core/moss/__init__.py b/ghostos/core/moss/__init__.py index 517abaeb..e152228b 100644 --- a/ghostos/core/moss/__init__.py +++ b/ghostos/core/moss/__init__.py @@ -1,8 +1,8 @@ from ghostos.container import Container from ghostos.core.moss.abc import ( - Moss, MossCompiler, MossRuntime, MossPrompter, MossResult, + Moss, MossCompiler, MossRuntime, MossPrompter, Execution, AttrPrompts, - MOSS_NAME, MOSS_TYPE_NAME, MOSS_HIDDEN_MARK, MOSS_HIDDEN_UNMARK, + MOSS_VALUE_NAME, MOSS_TYPE_NAME, MOSS_HIDDEN_MARK, MOSS_HIDDEN_UNMARK, MOSS_EXEC_EVENT, MOSS_PROMPT_EVENT, MOSS_COMPILE_EVENT, MOSS_ATTR_PROMPTS_EVENT, moss_message, ) @@ -17,14 +17,13 @@ __all__ = [ # abstract contracts - Moss, MossCompiler, MossRuntime, MossPrompter, MossResult, + Moss, MossCompiler, MossRuntime, MossPrompter, Execution, # constants - MOSS_NAME, MOSS_TYPE_NAME, MOSS_HIDDEN_MARK, MOSS_HIDDEN_UNMARK, + MOSS_VALUE_NAME, MOSS_TYPE_NAME, MOSS_HIDDEN_MARK, MOSS_HIDDEN_UNMARK, MOSS_EXEC_EVENT, MOSS_PROMPT_EVENT, MOSS_COMPILE_EVENT, MOSS_ATTR_PROMPTS_EVENT, DEFAULT_MOSS_FUNCTIONAL_TOKEN, DEFAULT_MOSS_PROMPT_TEMPLATE, # methods - moss_message, get_default_moss_prompt, # types AttrPrompts, diff --git a/ghostos/core/moss/abc.py b/ghostos/core/moss/abc.py index d2a1e6f9..7edc827e 100644 --- a/ghostos/core/moss/abc.py +++ b/ghostos/core/moss/abc.py @@ -1,5 +1,6 @@ +from __future__ import annotations import contextlib -from typing import Dict, Any, Union, List, Optional, NamedTuple, Type, Callable +from typing import Dict, Any, Union, List, Optional, NamedTuple, Type, Callable, Self from types import ModuleType from abc import ABC, abstractmethod from ghostos.container import Container, Provider, Factory, provide @@ -8,7 +9,6 @@ AttrPrompts, reflect_module_locals, PROMPT_MAGIC_ATTR, compile_attr_prompts, ) -from ghostos.core.messages import Message, Role from ghostos.core.moss.decorators import cls_source_code from contextlib import contextmanager @@ -53,11 +53,11 @@ __all__ = [ 'Moss', 'attr', 'MossCompiler', 'MossRuntime', - 'MossResult', 'MossPrompter', - 'moss_message', + 'Execution', 'MossPrompter', + # 'moss_message', 'AttrPrompts', 'MOSS_COMPILE_EVENT', 'MOSS_PROMPT_EVENT', 'MOSS_EXEC_EVENT', 'MOSS_ATTR_PROMPTS_EVENT', - 'MOSS_TYPE_NAME', 'MOSS_NAME', + 'MOSS_TYPE_NAME', 'MOSS_VALUE_NAME', 'MOSS_HIDDEN_MARK', 'MOSS_HIDDEN_UNMARK', ] @@ -75,7 +75,7 @@ MOSS_TYPE_NAME = "Moss" -MOSS_NAME = "moss" +MOSS_VALUE_NAME = "moss" MOSS_HIDDEN_MARK = "# " """ pycontext.module 源码某一行以这个标记开头, 其后的代码都不生成到 prompt 里. """ @@ -96,11 +96,11 @@ class Moss(ABC): pass -def moss_message(content: str, memory: Optional[str] = None) -> Message: - """ - default message type that MOSS execution generated - """ - return Role.ASSISTANT.new(content=content, memory=memory, name="__moss__") +class Injection(ABC): + + @abstractmethod + def on_inject(self, compiler: MossCompiler, property_name: str) -> Self: + pass class MossCompiler(ABC): @@ -151,10 +151,6 @@ def with_locals(self, **kwargs) -> "MossCompiler": """ pass - @abstractmethod - def with_ignore_prompts(self, *attr_names) -> "MossCompiler": - pass - def register(self, provider: Provider) -> None: """ 向生成 MOSS 的 IoC 容器里注册 Provider. @@ -222,13 +218,6 @@ def compile( # 手动管理一下, 避免外部解决内存泄漏的心智成本. self.destroy() - @contextmanager - def compile_ctx(self, modulename: Optional[str]): - runtime = self.compile(modulename) - yield runtime - # destroy it in case of memory leak - runtime.destroy() - @abstractmethod def _compile(self, modulename: Optional[str] = None) -> ModuleType: """ @@ -252,6 +241,12 @@ def destroy(self) -> None: """ pass + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.destroy() + class MossPrompter(ABC): """ @@ -433,7 +428,7 @@ def execute( local_kwargs: Optional[Dict[str, str]] = None, args: Optional[List[Any]] = None, kwargs: Optional[Dict[str, Any]] = None, - ) -> "MossResult": + ) -> "Execution": """ 基于 moos 提供的上下文, 运行一段代码. :param code: 需要运行的代码. @@ -477,8 +472,14 @@ def destroy(self) -> None: """ pass + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.destroy() + -class MossResult(NamedTuple): +class Execution(NamedTuple): """ result of the moss runtime execution. """ diff --git a/ghostos/core/moss/examples/baseline.py b/ghostos/core/moss/examples/baseline.py index 377e5422..a9795a59 100644 --- a/ghostos/core/moss/examples/baseline.py +++ b/ghostos/core/moss/examples/baseline.py @@ -36,7 +36,7 @@ class Moss(Parent): from typing import TYPE_CHECKING if TYPE_CHECKING: - from ghostos.core.moss.abc import MossCompiler, MossRuntime, AttrPrompts, MossPrompter, MossResult + from ghostos.core.moss.abc import MossCompiler, MossRuntime, AttrPrompts, MossPrompter, Execution def __moss_compile__(compiler: "MossCompiler") -> "MossCompiler": @@ -81,7 +81,7 @@ def __moss_prompt__(prompter: "MossPrompter") -> str: return __moss_prompt__(prompter) -def __moss_exec__(*args, **kwargs) -> "MossResult": +def __moss_exec__(*args, **kwargs) -> "Execution": # 测试生命周期生效. Moss.life.append("__moss_exec__") from ghostos.core.moss.lifecycle import __moss_exec__ diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index ce7ee0a7..875a8b75 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -6,7 +6,7 @@ from ghostos.container import Container, Provider from ghostos.core.moss.abc import ( Moss, - MossCompiler, MossRuntime, MossPrompter, MOSS_NAME, MOSS_TYPE_NAME, + MossCompiler, MossRuntime, MossPrompter, MOSS_VALUE_NAME, MOSS_TYPE_NAME, MOSS_HIDDEN_MARK, MOSS_HIDDEN_UNMARK, ) from ghostos.contracts.modules import Modules, ImportWrapper @@ -29,6 +29,7 @@ def __init__(self, *, container: Container, pycontext: Optional[PyContext] = Non } self._injections: Dict[str, Any] = {} self._attr_prompts: List = [] + self._destroyed = False def container(self) -> Container: return self._container @@ -109,6 +110,9 @@ def pycontext_code(self) -> str: return code if code else "" def destroy(self) -> None: + if self._destroyed: + return + self._destroyed = True # container 先不 destroy. del self._container del self._pycontext @@ -214,7 +218,7 @@ def _compile_moss(self): # 依赖注入. setattr(moss, name, value) self._moss = moss - self._compiled.__dict__[MOSS_NAME] = moss + self._compiled.__dict__[MOSS_VALUE_NAME] = moss self._compiled.__dict__[MOSS_TYPE_NAME] = moss_type self._compiled.__dict__["print"] = self._print diff --git a/ghostos/core/moss/lifecycle.py b/ghostos/core/moss/lifecycle.py index b8c64fa9..1d760a63 100644 --- a/ghostos/core/moss/lifecycle.py +++ b/ghostos/core/moss/lifecycle.py @@ -4,7 +4,7 @@ if TYPE_CHECKING: from ghostos.core.moss.prompts import AttrPrompts - from ghostos.core.moss.abc import MossPrompter, MossResult, MossRuntime, MossCompiler + from ghostos.core.moss.abc import MossPrompter, Execution, MossRuntime, MossCompiler """ 这个文件提供了 MOSS 生命周期的关键方法, 每一个都是可选的. @@ -114,7 +114,7 @@ def __moss_exec__( local_kwargs: "Optional[Dict[str, Any]]" = None, args: Optional[List[Any]] = None, kwargs: Optional[Dict[str, Any]] = None, -) -> "MossResult": +) -> "Execution": """ 基于 MOSS Runtime 执行一段代码, 并且调用目标方法或返回目标值. :param runtime: moss runtime @@ -126,7 +126,7 @@ def __moss_exec__( :param kwargs: 从外部注入的参数变量. """ from typing import Callable - from ghostos.core.moss.abc import MossResult + from ghostos.core.moss.abc import Execution pycontext = runtime.dump_pycontext() pycontext.execute_code = code pycontext.executed = False @@ -174,4 +174,4 @@ def __moss_exec__( std_output = runtime.dump_std_output() pycontext = runtime.dump_pycontext() pycontext.executed = True - return MossResult(returns, std_output, pycontext) + return Execution(returns, std_output, pycontext) diff --git a/ghostos/core/moss/pycontext.py b/ghostos/core/moss/pycontext.py index e2222c5f..ac044a52 100644 --- a/ghostos/core/moss/pycontext.py +++ b/ghostos/core/moss/pycontext.py @@ -28,10 +28,10 @@ class PyContext(BaseModel): description="if code given, use it instead of code from the module", ) - injections: Dict[str, Injection] = Field( - default_factory=dict, - description="通过 python 引入的包, 类, 方法 等. 会注入到 MOSS 上, 同时会实现它.", - ) + # injections: Dict[str, Injection] = Field( + # default_factory=dict, + # description="通过 python 引入的包, 类, 方法 等. 会注入到 MOSS 上, 同时会实现它.", + # ) properties: Dict[str, Property] = Field( default_factory=dict, description="在上下文中定义的变量. 会注入到 MOSS 上. 修改后也会保存到 pycontext 里. ", diff --git a/ghostos/core/moss/test_suites.py b/ghostos/core/moss/test_suites.py index 34ee232e..350d099d 100644 --- a/ghostos/core/moss/test_suites.py +++ b/ghostos/core/moss/test_suites.py @@ -1,5 +1,5 @@ from typing import List, Dict, Optional, Callable -from ghostos.core.moss.abc import MossCompiler, MossResult +from ghostos.core.moss.abc import MossCompiler, Execution from ghostos.core.moss.pycontext import PyContext from ghostos.container import Container from queue import Queue @@ -29,7 +29,7 @@ def dump_prompt( def run_module_tests( self, *, modulename: str, - callback: Callable[[str, MossResult], None], + callback: Callable[[str, Execution], None], test_modulename: str = "__test__", targets: Optional[str] = None, ) -> None: @@ -66,7 +66,7 @@ def run( target: str = "test_main", args: Optional[List[str]] = None, kwargs: Dict[str, str] = None, - ) -> MossResult: + ) -> Execution: """ 运行一个指定的 moss 测试. :param modulename: 想要测试的 moss 文件的模块路径. @@ -86,7 +86,7 @@ def parallel_run_moss_func( self, *, modulename: str, funcs: List[str], - callback: Callable[[str, MossResult], None], + callback: Callable[[str, Execution], None], test_module_name: str = "__test__", ) -> None: """ diff --git a/ghostos/core/runtime/__init__.py b/ghostos/core/runtime/__init__.py new file mode 100644 index 00000000..163dc28c --- /dev/null +++ b/ghostos/core/runtime/__init__.py @@ -0,0 +1,10 @@ +from ghostos.core.runtime.tasks import ( + GoTaskStruct, TaskPayload, TaskBrief, + GoTasks, TaskState, +) +from ghostos.core.runtime.threads import GoThreads, GoThreadInfo, thread_to_chat, Turn +from ghostos.core.runtime.processes import GoProcess, GoProcesses +from ghostos.core.runtime.messenger import Messenger, Buffed +from ghostos.core.runtime.events import Event, EventBus, EventTypes +from ghostos.core.runtime.simple_thread import SimpleMsgThread +from ghostos.core.runtime.runtime import Runtime diff --git a/ghostos/core/session/events.py b/ghostos/core/runtime/events.py similarity index 97% rename from ghostos/core/session/events.py rename to ghostos/core/runtime/events.py index 0b3bfd2c..86130bef 100644 --- a/ghostos/core/session/events.py +++ b/ghostos/core/runtime/events.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Any from typing_extensions import Self from abc import ABC, abstractmethod from enum import Enum @@ -11,8 +11,6 @@ 'Event', 'EventBus', 'EventTypes', ] -EVENT_ENTITY_TYPE = "ghostos.core.session.events.Event" - class Event(BaseModel): """ @@ -28,8 +26,13 @@ class Event(BaseModel): ) type: str = Field( default="", - description="event type, by default the handler shall named on_{type}" + description="event type" + ) + attrs: Dict[str, Any] = Field( + default_factory=dict, + description="event attributes that follow the types." ) + task_id: str = Field( description="task id of which this event shall send to.", ) @@ -41,11 +44,11 @@ class Event(BaseModel): default=None, description="task name in which this event is fired", ) + reason: str = Field( default="", description="reason of the event, wrapped by system type message before the messages", ) - messages: List[Message] = Field( default_factory=list, description="list of messages sent by this event", diff --git a/ghostos/core/session/messenger.py b/ghostos/core/runtime/messenger.py similarity index 100% rename from ghostos/core/session/messenger.py rename to ghostos/core/runtime/messenger.py diff --git a/ghostos/core/session/processes.py b/ghostos/core/runtime/processes.py similarity index 100% rename from ghostos/core/session/processes.py rename to ghostos/core/runtime/processes.py diff --git a/ghostos/core/runtime/runtime.py b/ghostos/core/runtime/runtime.py new file mode 100644 index 00000000..b160da6d --- /dev/null +++ b/ghostos/core/runtime/runtime.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from typing import Protocol +from .tasks import GoTasks +from .threads import GoThreads +from .processes import GoProcesses +from ghostos.container import Container +from ghostos.core.messages.transport import Stream + + +class Runtime(Protocol): + """ + shell runtime + """ + shell_id: str + """basic shell id.""" + process_id: str + """the process id of this instance of shell.""" + stream: Stream + """upstream to send messages""" + container: Container + """the container of the shell""" + tasks: GoTasks + """the tasks of the shell""" + threads: GoThreads + """the threads of the shell""" + processes: GoProcesses + """"the processes of the shell""" diff --git a/ghostos/core/session/simple_thread.py b/ghostos/core/runtime/simple_thread.py similarity index 97% rename from ghostos/core/session/simple_thread.py rename to ghostos/core/runtime/simple_thread.py index 3b20cd20..b87ba438 100644 --- a/ghostos/core/session/simple_thread.py +++ b/ghostos/core/runtime/simple_thread.py @@ -1,7 +1,7 @@ from typing import List, Optional, Dict, Any from pydantic import BaseModel, Field from ghostos.core.messages import Message -from ghostos.core.session.threads import GoThreadInfo, Turn +from ghostos.core.runtime.threads import GoThreadInfo, Turn DESCRIPTION = """ Simple Thread is a simple mode for MsgThread, useful to show thread important information when debugging. diff --git a/ghostos/core/session/tasks.py b/ghostos/core/runtime/tasks.py similarity index 94% rename from ghostos/core/session/tasks.py rename to ghostos/core/runtime/tasks.py index 5c97bf9f..1ba2ab49 100644 --- a/ghostos/core/session/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -66,9 +66,6 @@ class AssistantInfo(Identifier, BaseModel): class GoTaskStruct(BaseModel): # -- scope --- # - shell_id: str = Field( - description="shell id that task belongs.", - ) process_id: str = Field( description=""" the id of the process that the task belongs to. @@ -98,7 +95,7 @@ class GoTaskStruct(BaseModel): # --- state values --- # - pointer: EntityMeta = Field( + meta: EntityMeta = Field( description="the entity meta of the task handler", ) @@ -169,7 +166,7 @@ class GoTaskStruct(BaseModel): def new( cls, *, task_id: str, - session_id: str, + shell_id: str, process_id: str, name: str, description: str, @@ -178,7 +175,7 @@ def new( ) -> "GoTaskStruct": return GoTaskStruct( task_id=task_id, - session_id=session_id, + shell_id=shell_id, process_id=process_id, thread_id=task_id, parent=parent_task_id, @@ -193,19 +190,19 @@ def add_child( name: str, description: str, meta: EntityMeta, - assistant: Optional[Identifier] = None, ) -> "GoTaskStruct": self.children.append(task_id) - return self.new( + child = self.new( task_id=task_id, - session_id=self.shell_id, + shell_id=self.shell_id, process_id=self.process_id, name=name, description=description, meta=meta, parent_task_id=self.task_id, - assistant=assistant, ) + child.depth = self.depth + 1 + return child def remove_child(self, child_task_id: str) -> bool: results = [] @@ -317,11 +314,10 @@ def save_task(self, *tasks: GoTaskStruct) -> None: pass @abstractmethod - def get_task(self, task_id: str, lock: bool) -> Optional[GoTaskStruct]: + def get_task(self, task_id: str) -> Optional[GoTaskStruct]: """ 使用 task id 来获取一个 task. :param task_id: - :param lock: 是否尝试对 task 上锁, 如果要求上锁但没成功, 返回 None. :return: if task is not Exists or locked failed """ pass diff --git a/ghostos/core/session/threads.py b/ghostos/core/runtime/threads.py similarity index 99% rename from ghostos/core/session/threads.py rename to ghostos/core/runtime/threads.py index 2fb3299b..c1142e79 100644 --- a/ghostos/core/session/threads.py +++ b/ghostos/core/runtime/threads.py @@ -6,7 +6,7 @@ from ghostos.core.messages import Message, copy_messages, Role from ghostos.core.moss.pycontext import PyContext from ghostos.core.llms import Prompt -from ghostos.core.session.events import Event, EventTypes +from ghostos.core.runtime.events import Event, EventTypes from ghostos.helpers import uuid from contextlib import contextmanager diff --git a/ghostos/core/session/__init__.py b/ghostos/core/session/__init__.py deleted file mode 100644 index 2cabbd8c..00000000 --- a/ghostos/core/session/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from ghostos.core.session.session import Session, Operator -from ghostos.core.session.tasks import ( - GoTaskStruct, TaskPayload, TaskBrief, - GoTasks, TaskState, WaitGroup, -) -from ghostos.core.session.threads import GoThreads, GoThreadInfo, thread_to_chat, Turn -from ghostos.core.session.processes import GoProcess, GoProcesses -from ghostos.core.session.messenger import Messenger, Buffed -from ghostos.core.session.events import Event, EventBus, EventTypes -from ghostos.core.session.simple_thread import SimpleMsgThread diff --git a/ghostos/core/session/session.py b/ghostos/core/session/session.py deleted file mode 100644 index 8deb90ae..00000000 --- a/ghostos/core/session/session.py +++ /dev/null @@ -1,255 +0,0 @@ -from typing import Optional, Iterable, List, Callable, Self, Union, Protocol, Dict, Any -from abc import ABC, abstractmethod - -from ghostos.core.session.events import Event, EventBus -from ghostos.core.session.messenger import Messenger -from ghostos.core.session.processes import GoProcesses, GoProcess -from ghostos.core.session.tasks import GoTasks, GoTaskStruct, TaskBrief -from ghostos.core.session.threads import GoThreads, GoThreadInfo -from ghostos.core.messages import MessageKind, Role, Buffer, Payload, Message -from ghostos.core.llms import FunctionalToken -from ghostos.container import Container -from pydantic import BaseModel - -__all__ = ['Session', 'SessionProps', 'SessionStateValue'] - -SessionProps = Dict[str, Union[Dict, BaseModel]] - - -class Scope(BaseModel): - shell_id: str - process_id: str - task_id: str - thread_id: str - parent_task_id: Optional[str] = None - - -class Session(ABC): - """ - Session 管理了一个有状态的会话. 所谓 "有状态的会话", 通常指的是: - shell + ghost + 多轮对话/多轮思考 运行中的状态. - - Session 则提供了 Ghost 的 Task 运行时状态统一管理的 API. - 通常每个运行中的 Task 都会创建一个独立的 Session. - Session 在运行周期里不会立刻调用底层 IO 存储消息, 而是要等一个周期正常结束. - 这是为了减少运行时错误对状态机造成的副作用. - """ - - scope: Scope - """ - the running scope of the session - """ - - globals: Dict[str, Any] - """ - global values of the session. - inherit from parent task or shell props. - some key are override by parent task or current task. - """ - - properties: SessionProps - """ - Most important value of the session. - keep the runtime properties of the task. - key is unique to the task handler, value shall be dict or BaseModel. - value shall be serializable, otherwise the world may crash!! - - session 最重要的数据结构, 用来承载所有的运行时数据. - key 是数据运行时唯一的 key, 值只能是 dict. - 所有的值都应该是 Serializable 的, 否则会有世界毁灭之类的灾难爆发!! - """ - - @abstractmethod - def container(self) -> Container: - pass - - @abstractmethod - def alive(self) -> bool: - """ - Session 对自身任务进行状态检查. - 如果这个任务被取消或终止, 则返回 false. - 基本判断逻辑: - 1. 消息上游流没有终止. - 2. task 持有了锁. - 3. 设置的超时时间没有过. - """ - pass - - @abstractmethod - def refresh(self) -> Self: - """ - refresh the session, update overdue time and task lock. - """ - pass - - # @abstractmethod - # def refresh_lock(self) -> bool: - # """ - # Session 尝试用已有的锁, 更新自身的锁. 更新失败的话, 返回 False. - # """ - # pass - - @abstractmethod - def task(self) -> "GoTaskStruct": - """ - 获取当前的任务对象. - 描述了任务所有的状态. - 返回的是一份 copy, 只有调用 update 方法才会更新. - """ - pass - - @abstractmethod - def thread(self) -> "GoThreadInfo": - """ - Session 会持有当前任务的 Thread, 只有 finish 的时候才会真正地保存它. - """ - pass - - @abstractmethod - def messenger( - self, *, - sending: bool = True, - saving: bool = True, - thread: Optional[GoThreadInfo] = None, - name: Optional[str] = None, - buffer: Optional[Buffer] = None, - payloads: Optional[Iterable[Payload]] = None, - attachments: Optional[Iterable[Attachment]] = None, - functional_tokens: Optional[Iterable[FunctionalToken]] = None - ) -> "Messenger": - """ - Task 当前运行状态下, 向上游发送消息的 Messenger. - 每次会实例化一个 Messenger, 理论上不允许并行发送消息. 但也可能做一个技术方案去支持它. - Messenger 未来要支持双工协议, 如果涉及多流语音还是很复杂的. - """ - pass - - @abstractmethod - def send_messages(self, *messages: MessageKind, remember: bool = True) -> List[Message]: - """ - 发送消息. - :param messages: - :param remember: remember the messages within the thread - :return: - """ - pass - - @abstractmethod - def update_task(self, task: "GoTaskStruct") -> None: - """ - 更新当前 session 的 task. - :param task: 如果不属于当前 session, 则会报错 - """ - pass - - @abstractmethod - def update_process(self, process: "GoProcess") -> None: - """ - 改动 process 并保存. 通常只在初始化里才需要. - """ - pass - - @abstractmethod - def update_thread(self, thread: "GoThreadInfo", update_history: bool) -> None: - """ - 单独更新当前 session 的 thread. - :param thread: 如果不属于当前 session, 则会报错 - :param update_history: 是否要将 thread 的历史更新掉. - """ - pass - - @abstractmethod - def create_tasks(self, *tasks: "GoTaskStruct") -> None: - """ - 创建多个 task. 只有 session.done() 的时候才会执行. - """ - pass - - # --- 多任务管理的 api. 在 session.finish 时真正执行. --- # - - @abstractmethod - def fire_events(self, *events: "Event") -> None: - """ - 发送多个事件. 这个环节需要给 event 标记 callback. - 在 session.done() 时才会真正执行. - """ - pass - - @abstractmethod - def get_task_briefs(self, *task_ids, children: bool = False) -> List[TaskBrief]: - """ - 获取多个任务的简介. - :param task_ids: 可以指定要获取的 task id - :param children: 如果为 true, 会返回当前任务的所有子任务数据. - """ - pass - - @abstractmethod - def save(self) -> None: - """ - 完成 session, 需要清理和真正保存状态. - 需要做的事情包括: - 1. 推送 events, events 要考虑 task 允许的栈深问题. 这个可以后续再做. - 2. 保存 task. task 要对自己的子 task 做垃圾回收. 并且保留一定的子 task 数, 包含 dead task. - 3. 保存 thread - 4. 保存 processes. - 5. 考虑到可能发生异常, 要做 transaction. - 6. 退出相关的逻辑只能在 finish 里实现. - :return: - """ - pass - - @abstractmethod - def fail(self, err: Optional[Exception]) -> bool: - """ - 任务执行异常的处理. 需要判断任务是致命的, 还是可以恢复. - :param err: - :return: - """ - pass - - @abstractmethod - def done(self) -> None: - pass - - @abstractmethod - def destroy(self) -> None: - """ - 手动清理数据, 方便垃圾回收. - """ - pass - - def __enter__(self) -> "Session": - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - intercept = None - if exc_val is not None: - intercept = self.fail(exc_val) - else: - self.done() - self.destroy() - return intercept - - -class SessionStateValue(Protocol): - """ - show a way to easy the session state controlling - """ - - @classmethod - @abstractmethod - def load(cls, session: Session) -> Union[Self, None]: - pass - - @abstractmethod - def bind(self, session: Session) -> None: - pass - - @abstractmethod - def get_or_bind(self, session: Session) -> Self: - val = self.load(session) - if val is None: - self.bind(session) - val = self - return val diff --git a/ghostos/demo/src/examples/moss_codes/run_test_suite.py b/ghostos/demo/src/examples/moss_codes/run_test_suite.py index 4c5bccb1..49b48c8b 100644 --- a/ghostos/demo/src/examples/moss_codes/run_test_suite.py +++ b/ghostos/demo/src/examples/moss_codes/run_test_suite.py @@ -1,4 +1,4 @@ -from ghostos.core.moss import moss_test_suite, MossResult +from ghostos.core.moss import moss_test_suite, Execution from ghostos.libraries.file_editor import DirectoryEditor, DirectoryEditorImpl from ghostos.demo.src.examples.moss_codes import dir_editor_moss_code from os.path import dirname @@ -7,7 +7,7 @@ suite = moss_test_suite() - def show_test_result(case_name: str, result: MossResult): + def show_test_result(case_name: str, result: Execution): """ callback method for each test case in the target moss module. :param case_name: name of the test case diff --git a/ghostos/framework/actions/moss_action.py b/ghostos/framework/actions/moss_action.py index 4b5388f1..ecb104ac 100644 --- a/ghostos/framework/actions/moss_action.py +++ b/ghostos/framework/actions/moss_action.py @@ -8,7 +8,7 @@ from ghostos.core.messages import MessageType, Caller from ghostos.core.moss import MossRuntime, moss_message from ghostos.core.ghosts.operators import Operator -from ghostos.core.session import Session +from ghostos.core.runtime import Session from ghostos.common import Identifier from pydantic import BaseModel, Field from traceback import format_exc diff --git a/ghostos/framework/chatpreparers/assistant_preparer.py b/ghostos/framework/chatpreparers/assistant_preparer.py index add6c6d0..515e0012 100644 --- a/ghostos/framework/chatpreparers/assistant_preparer.py +++ b/ghostos/framework/chatpreparers/assistant_preparer.py @@ -1,7 +1,7 @@ from typing import Optional from ghostos.core.messages import Message, Role from ghostos.core.llms import PromptPipe, Prompt -from ghostos.core.session import TaskPayload +from ghostos.core.runtime import TaskPayload class OtherAgentOrTaskPipe(PromptPipe): diff --git a/ghostos/framework/eventbuses/__init__.py b/ghostos/framework/eventbuses/__init__.py index 4114b5fa..111a4d1b 100644 --- a/ghostos/framework/eventbuses/__init__.py +++ b/ghostos/framework/eventbuses/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.session import EventBus +from ghostos.core.runtime import EventBus from ghostos.framework.eventbuses.memimpl import MemEventBusImplProvider diff --git a/ghostos/framework/eventbuses/memimpl.py b/ghostos/framework/eventbuses/memimpl.py index 193cd728..3bbd4a97 100644 --- a/ghostos/framework/eventbuses/memimpl.py +++ b/ghostos/framework/eventbuses/memimpl.py @@ -1,7 +1,7 @@ from typing import Optional, Dict, Type -from ghostos.core.session import Event -from ghostos.core.session.events import EventBus +from ghostos.core.runtime import Event +from ghostos.core.runtime.events import EventBus from queue import Queue, Empty from ghostos.container import Provider, Container, BootstrappingProvider from ghostos.contracts.shutdown import Shutdown diff --git a/ghostos/framework/ghostos/basic.py b/ghostos/framework/ghostos/basic.py index ef1aa145..7a2a285a 100644 --- a/ghostos/framework/ghostos/basic.py +++ b/ghostos/framework/ghostos/basic.py @@ -7,7 +7,7 @@ from ghostos.core.ghostos import AbsGhostOS from ghostos.core.ghosts import Ghost from ghostos.core.messages import Stream -from ghostos.core.session import GoProcess, GoTaskStruct +from ghostos.core.runtime import GoProcess, GoTaskStruct from ghostos.contracts.shutdown import ShutdownProvider from ghostos.contracts.modules import Modules, DefaultModulesProvider from ghostos.framework.storage import FileStorageProvider diff --git a/ghostos/framework/ghostos/demo_os.py b/ghostos/framework/ghostos/demo_os.py index 995c198a..296ae377 100644 --- a/ghostos/framework/ghostos/demo_os.py +++ b/ghostos/framework/ghostos/demo_os.py @@ -2,7 +2,7 @@ from ghostos.core.ghosts import Ghost, GhostConf, Workspace, Shell from ghostos.core.messages import Stream -from ghostos.core.session import GoProcess, GoTaskStruct +from ghostos.core.runtime import GoProcess, GoTaskStruct from ghostos.contracts.logger import LoggerItf from ghostos.contracts.configs import Configs, YamlConfig diff --git a/ghostos/framework/ghosts/basic.py b/ghostos/framework/ghosts/basic.py index 757affd7..25b217c7 100644 --- a/ghostos/framework/ghosts/basic.py +++ b/ghostos/framework/ghosts/basic.py @@ -14,7 +14,7 @@ from ghostos.core.llms import LLMs from ghostos.core.moss import MossCompiler from ghostos.core.messages import Caller -from ghostos.core.session import ( +from ghostos.core.runtime import ( Session, Event, EventTypes, EventBus, GoTasks, GoProcesses, GoThreads, Messenger, GoProcess, GoTaskStruct, GoThreadInfo, @@ -220,7 +220,7 @@ def make_session( meta = root_thought.to_entity_meta() task = GoTaskStruct.new( task_id=task_id, - session_id=process.session_id, + shell_id=process.session_id, process_id=process.process_id, name=identifier.name, description=identifier.description, diff --git a/ghostos/framework/ghosts/demo.py b/ghostos/framework/ghosts/demo.py index edd9da38..695b5dff 100644 --- a/ghostos/framework/ghosts/demo.py +++ b/ghostos/framework/ghosts/demo.py @@ -1,7 +1,7 @@ from typing import Optional, List from ghostos.common import Identifier from ghostos.core.ghosts import GhostConf, Shell, Workspace -from ghostos.core.session import GoProcess, GoTaskStruct +from ghostos.core.runtime import GoProcess, GoTaskStruct from ghostos.contracts.modules import Modules from ghostos.core.messages import Stream from ghostos.framework.ghosts.basic import BasicGhost, InputsPipe diff --git a/ghostos/framework/messages/__init__.py b/ghostos/framework/messages/__init__.py index d09cea48..30630295 100644 --- a/ghostos/framework/messages/__init__.py +++ b/ghostos/framework/messages/__init__.py @@ -2,5 +2,5 @@ # default payloads -from ghostos.core.session import TaskPayload +from ghostos.core.runtime import TaskPayload from ghostos.core.messages.openai import CompletionUsagePayload diff --git a/ghostos/framework/messengers/__init__.py b/ghostos/framework/messengers/__init__.py index 5ad06f9e..3a73ac6a 100644 --- a/ghostos/framework/messengers/__init__.py +++ b/ghostos/framework/messengers/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.session import Messenger +from ghostos.core.runtime import Messenger from ghostos.framework.messengers.defaults import DefaultMessenger, TestMessengerProvider diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index b890acd9..09e448ef 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -1,11 +1,11 @@ from typing import Optional, Iterable, TYPE_CHECKING, Type, Dict, List from ghostos.container import Container, Provider -from ghostos.core.session.messenger import Messenger, Buffed +from ghostos.core.runtime.messenger import Messenger, Buffed from ghostos.core.messages import ( Message, Payload, Attachment, Role, MessageType, Buffer, Stream, ) -from ghostos.core.session.threads import GoThreadInfo +from ghostos.core.runtime.threads import GoThreadInfo from ghostos.core.llms import FunctionalToken from ghostos.framework.messages.buffers import DefaultBuffer from ghostos.helpers import uuid diff --git a/ghostos/framework/multitasks/basic.py b/ghostos/framework/multitasks/basic.py index a85dc82e..e11d0f41 100644 --- a/ghostos/framework/multitasks/basic.py +++ b/ghostos/framework/multitasks/basic.py @@ -2,7 +2,7 @@ from ghostos.core.ghosts import MultiTask, Operator, Ghost, Thought, NewTask from ghostos.core.llms import Prompt from ghostos.core.messages import MessageKind, Role -from ghostos.core.session.events import EventTypes +from ghostos.core.runtime.events import EventTypes from ghostos.framework.operators import WaitOnTasksOperator from ghostos.helpers import yaml_pretty_dump diff --git a/ghostos/framework/operators/action_ops.py b/ghostos/framework/operators/action_ops.py index de1df766..abcba00b 100644 --- a/ghostos/framework/operators/action_ops.py +++ b/ghostos/framework/operators/action_ops.py @@ -6,7 +6,7 @@ from ghostos.core.messages import ( MessageKind, MessageKindParser, Role, ) -from ghostos.core.session import ( +from ghostos.core.runtime import ( EventTypes, TaskState, GoTaskStruct, diff --git a/ghostos/framework/operators/event_ops.py b/ghostos/framework/operators/event_ops.py index 092fd18b..3029543a 100644 --- a/ghostos/framework/operators/event_ops.py +++ b/ghostos/framework/operators/event_ops.py @@ -3,7 +3,7 @@ from ghostos.core.ghosts import ( EventOperator, Ghost, Operator, get_event_operator ) -from ghostos.core.session import ( +from ghostos.core.runtime import ( TaskState, EventTypes, ) diff --git a/ghostos/framework/processes/__init__.py b/ghostos/framework/processes/__init__.py index 9dcbdad2..7492f429 100644 --- a/ghostos/framework/processes/__init__.py +++ b/ghostos/framework/processes/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.session import GoProcesses +from ghostos.core.runtime import GoProcesses from ghostos.framework.processes.storage_processes import StorageProcessImplProvider, WorkspaceProcessesProvider diff --git a/ghostos/framework/processes/storage_processes.py b/ghostos/framework/processes/storage_processes.py index da667df5..8e2c6b1b 100644 --- a/ghostos/framework/processes/storage_processes.py +++ b/ghostos/framework/processes/storage_processes.py @@ -1,7 +1,7 @@ from typing import Optional, Dict, Type import yaml -from ghostos.core.session import GoProcess -from ghostos.core.session.processes import GoProcesses +from ghostos.core.runtime import GoProcess +from ghostos.core.runtime.processes import GoProcesses from ghostos.contracts.storage import Storage from ghostos.contracts.logger import LoggerItf from ghostos.contracts.workspace import Workspace diff --git a/ghostos/framework/repliers/basic.py b/ghostos/framework/repliers/basic.py index 09d34f71..634c63a9 100644 --- a/ghostos/framework/repliers/basic.py +++ b/ghostos/framework/repliers/basic.py @@ -3,7 +3,7 @@ from ghostos.core.ghosts import Operator from ghostos.core.ghosts.schedulers import Replier from ghostos.core.messages import Role -from ghostos.core.session import GoTaskStruct +from ghostos.core.runtime import GoTaskStruct from ghostos.framework.operators import WaitsOperator, ThinkOperator, FinishOperator from ghostos.helpers import yaml_pretty_dump diff --git a/ghostos/framework/session/basic.py b/ghostos/framework/session/basic.py index a098b927..0c582acd 100644 --- a/ghostos/framework/session/basic.py +++ b/ghostos/framework/session/basic.py @@ -3,7 +3,7 @@ MessageKind, Role, Stream, MessageKindParser, MessageType, Buffer, Payload, Attachment, Message, ) -from ghostos.core.session import ( +from ghostos.core.runtime import ( Session, GoProcess, GoProcesses, GoThreadInfo, GoThreads, diff --git a/ghostos/framework/tasks/__init__.py b/ghostos/framework/tasks/__init__.py index fbd6c3a0..0ff1e27a 100644 --- a/ghostos/framework/tasks/__init__.py +++ b/ghostos/framework/tasks/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.session import GoTasks +from ghostos.core.runtime import GoTasks from ghostos.framework.tasks.storage_tasks import StorageTasksImplProvider, WorkspaceTasksProvider diff --git a/ghostos/framework/tasks/storage_tasks.py b/ghostos/framework/tasks/storage_tasks.py index 2c20d8fe..c63f89ac 100644 --- a/ghostos/framework/tasks/storage_tasks.py +++ b/ghostos/framework/tasks/storage_tasks.py @@ -1,6 +1,6 @@ from typing import Optional, List, Iterable, Dict, Type import yaml -from ghostos.core.session import TaskState, TaskBrief, GoTaskStruct, GoTasks +from ghostos.core.runtime import TaskState, TaskBrief, GoTaskStruct, GoTasks from ghostos.contracts.workspace import Workspace from ghostos.contracts.logger import LoggerItf from ghostos.contracts.storage import Storage diff --git a/ghostos/framework/threads/__init__.py b/ghostos/framework/threads/__init__.py index e05d25ed..f31950b1 100644 --- a/ghostos/framework/threads/__init__.py +++ b/ghostos/framework/threads/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.session import GoThreads +from ghostos.core.runtime import GoThreads from ghostos.framework.threads.storage_threads import MsgThreadRepoByStorageProvider, MsgThreadsRepoByWorkSpaceProvider diff --git a/ghostos/framework/threads/storage_threads.py b/ghostos/framework/threads/storage_threads.py index 532f622d..bb8e5d3e 100644 --- a/ghostos/framework/threads/storage_threads.py +++ b/ghostos/framework/threads/storage_threads.py @@ -1,5 +1,5 @@ from typing import Optional, Type -from ghostos.core.session import GoThreadInfo, GoThreads, SimpleMsgThread +from ghostos.core.runtime import GoThreadInfo, GoThreads, SimpleMsgThread from ghostos.contracts.workspace import Workspace from ghostos.contracts.storage import Storage from ghostos.contracts.logger import LoggerItf diff --git a/ghostos/helpers/__init__.py b/ghostos/helpers/__init__.py index 8ebdce12..9927ed42 100644 --- a/ghostos/helpers/__init__.py +++ b/ghostos/helpers/__init__.py @@ -18,7 +18,7 @@ ) from ghostos.helpers.io import BufferPrint from ghostos.helpers.time import Timeleft -from ghostos.helpers.hashes import md5 +from ghostos.helpers.hashes import md5, sha1, sha256 from ghostos.helpers.trans import gettext, ngettext, get_current_locale, GHOSTOS_DOMAIN from ghostos.helpers.coding import reflect_module_code, unwrap diff --git a/ghostos/prototypes/aifunc/app.py b/ghostos/prototypes/aifunc/app.py index 5713dc4d..c960c777 100644 --- a/ghostos/prototypes/aifunc/app.py +++ b/ghostos/prototypes/aifunc/app.py @@ -4,7 +4,7 @@ import yaml from typing import List, Dict -from ghostos.core.session import GoThreadInfo +from ghostos.core.runtime import GoThreadInfo from logging.config import dictConfig from ghostos.core.llms import Prompt from ghostos.core.messages import Message diff --git a/ghostos/prototypes/ghostfunc/driver.py b/ghostos/prototypes/ghostfunc/driver.py index fb7746ec..a0ee24fd 100644 --- a/ghostos/prototypes/ghostfunc/driver.py +++ b/ghostos/prototypes/ghostfunc/driver.py @@ -3,7 +3,7 @@ import yaml import importlib from ghostos.container import Container -from ghostos.core.session import GoThreadInfo, EventTypes, thread_to_chat +from ghostos.core.runtime import GoThreadInfo, EventTypes, thread_to_chat from ghostos.core.moss import MossRuntime, MossCompiler, PyContext from ghostos.core.llms import LLMs, LLMApi from ghostos.core.messages import Role, Message diff --git a/ghostos/scripts/aifunc_test.py b/ghostos/scripts/aifunc_test.py index 83d4d9e1..d7108d02 100644 --- a/ghostos/scripts/aifunc_test.py +++ b/ghostos/scripts/aifunc_test.py @@ -4,7 +4,7 @@ import yaml from typing import List, Dict -from ghostos.core.session import GoThreadInfo +from ghostos.core.runtime import GoThreadInfo from ghostos.scripts.logconf import prepare_logger from ghostos.core.llms import Prompt from ghostos.core.messages import Message diff --git a/ghostos/scripts/swe_test.py b/ghostos/scripts/swe_test.py deleted file mode 100644 index e966cfc2..00000000 --- a/ghostos/scripts/swe_test.py +++ /dev/null @@ -1,141 +0,0 @@ -import argparse -import sys -import os -import yaml -from typing import List, Dict - -from ghostos.core.session import GoThreadInfo -from ghostos.scripts.logconf import prepare_logger -from ghostos.core.llms import Prompt -from ghostos.core.messages import Message -from ghostos.core.moss import test_container -from ghostos.core.aifunc import DefaultAIFuncExecutorImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncExecutor -from ghostos.framework.logger import NamedLoggerProvider -from ghostos.framework.storage import FileStorageProvider -from ghostos.framework.llms import ConfigBasedLLMsProvider -from ghostos.framework.threads import MsgThreadRepoByStorageProvider -from ghostos.container import Container -from ghostos.contracts.modules import Modules -from ghostos.framework.configs import ConfigsByStorageProvider -from ghostos.helpers import import_from_path, yaml_pretty_dump -from rich.console import Console -from rich.panel import Panel -from rich.markdown import Markdown -from rich.prompt import Prompt - - -console = Console() - -prepare_logger() - - -def prepare_container(root_dir: str) -> Container: - container = test_container() - container.register(FileStorageProvider(root_dir)) - container.register(NamedLoggerProvider(logger_name="debug")) - container.register(MsgThreadRepoByStorageProvider(threads_dir='runtime/threads')) - container.register(ConfigsByStorageProvider("ghostos/configs")) - container.register(ConfigBasedLLMsProvider("llms/llms_conf.yaml")) - return container - - -def main() -> None: - parser = argparse.ArgumentParser( - description="run swe-evaluation aifunc test cases, show results", - ) - parser.add_argument( - "--case", '-c', - help="ghostos aifunc test case name in demo/ghostos/tests/aifunc_tests.yml", - type=str, - default="", - ) - parser.add_argument( - "--import_path", '-i', - help="the import path of the AIFunc instance, such as foo.bar:baz", - type=str, - default="evaluation.swe_bench_lite.debug_localization:example", - ) - parser.add_argument( - "--llm_api", '-l', - help="the llm api name", - type=str, - default="", - ) - parser.add_argument( - "--auto", '-a', - help="auto run the test or stop at each generations", - action="store_true", - default=False, - ) - - parsed = parser.parse_args(sys.argv[1:]) - llm_api = parsed.llm_api - demo_dir = os.path.abspath(os.path.dirname(__file__) + "/../../demo") - container = prepare_container(demo_dir) - - class TestDriverImpl(DefaultAIFuncDriverImpl): - console = console - - def on_message(self, message: Message) -> None: - self.console.print( - Panel( - Markdown(message.get_content()), - title=f"generated message ({self.name()})", - ) - ) - if not parsed.auto: - value = Prompt.ask("Continue?", choices=["y", "n"], default="y") - if value != "y": - exit(0) - - def on_chat(self, chat: Prompt) -> None: - for message in chat.get_messages(): - self.console.print(Panel( - Markdown(message.get_content()), - title=f"chat_info ({self.name()})", - )) - if not parsed.auto: - value = Prompt.ask("Continue?", choices=["y", "n"], default="y") - if value != "y": - exit(0) - - def on_system_messages(self, messages: List[Message]) -> None: - pass - - def on_save(self, manager: AIFuncExecutor, thread: GoThreadInfo) -> None: - current = thread.current - if current: - for message in current.messages(): - self.console.print( - Panel( - Markdown(message.get_content()), - title="thread new round message", - ) - ) - - manager_ = DefaultAIFuncExecutorImpl( - container=container, - llm_api_name=llm_api, - default_driver=TestDriverImpl, - ) - modules = container.force_fetch(Modules) - aifunc = import_from_path(parsed.import_path, modules.import_module) - if not isinstance(aifunc, AIFunc): - raise AttributeError(f'aifunc must be an instance of {AIFunc}, {aifunc} given') - - driver = manager_.get_driver(aifunc) - # print initialized thread. - thread_ = driver.initialize() - thread_content = yaml_pretty_dump(thread_.model_dump(exclude_defaults=True)) - console.print(Panel( - Markdown(f"```markdown\n{thread_content}\n```"), - title="initialized thread", - )) - - result = manager_.execute(aifunc) - console.print(result) - manager_.destroy() - - -if __name__ == "__main__": - main() diff --git a/ghostos/thoughts/basic.py b/ghostos/thoughts/basic.py index 331e0c79..b3aef59c 100644 --- a/ghostos/thoughts/basic.py +++ b/ghostos/thoughts/basic.py @@ -1,6 +1,6 @@ from typing import Optional, Generic, Iterable from abc import ABC, abstractmethod -from ghostos.core.session import Event, thread_to_chat +from ghostos.core.runtime import Event, thread_to_chat from ghostos.core.ghosts import Ghost, Operator, Action from ghostos.core.llms import LLMApi, PromptPipe, Prompt, run_prompt_pipeline from ghostos.core.messages import Role diff --git a/ghostos/thoughts/chat.py b/ghostos/thoughts/chat.py index fae1c13a..354d82de 100644 --- a/ghostos/thoughts/chat.py +++ b/ghostos/thoughts/chat.py @@ -6,7 +6,7 @@ from pydantic import Field from ghostos.core.llms import LLMApi -from ghostos.core.session import Event +from ghostos.core.runtime import Event __all__ = ["ChatThought", "ChatThoughtDriver"] diff --git a/ghostos/thoughts/directory_editor_thought.py b/ghostos/thoughts/directory_editor_thought.py index 1e3134a4..ab83bb6c 100644 --- a/ghostos/thoughts/directory_editor_thought.py +++ b/ghostos/thoughts/directory_editor_thought.py @@ -43,7 +43,7 @@ class Moss(Parent): # using TYPE_CHECKING to avoid reflect invalid importing to prompt. if TYPE_CHECKING: from ghostos.core.ghosts import Ghost - from ghostos.core.session import Event, Session, GoThreadInfo + from ghostos.core.runtime import Event, Session, GoThreadInfo from ghostos.core.llms import LLMApi from ghostos.core.moss import MossCompiler diff --git a/ghostos/thoughts/file_editor_thought.py b/ghostos/thoughts/file_editor_thought.py index 3af53428..fea9d7b8 100644 --- a/ghostos/thoughts/file_editor_thought.py +++ b/ghostos/thoughts/file_editor_thought.py @@ -1,7 +1,7 @@ from ghostos.core.ghosts import ModelThought, Ghost from ghostos.core.llms import LLMApi from ghostos.core.moss import PyContext, MossCompiler -from ghostos.core.session import Event, Session, GoThreadInfo +from ghostos.core.runtime import Event, Session, GoThreadInfo from ghostos.thoughts.moss_thought import BasicMossThoughtDriver, LLMThoughtDriver from ghostos.thoughts import file_editor_moss from ghostos.libraries.file_editor import FileEditorImpl, FileEditor diff --git a/ghostos/thoughts/magic_moss_thought.py b/ghostos/thoughts/magic_moss_thought.py index e57f3dbe..32b0b129 100644 --- a/ghostos/thoughts/magic_moss_thought.py +++ b/ghostos/thoughts/magic_moss_thought.py @@ -7,7 +7,7 @@ from ghostos.core.moss import PyContext, MossCompiler from ghostos.core.ghosts import Ghost, Action from ghostos.core.llms import LLMApi -from ghostos.core.session import Event, Session, GoThreadInfo +from ghostos.core.runtime import Event, Session, GoThreadInfo from ghostos.container import Provider import inspect from pydantic import Field diff --git a/ghostos/thoughts/moss_thought.py b/ghostos/thoughts/moss_thought.py index e6a8835a..4c3452f1 100644 --- a/ghostos/thoughts/moss_thought.py +++ b/ghostos/thoughts/moss_thought.py @@ -3,7 +3,7 @@ from ghostos.core.ghosts import Ghost, Action, ModelThought, Operator from ghostos.core.llms import LLMApi -from ghostos.core.session import Event, GoThreadInfo +from ghostos.core.runtime import Event, GoThreadInfo from ghostos.core.moss import MossCompiler, MossRuntime, PyContext from ghostos.thoughts.basic import LLMThoughtDriver from ghostos.framework.actions import MossAction diff --git a/ghostos/thoughts/pymodule_editor.py b/ghostos/thoughts/pymodule_editor.py index c9026d0f..9b4936a2 100644 --- a/ghostos/thoughts/pymodule_editor.py +++ b/ghostos/thoughts/pymodule_editor.py @@ -3,7 +3,7 @@ from ghostos.core.ghosts import ModelThought, Ghost from ghostos.core.llms import LLMApi from ghostos.core.moss import PyContext, MossCompiler -from ghostos.core.session import Event, Session, GoThreadInfo +from ghostos.core.runtime import Event, Session, GoThreadInfo from ghostos.thoughts.basic import LLMThoughtDriver from ghostos.thoughts.moss_thought import BasicMossThoughtDriver from ghostos.thoughts import pymodule_editor_moss diff --git a/tests/framework/eventbuses/test_mem_impl.py b/tests/framework/eventbuses/test_mem_impl.py index b4eaeeaa..59f3090a 100644 --- a/tests/framework/eventbuses/test_mem_impl.py +++ b/tests/framework/eventbuses/test_mem_impl.py @@ -1,5 +1,5 @@ from ghostos.framework.eventbuses.memimpl import MemEventBusImpl -from ghostos.core.session.events import EventTypes +from ghostos.core.runtime.events import EventTypes def test_mem_impl_send_pop_event(): diff --git a/tests/framework/messenger/test_messenger.py b/tests/framework/messenger/test_messenger.py index 540bcd38..989800ef 100644 --- a/tests/framework/messenger/test_messenger.py +++ b/tests/framework/messenger/test_messenger.py @@ -1,6 +1,6 @@ from ghostos.framework.messengers import Messenger, DefaultMessenger from ghostos.framework.streams import EmptyStream -from ghostos.core.session.threads import GoThreadInfo +from ghostos.core.runtime.threads import GoThreadInfo from ghostos.core.messages import Message from ghostos.core.llms import FunctionalToken diff --git a/tests/framework/streams/test_arr_connection.py b/tests/framework/streams/test_arr_connection.py index d34f959b..25e07706 100644 --- a/tests/framework/streams/test_arr_connection.py +++ b/tests/framework/streams/test_arr_connection.py @@ -3,7 +3,7 @@ from ghostos.core.messages import Message from ghostos.framework.streams import new_connection, Stream from ghostos.framework.messengers import DefaultMessenger -from ghostos.core.session import GoThreadInfo +from ghostos.core.runtime import GoThreadInfo from ghostos.core.llms import FunctionalToken from threading import Thread diff --git a/tests/framework/tasks/test_storage_impl.py b/tests/framework/tasks/test_storage_impl.py index 4f49a0a5..056aed11 100644 --- a/tests/framework/tasks/test_storage_impl.py +++ b/tests/framework/tasks/test_storage_impl.py @@ -1,7 +1,7 @@ from ghostos.framework.storage import MemStorage from ghostos.framework.tasks.storage_tasks import StorageGoTasksImpl from ghostos.framework.logger import FakeLogger -from ghostos.core.session import GoTaskStruct +from ghostos.core.runtime import GoTaskStruct from ghostos.entity import EntityMeta @@ -10,7 +10,7 @@ def test_storage_tasks_impl(): tasks = StorageGoTasksImpl(storage, FakeLogger()) task = GoTaskStruct.new( task_id="task_id", - session_id="session_id", + shell_id="session_id", process_id="process_id", name="name", description="description", diff --git a/tests/python/test_dict.py b/tests/python/test_dict.py index 7d2c41a7..e0b77fd3 100644 --- a/tests/python/test_dict.py +++ b/tests/python/test_dict.py @@ -47,3 +47,19 @@ class Bar(TypedDict, total=True): bar2 = Bar(a="world") assert "a" in bar2 + + +def test_dict_sort(): + a = {3: 3, 4: 4, 1: 1, 2: 2, } + values = sorted(a.keys()) + assert values == [1, 2, 3, 4] + + +def test_get_dict_by_str_type(): + class Key(str): + + def get(self, data_: dict): + return data_.get(str(self), None) + + data = {"a": 1, "b": 2} + assert Key("a").get(data) == 1 From 5e7f599d2076ffb0b38e1bce5c87b49de82ed3f7 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 10 Nov 2024 03:26:23 +0800 Subject: [PATCH 060/148] dev: refact moss make it simpler --- ghostos/contracts/documents.py | 2 +- ghostos/core/__init__.py | 1 - ghostos/core/abcd/__init__.py | 2 +- ghostos/core/abcd/concepts.py | 97 ++--- ghostos/core/abcd/ghosts.py | 4 +- ghostos/core/abcd/prompters.py | 16 + ghostos/core/abcd/utils.py | 2 +- .../core/{moss => agents}/functional_token.py | 2 +- ghostos/core/agents/moss_agent.py | 168 +++++++-- ghostos/core/{wall.py => agents/template.py} | 0 ghostos/core/agents/utils.py | 2 +- ghostos/core/aifunc/driver.py | 2 +- ghostos/core/aifunc/func.py | 6 +- ghostos/core/aifunc/interfaces.py | 2 +- ghostos/core/aifunc/repository.py | 2 +- ghostos/core/ghosts/actions.py | 6 +- ghostos/core/ghosts/assistants.py | 4 +- ghostos/core/ghosts/ghost.py | 4 +- ghostos/core/ghosts/thoughts.py | 6 +- ghostos/core/ghosts/utils.py | 2 +- ghostos/core/llms/prompt.py | 4 +- ghostos/core/llms/tools.py | 2 +- ghostos/core/moss/__init__.py | 24 +- ghostos/core/moss/{abc.py => abcd.py} | 102 ++--- ghostos/core/moss/decorators.py | 7 +- ghostos/core/moss/examples/baseline.py | 37 +- ghostos/core/moss/examples/mem_baseline.py | 57 --- ghostos/core/moss/examples/test_suite.py | 2 +- ghostos/core/moss/exports.py | 173 --------- ghostos/core/moss/impl.py | 175 +++++---- ghostos/core/moss/lifecycle.py | 68 ++-- ghostos/core/moss/prompts.py | 314 ++------------- ghostos/core/moss/pycontext.py | 357 ++++++++++-------- ghostos/core/moss/test_suites.py | 2 +- ghostos/core/moss/utils.py | 18 +- ghostos/core/runtime/tasks.py | 6 +- ghostos/core/runtime/threads.py | 2 +- ghostos/entity.py | 239 ++++++------ ghostos/framework/actions/moss_action.py | 6 +- .../chatpreparers/assistant_preparer.py | 2 +- ghostos/framework/documents/storage_impl.py | 3 +- ghostos/framework/ghosts/basic.py | 2 +- ghostos/framework/ghosts/demo.py | 2 +- ghostos/framework/multitasks/basic.py | 2 +- ghostos/helpers/__init__.py | 1 + ghostos/helpers/tree_sitter.py | 4 +- ghostos/{common.py => identifier.py} | 167 +------- ghostos/prompter.py | 163 ++++++++ ghostos/prototypes/aifunc/app.py | 4 +- ghostos/prototypes/ghostfunc/prepare.py | 4 +- ghostos/prototypes/mosstemp/template.py | 2 +- .../streamlitapp/pages/aifuncs/detail.py | 2 +- .../streamlitapp/pages/aifuncs/index.py | 2 +- ghostos/scripts/aifunc_test.py | 4 +- ghostos/thoughts/basic.py | 2 +- ghostos/thoughts/file_editor_thought.py | 2 +- ghostos/thoughts/pymodule_editor.py | 2 +- tests/core/moss/examples/test_baseline.py | 107 ++---- tests/core/moss/test_decorators.py | 6 +- tests/core/moss/test_prompts.py | 12 +- tests/core/moss/test_pycontext.py | 84 ++--- tests/helpers/test_tree_sitter.py | 4 +- tests/python/test_class.py | 7 + tests/test_abc.py | 6 +- tests/test_entity.py | 38 ++ 65 files changed, 1098 insertions(+), 1461 deletions(-) create mode 100644 ghostos/core/abcd/prompters.py rename ghostos/core/{moss => agents}/functional_token.py (98%) rename ghostos/core/{wall.py => agents/template.py} (100%) rename ghostos/core/moss/{abc.py => abcd.py} (85%) delete mode 100644 ghostos/core/moss/examples/mem_baseline.py delete mode 100644 ghostos/core/moss/exports.py rename ghostos/{common.py => identifier.py} (51%) create mode 100644 ghostos/prompter.py create mode 100644 tests/test_entity.py diff --git a/ghostos/contracts/documents.py b/ghostos/contracts/documents.py index ef3fadc6..ca83cdfc 100644 --- a/ghostos/contracts/documents.py +++ b/ghostos/contracts/documents.py @@ -1,7 +1,7 @@ from typing import List, Iterable from typing_extensions import Self from abc import ABC, abstractmethod -from ghostos.common import Identical, Identifier +from ghostos.identifier import Identical, Identifier class Documents(Identical, ABC): diff --git a/ghostos/core/__init__.py b/ghostos/core/__init__.py index ca47dd46..e69de29b 100644 --- a/ghostos/core/__init__.py +++ b/ghostos/core/__init__.py @@ -1 +0,0 @@ -from ghostos.core.ghostos import GhostOS diff --git a/ghostos/core/abcd/__init__.py b/ghostos/core/abcd/__init__.py index 6ae63ca6..c151b58f 100644 --- a/ghostos/core/abcd/__init__.py +++ b/ghostos/core/abcd/__init__.py @@ -1,2 +1,2 @@ -from .concepts import Ghost, GhostDriver, Operator, Session, GhostOS +from .concepts import Ghost, GhostDriver, Operator, Session, GhostOS, StateValue, Action from .ghosts import Agent diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index fc73ee06..b979cb4b 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -5,7 +5,9 @@ ) from abc import ABC, abstractmethod -from ghostos.common import Identifiable, Entity, EntityType, EntityMeta, to_entity_meta, from_entity_meta +from ghostos.identifier import Identifiable +from ghostos.entity import EntityType +from ghostos.prompter import Prompter from ghostos.core.runtime import ( TaskState, ) @@ -13,10 +15,9 @@ from ghostos.core.runtime.tasks import GoTaskStruct, TaskBrief from ghostos.core.runtime.threads import GoThreadInfo from ghostos.core.messages import MessageKind, Message, Stream, Caller, Payload +from ghostos.contracts.logger import LoggerItf from ghostos.container import Container -from ghostos.helpers import generate_import_path from pydantic import BaseModel -import json """ # Core Concepts of GhostOS framework. @@ -48,7 +49,7 @@ 2. ghost shall be defined by code, which can be generated by meta-agents. """ -__all__ = ("Ghost", "Session", "GhostDriver", "Props", "GhostOS", "Operator", "StateValue") +__all__ = ("Ghost", "Session", "GhostDriver", "GhostOS", "Operator", "StateValue") class Ghost(Identifiable, EntityType, ABC): @@ -61,12 +62,12 @@ class Ghost(Identifiable, EntityType, ABC): 4. driver is """ - Props: ClassVar[Union[Type[Props], None]] - """ props is the model of properties that passed from caller, and alternative during runtime""" - - Artifact: ClassVar[Union[Type, None]] + Artifact: ClassVar[Union[Type, None]] = None """ the model of the ghost's artifact, is completing during runtime""" + Context: ClassVar[Type[Prompter], None] = None + """ the model of the ghost's context, is completing during runtime'""" + Driver: Type[GhostDriver] = None """ separate ghost's methods to the driver class, make sure the ghost is simple and clear to other ghost""" @@ -107,6 +108,23 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: pass +class Context(Payload, Prompter, ABC): + """ + context model that ghost care about + """ + key = "ghostos_context" + + @abstractmethod + def self_prompt(self, container: Container, depth: int = 0) -> str: + """ + generate prompt from model values with libraries that container provides. + :param container: IoC container provides library implementation. + :param depth: the depth of the context, usually decide the prompt title level + :return: natural language prompt + """ + pass + + class Operator(Protocol): """ Operator to operating the GhostOS through the Session encapsulation. @@ -139,57 +157,14 @@ def destroy(self): pass -class Props(Payload, Entity, ABC): - """ - is strong-typed model for runtime alternative properties of a ghost. - """ - key = "ghost_props" - """props is also a Payload class, which can be bound to event or messages""" - - __children__: List[Props] - """ children is fractal sub context nodes""" - - def with_children(self, *children: Props) -> Props: - self.__children__.extend(children) - return self - +class Action(Protocol): @abstractmethod - def self_prompt(self, container: Container, depth: int = 0) -> str: - """ - generate prompt by self, without children - :param container: - :param depth: - :return: - """ + def name(self) -> str: pass - def get_prompt(self, container: Container, depth: int = 0) -> str: - self_prompt = self.self_prompt(container, depth=depth) - prompts = [self_prompt] - for child in self.__children__: - prompts.append(child.get_prompt(container, depth=depth + 1)) - return "\n\n".join([prompt.rstrip() for prompt in prompts]) - - def __to_entity_meta__(self) -> EntityMeta: - type_ = generate_import_path(self.__class__) - ctx_data = self.model_dump(exclude_defaults=True) - children_data = [] - for child in self.__children__: - children_data.append(to_entity_meta(child)) - data = {"ctx": ctx_data, "children": children_data} - content = json.dumps(data) - return EntityMeta(type=type_, content=content.encode()) - - @classmethod - def __from_entity_meta__(cls, meta: EntityMeta) -> Self: - data = json.loads(meta["content"]) - ctx_data = data["ctx"] - children_data = data["children"] - result = cls(**ctx_data) - children = [] - for child in children_data: - children.append(from_entity_meta(child)) - return result.with_children(*children) + @abstractmethod + def run(self, session: Session, caller: Caller) -> Union[Operator, None]: + pass class GhostOS(Protocol): @@ -280,7 +255,7 @@ def is_done(self) -> bool: def respond( self, inputs: Iterable[Message], - props: Optional[G.Props] = None, + context: Optional[Prompter] = None, *, history: Optional[Iterable[Message]] = None, ) -> Iterable[Message]: @@ -416,6 +391,8 @@ class Scope(BaseModel): thread: GoThreadInfo """thread info of the task""" + logger: LoggerItf + @abstractmethod def is_alive(self) -> bool: """ @@ -437,7 +414,7 @@ def ghost(self) -> G: pass @abstractmethod - def get_props(self) -> G.Props: + def get_context(self) -> Optional[Prompter]: """ current context for the ghost """ @@ -495,7 +472,7 @@ def self_finish(self, status: str = "", *replies: MessageKind) -> Operator: pass @abstractmethod - def self_fail(self, status: str, *replies: MessageKind) -> Operator: + def self_fail(self, status: str = "", *replies: MessageKind) -> Operator: """ self task failed. :param status: describe status of the task @@ -504,7 +481,7 @@ def self_fail(self, status: str, *replies: MessageKind) -> Operator: pass @abstractmethod - def self_wait(self, status: str, *replies: MessageKind) -> Operator: + def self_wait(self, status: str = "", *replies: MessageKind) -> Operator: """ wait for the parent task or user to provide more information or further instruction. :param status: describe current status diff --git a/ghostos/core/abcd/ghosts.py b/ghostos/core/abcd/ghosts.py index c9f7ff28..66e1b2b8 100644 --- a/ghostos/core/abcd/ghosts.py +++ b/ghostos/core/abcd/ghosts.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -from ghostos.common import Identifier +from ghostos.identifier import Identifier from pydantic import BaseModel -from .concepts import Ghost, GhostDriver +from .concepts import Ghost """ Some ghost prototypes. diff --git a/ghostos/core/abcd/prompters.py b/ghostos/core/abcd/prompters.py new file mode 100644 index 00000000..5651ac12 --- /dev/null +++ b/ghostos/core/abcd/prompters.py @@ -0,0 +1,16 @@ +from .concepts import Prompter +from ghostos.container import Container +from pydantic import Field + + +class SystemPrompter(Prompter): + """ + root of the prompt + """ + meta_prompt: str = Field( + default="", + description="meta prompt for agent", + ) + + def self_prompt(self, container: Container, depth: int = 0) -> str: + return self.meta_prompt diff --git a/ghostos/core/abcd/utils.py b/ghostos/core/abcd/utils.py index 9dfb673a..da4271c4 100644 --- a/ghostos/core/abcd/utils.py +++ b/ghostos/core/abcd/utils.py @@ -1,6 +1,6 @@ from typing import TypeVar, Optional, Type, Union from ghostos.helpers import import_class_from_path, generate_import_path, md5 -from ghostos.common import get_identifier, to_entity_meta +from ghostos.identifier import get_identifier, to_entity_meta from ghostos.core.runtime import Runtime, GoTaskStruct from .concepts import Ghost, GhostDriver diff --git a/ghostos/core/moss/functional_token.py b/ghostos/core/agents/functional_token.py similarity index 98% rename from ghostos/core/moss/functional_token.py rename to ghostos/core/agents/functional_token.py index a234d39b..02cec18b 100644 --- a/ghostos/core/moss/functional_token.py +++ b/ghostos/core/agents/functional_token.py @@ -1,5 +1,5 @@ from ghostos.core.llms import FunctionalToken -from ghostos.core.moss.abc import MossPrompter +from ghostos.core.moss.abcd import MossPrompter from pydantic import BaseModel, Field __all__ = ['MOSSArgument', 'DEFAULT_MOSS_FUNCTIONAL_TOKEN', 'DEFAULT_MOSS_PROMPT_TEMPLATE', 'get_default_moss_prompt'] diff --git a/ghostos/core/agents/moss_agent.py b/ghostos/core/agents/moss_agent.py index b92aed3c..a9fc89e2 100644 --- a/ghostos/core/agents/moss_agent.py +++ b/ghostos/core/agents/moss_agent.py @@ -1,13 +1,17 @@ -from typing import Union, Optional, Protocol, Dict, Any, TypeVar +from typing import Union, Optional, Protocol, Dict, Any, TypeVar, Generic, List from types import ModuleType from abc import ABC, abstractmethod -from ghostos.common import Identifier + +from ghostos.identifier import Identifier from pydantic import BaseModel, Field from ghostos.helpers import import_from_path, generate_import_path -from ghostos.core.abcd import GhostDriver, Operator, Agent -from ghostos.core.runtime import Event, Runtime +from ghostos.core.abcd import GhostDriver, Operator, Agent, Session, StateValue, Action +from ghostos.core.runtime import Event, Runtime, GoThreadInfo from ghostos.core.moss import MossCompiler, PyContext +from ghostos.core.messages import Message, Caller +from ghostos.core.llms import LLMs, LLMApi, Prompt, PromptPipe +from ghostos.container import Container from .utils import make_agent_task_id @@ -19,9 +23,6 @@ class MossAgent(BaseModel, Agent): Artifact = None """ subclass of MossAgent could have a GoalType, default is None""" - Props = None - """ subclass of MossAgent could have a ContextType, default is None""" - moss_module: str = Field(description="Moss module name for the agent") instruction: str = Field(description="The instruction that the agent should follow") @@ -39,10 +40,42 @@ def __identifier__(self) -> Identifier: description=self.description, ) -M = TypeVar("M", bound=MossAgent) -class Moss(Protocol): - pass +A = TypeVar("A", bound=MossAgent) + + +class Moss(Generic[A], ABC): + """ + the model-oriented operating system defined in the moss module + useful for: + 1. inject dynamic implementations by IoC Container + 2. inject session state values. + + and the state values injected to the Moss instance, will bind to the session as default. + """ + + self: A + """self moss agent""" + + props: A.Props + """self props""" + + +# --- lifecycle methods of moss agent --- # + + +def __agent_goal__(agent: MossAgent, moss: Moss): + """ + get the agent goal, default is None + """ + return None + + +def __agent_contextual_prompt__(agent: A, moss: Moss) -> str: + """ + magic function that defined in the moss module, generate contextual prompt for the agent + """ + return "" # @@ -51,7 +84,7 @@ class Moss(Protocol): """ magic attr that predefine an agent of the module with given persona and instruction.""" -def __agent_moss_injections__(agent: MossAgent, session: Session) -> Dict[str, Any]: +def __agent_moss_injections__(agent: A, session: Session[A]) -> Dict[str, Any]: """ manually define some of the injections to the Moss Class. if a property of Moss is not injected here, the session container will inject it by typehint. @@ -62,47 +95,97 @@ def __agent_moss_injections__(agent: MossAgent, session: Session) -> Dict[str, A } -def __agent_context_prompt__(agent: MossAgent, moss: Moss) -> str: - return "" - - -def __agent_goal__(agent: MossAgent, moss: Moss): - """ - get the agent goal, default is None - """ - return None - - -def __agent_on_event_type__(agent: MossAgent, session: Session, moss: Moss): +def __agent_on_event_type__(agent: A, session: Session[A], moss: Moss): pass class MossAgentDriver(GhostDriver[MossAgent]): - def make_task_id(self, runtime: Runtime, parent_task_id: Optional[str] = None) -> str: - return make_agent_task_id(runtime, self.ghost, parent_task_id) - def get_module(self) -> ModuleType: m = import_from_path(self.ghost.moss_module) return m def get_goal(self, session: Session) -> Optional[MossAgent.GoalType]: m = self.get_module() - fn = __agent_goal__ - if __agent_goal__.__name__ in m.__dict__: - fn = getattr(m, __agent_goal__.__name__) - return fn(self.ghost, session) + if __agent_goal__.__name__ not in m.__dict__: + return None + fn = getattr(m, __agent_goal__.__name__) + compiler = self.get_compiler(session) + with compiler: + runtime = compiler.compile(self.ghost.moss_module) + with runtime: + moss = runtime.moss() + return fn(self.ghost, moss) def on_event(self, session: Session, event: Event) -> Union[Operator, None]: + event = self.filter_event(session, event) + if event is None: + return None + + thread = self.update_with_event(session, event) + compiler = self.get_compiler(session) with compiler: rtm = compiler.compile(self.ghost.compile_module) with rtm: moss = rtm.moss() + # prepare instructions. + instructions = self.get_instructions(session, moss) + # prepare prompt + prompt = thread.to_prompt(instructions) + prompt = self.prepare_prompt(session, moss, prompt) + actions = self.get_actions(session) + for action in actions: + if isinstance(action, PromptPipe): + prompt = action.update_prompt(prompt) + + # call llm + llm = self.get_llmapi(session) + messenger = session.messenger() + llm.deliver_chat_completion(prompt, messenger) + + # handle actions + messages, callers = messenger.flush() + for caller in callers: + if caller.name in actions: + action = actions[caller.name] + op = action.run(session, caller) + if op is not None: + return op + return session.self_wait() + + def filter_event(self, session: Session, event: Event) -> Union[Event, None]: + return event + + def get_instructions(self, session: Session, moss: object) -> List[Message]: + # instruction of moss agent is composed by: + # 1. meta prompt. + # 2. persona. + # 3. instruction. + # 4. props + # 5. injections. + # 6. contextual prompt. + pass + + def prepare_prompt(self, session: Session, moss: object, prompt: Prompt) -> Prompt: + pass + + def get_actions(self, session: Session) -> Dict[str, Action]: + pass + + def get_llmapi(self, session: Session) -> LLMApi: + llms = session.container.force_fetch(LLMs) + llmapi_name = self.ghost.llmapi_name + return llms.get_api(llmapi_name) + + def update_with_event(self, session: Session, event: Event) -> GoThreadInfo: + session.thread.new_turn(event) + return session.thread def get_compiler(self, session: Session) -> MossCompiler: pycontext = self.get_pycontext(session) - compiler = session.container().force_fetch(MossCompiler) + + compiler = session.container.force_fetch(MossCompiler) compiler = compiler.join_context(pycontext) compiler = compiler.with_locals(Optional=Optional) @@ -115,19 +198,34 @@ def get_compiler(self, session: Session) -> MossCompiler: if __agent_moss_injections__.__name__ in module.__dict__: injection_fn = getattr(module, __agent_moss_injections__.__name__) injections = injection_fn(self.ghost, session) - compiler = compiler.injects(**injections) - + if injections: + compiler = compiler.injects(**injections) return compiler def get_pycontext(self, session: Session) -> PyContext: pycontext_key = generate_import_path(PyContext) - data = session.properties.get(pycontext_key, None) + data = session.state.get(pycontext_key, None) if data is not None: pycontext = PyContext(**data) else: pycontext = PyContext( - module=self.get_module(), + module=self.ghost.moss_module, ) return pycontext + +META_PROMPT = """ +""" + + +class MossAction(Action, PromptPipe): + + def name(self) -> str: + return "moss" + + def update_prompt(self, prompt: Prompt) -> Prompt: + pass + + def run(self, session: Session, caller: Caller) -> Union[Operator, None]: + pass # diff --git a/ghostos/core/wall.py b/ghostos/core/agents/template.py similarity index 100% rename from ghostos/core/wall.py rename to ghostos/core/agents/template.py diff --git a/ghostos/core/agents/utils.py b/ghostos/core/agents/utils.py index 363fd245..27b27dd0 100644 --- a/ghostos/core/agents/utils.py +++ b/ghostos/core/agents/utils.py @@ -1,6 +1,6 @@ from typing import Optional, Iterable, List, Callable, Self, ClassVar, Union, Type, TypeVar, Generic from abc import ABC, abstractmethod -from ghostos.common import Identical, Identifier, EntityMeta, to_entity_meta, get_identifier +from ghostos.identifier import Identical, Identifier, EntityMeta, to_entity_meta, get_identifier from ghostos.core.runtime import ( Event, Session, GoTaskStruct, Runtime, ) diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index 9f99b4c3..27439766 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -10,7 +10,7 @@ get_aifunc_instruction, get_aifunc_result_type, get_aifunc_pycontext, get_aifunc_llmapi, ) from ghostos.core.llms import LLMs, Prompt -from ghostos.core.moss.abc import MossRuntime +from ghostos.core.moss.abcd import MossRuntime from ghostos.core.runtime import GoThreadInfo, EventTypes, GoThreads, thread_to_chat from ghostos.core.messages import Role, Message, Stream from ghostos.container import Container diff --git a/ghostos/core/aifunc/func.py b/ghostos/core/aifunc/func.py index 1070a767..418bd6cd 100644 --- a/ghostos/core/aifunc/func.py +++ b/ghostos/core/aifunc/func.py @@ -3,7 +3,7 @@ from abc import ABC from pydantic import BaseModel from ghostos.helpers import generate_import_path, import_from_path -from ghostos.common import PrompterClass +from ghostos.identifier import PromptAbleClass from ghostos.core.llms import LLMs, LLMApi from ghostos.core.moss.utils import make_class_prompt, add_comment_mark from ghostos.core.moss.prompts import get_class_magic_prompt @@ -19,7 +19,7 @@ ] -class AIFunc(PrompterClass, BaseModel, ABC): +class AIFunc(PromptAbleClass, BaseModel, ABC): """ Model interface for an AIFunc arguments, always followed by an AIFuncResult Model. The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need. @@ -47,7 +47,7 @@ def func_name(cls) -> str: return generate_import_path(cls) -class AIFuncResult(PrompterClass, BaseModel, ABC): +class AIFuncResult(PromptAbleClass, BaseModel, ABC): """ the AIFuncResult Model """ diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index 855ac343..56bdeea3 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -6,7 +6,7 @@ from ghostos.core.llms import LLMApi, Prompt from ghostos.core.runtime import GoThreadInfo from ghostos.core.messages import Message, Stream, Payload -from ghostos.common import Identifier +from ghostos.identifier import Identifier from ghostos.helpers import generate_import_path, uuid from ghostos.container import Container from ghostos.entity import EntityMeta, model_to_entity_meta, model_from_entity_meta diff --git a/ghostos/core/aifunc/repository.py b/ghostos/core/aifunc/repository.py index 1cfee700..cf7f0002 100644 --- a/ghostos/core/aifunc/repository.py +++ b/ghostos/core/aifunc/repository.py @@ -2,7 +2,7 @@ from typing import List, Type, Dict, Set, Iterable, Optional from types import ModuleType -from ghostos.common import Identifier, identify_class +from ghostos.identifier import Identifier, identify_class from ghostos.core.aifunc import AIFunc, ExecFrame from ghostos.core.aifunc.interfaces import AIFuncRepository from ghostos.contracts.configs import YamlConfig, Configs diff --git a/ghostos/core/ghosts/actions.py b/ghostos/core/ghosts/actions.py index 4a4f4d7e..025d6095 100644 --- a/ghostos/core/ghosts/actions.py +++ b/ghostos/core/ghosts/actions.py @@ -6,7 +6,7 @@ from ghostos.core.ghosts.operators import Operator from ghostos.core.messages.message import Caller from ghostos.core.runtime import Session -from ghostos.common import Identical, Identifier +from ghostos.identifier import Identical, Identifier from pydantic import BaseModel __all__ = ['Action', 'ToolAction'] @@ -18,7 +18,7 @@ class Action(Identical, PromptPipe, ABC): """ @abstractmethod - def process(self, chat: Prompt) -> Prompt: + def update_prompt(self, chat: Prompt) -> Prompt: """ Action update the chat with messages, tool, functional_tokens, etc. :param chat: origin chat. @@ -62,7 +62,7 @@ def do_act(self, container: "Container", session: Session, arguments: A) -> Opti """ pass - def process(self, chat: Prompt) -> Prompt: + def update_prompt(self, chat: Prompt) -> Prompt: """ 将工具注入到 chat. """ diff --git a/ghostos/core/ghosts/assistants.py b/ghostos/core/ghosts/assistants.py index b8117a38..d48631b8 100644 --- a/ghostos/core/ghosts/assistants.py +++ b/ghostos/core/ghosts/assistants.py @@ -1,6 +1,6 @@ from typing import Optional, TypeVar, Generic, Type from abc import ABC, abstractmethod -from ghostos.common import Identical, Identifier +from ghostos.identifier import Identical, Identifier from ghostos.core.ghosts import Ghost from ghostos.core.ghosts.thoughts import Thought, ModelThought from ghostos.helpers import generate_import_path, md5, import_from_path @@ -46,7 +46,7 @@ def task_id(self, g: Ghost) -> str: """ generate unique task id for assistant instance in the process """ - process_id = g.session().process().process_id + process_id = g.session().update_prompt().process_id name = self.assistant.identifier().name assistant_type = generate_import_path(type(self.assistant)) thought_type = generate_import_path(type(self.root_thought(g))) diff --git a/ghostos/core/ghosts/ghost.py b/ghostos/core/ghosts/ghost.py index 249d2fec..eb5ce53f 100644 --- a/ghostos/core/ghosts/ghost.py +++ b/ghostos/core/ghosts/ghost.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from ghostos.entity import ModelEntity, EntityMeta, EntityFactory from ghostos.container import Container -from ghostos.common import Identical, Identifier +from ghostos.identifier import Identical, Identifier from ghostos.contracts.logger import LoggerItf from ghostos.contracts.modules import Modules from ghostos.contracts.configs import Configs @@ -146,7 +146,7 @@ def actions(self) -> List["Action"]: ghost default actions """ session = self.session() - if session.task().task_id == session.process().main_task_id: + if session.task().task_id == session.update_prompt().main_task_id: return list(self.shell().actions()) return [] diff --git a/ghostos/core/ghosts/thoughts.py b/ghostos/core/ghosts/thoughts.py index 2d3a4c44..d3a8e1dd 100644 --- a/ghostos/core/ghosts/thoughts.py +++ b/ghostos/core/ghosts/thoughts.py @@ -5,7 +5,7 @@ from ghostos.core.runtime import Event, GoThreadInfo, Session from ghostos.core.ghosts.ghost import Ghost from ghostos.core.ghosts.operators import Operator -from ghostos.common import Identical, Identifier, PrompterClass +from ghostos.identifier import Identical, Identifier, PromptAbleClass from ghostos.helpers import uuid, generate_import_path from pydantic import Field @@ -60,7 +60,7 @@ class Thought(ABC): return inspect.getsource(cls) -class ModelThought(Thought, ModelEntity, PrompterClass, ABC): +class ModelThought(Thought, ModelEntity, PromptAbleClass, ABC): """ The abstract model of the thought based by pydantic.BaseModel. """ @@ -168,7 +168,7 @@ def on_created(self, g: Ghost, e: Event) -> Optional[Operator]: thread = session.thread() task = session.task() - process_id = session.process().process_id + process_id = session.update_prompt().process_id task_name = task.name.replace("/", "_") task_name = task_name.replace(".", "_") thread.save_file = f"process_{process_id}/task_{task_name}_thread_{thread.id}.yml" diff --git a/ghostos/core/ghosts/utils.py b/ghostos/core/ghosts/utils.py index 95d45b26..acb1275f 100644 --- a/ghostos/core/ghosts/utils.py +++ b/ghostos/core/ghosts/utils.py @@ -44,7 +44,7 @@ def initialize(self) -> None: initialize ghost """ session = self.ghost.session() - process = session.process() + process = session.update_prompt() if process.initialized: return None task_id = process.main_task_id diff --git a/ghostos/core/llms/prompt.py b/ghostos/core/llms/prompt.py index 99b618c4..ebb2b106 100644 --- a/ghostos/core/llms/prompt.py +++ b/ghostos/core/llms/prompt.py @@ -171,7 +171,7 @@ class PromptPipe(ABC): """ @abstractmethod - def process(self, prompt: Prompt) -> Prompt: + def update_prompt(self, prompt: Prompt) -> Prompt: pass @@ -180,7 +180,7 @@ def run_prompt_pipeline(prompt: Prompt, pipeline: Iterable[PromptPipe]) -> Promp 通过多个 filter 来加工 chat. """ for f in pipeline: - prompt = f.process(prompt) + prompt = f.update_prompt(prompt) return prompt diff --git a/ghostos/core/llms/tools.py b/ghostos/core/llms/tools.py index 7abb0732..67afd5fe 100644 --- a/ghostos/core/llms/tools.py +++ b/ghostos/core/llms/tools.py @@ -5,7 +5,7 @@ from typing import Dict, Optional, Type from pydantic import BaseModel, Field -from ghostos.common import Identical, Identifier +from ghostos.identifier import Identical, Identifier from ghostos.core.messages import Caller diff --git a/ghostos/core/moss/__init__.py b/ghostos/core/moss/__init__.py index e152228b..f91d5b2e 100644 --- a/ghostos/core/moss/__init__.py +++ b/ghostos/core/moss/__init__.py @@ -1,44 +1,32 @@ from ghostos.container import Container -from ghostos.core.moss.abc import ( +from ghostos.core.moss.abcd import ( Moss, MossCompiler, MossRuntime, MossPrompter, Execution, AttrPrompts, MOSS_VALUE_NAME, MOSS_TYPE_NAME, MOSS_HIDDEN_MARK, MOSS_HIDDEN_UNMARK, - MOSS_EXEC_EVENT, MOSS_PROMPT_EVENT, MOSS_COMPILE_EVENT, MOSS_ATTR_PROMPTS_EVENT, - moss_message, ) from ghostos.core.moss.impl import DefaultMOSSProvider from ghostos.core.moss.test_suites import MossTestSuite -from ghostos.core.moss.pycontext import PyContext, Injection, Property, attr, SerializableType, SerializableData -from ghostos.core.moss.functional_token import ( - DEFAULT_MOSS_FUNCTIONAL_TOKEN, - DEFAULT_MOSS_PROMPT_TEMPLATE, - get_default_moss_prompt, -) +from ghostos.core.moss.pycontext import PyContext __all__ = [ # abstract contracts Moss, MossCompiler, MossRuntime, MossPrompter, Execution, # constants MOSS_VALUE_NAME, MOSS_TYPE_NAME, MOSS_HIDDEN_MARK, MOSS_HIDDEN_UNMARK, - MOSS_EXEC_EVENT, MOSS_PROMPT_EVENT, MOSS_COMPILE_EVENT, MOSS_ATTR_PROMPTS_EVENT, - DEFAULT_MOSS_FUNCTIONAL_TOKEN, - DEFAULT_MOSS_PROMPT_TEMPLATE, - # methods - get_default_moss_prompt, # types AttrPrompts, # pycontext related - PyContext, Injection, Property, attr, SerializableType, SerializableData, + PyContext, # testing DefaultMOSSProvider, MossTestSuite, - 'test_container', + 'moss_container', 'moss_test_suite', ] -def test_container() -> Container: +def moss_container() -> Container: """ test container for Moss """ @@ -53,5 +41,5 @@ def moss_test_suite() -> MossTestSuite: """ return a MossTestSuite """ - container = test_container() + container = moss_container() return MossTestSuite(container) diff --git a/ghostos/core/moss/abc.py b/ghostos/core/moss/abcd.py similarity index 85% rename from ghostos/core/moss/abc.py rename to ghostos/core/moss/abcd.py index 7edc827e..cc72b9bb 100644 --- a/ghostos/core/moss/abc.py +++ b/ghostos/core/moss/abcd.py @@ -1,16 +1,13 @@ from __future__ import annotations -import contextlib -from typing import Dict, Any, Union, List, Optional, NamedTuple, Type, Callable, Self +from typing import Dict, Any, Union, List, Optional, NamedTuple, Type, Callable, Self, TypeVar from types import ModuleType from abc import ABC, abstractmethod from ghostos.container import Container, Provider, Factory, provide -from ghostos.core.moss.pycontext import PyContext, attr +from ghostos.core.moss.pycontext import PyContext from ghostos.core.moss.prompts import ( - AttrPrompts, reflect_module_locals, PROMPT_MAGIC_ATTR, - compile_attr_prompts, + AttrPrompts, reflect_module_locals, compile_attr_prompts ) from ghostos.core.moss.decorators import cls_source_code -from contextlib import contextmanager """ MOSS 是 Model-oriented Operating System Simulation 的简写. @@ -51,28 +48,15 @@ """ __all__ = [ - 'Moss', 'attr', + 'Moss', 'MossCompiler', 'MossRuntime', 'Execution', 'MossPrompter', # 'moss_message', 'AttrPrompts', - 'MOSS_COMPILE_EVENT', 'MOSS_PROMPT_EVENT', 'MOSS_EXEC_EVENT', 'MOSS_ATTR_PROMPTS_EVENT', 'MOSS_TYPE_NAME', 'MOSS_VALUE_NAME', 'MOSS_HIDDEN_MARK', 'MOSS_HIDDEN_UNMARK', ] -MOSS_COMPILE_EVENT = "__moss_compile__" -"""moss 编译阶段的回调事件, 可以在对应文件里自定义这个事件, 替换系统默认. """ - -MOSS_ATTR_PROMPTS_EVENT = "__moss_attr_prompts__" -"""通过这个属性来获取一个实例 (module/instance of class) 所有属性的 prompts. """ - -MOSS_PROMPT_EVENT = "__moss_prompt__" -""" moss 生成 prompt 阶段的回调事件. """ - -MOSS_EXEC_EVENT = "__moss_exec__" -""" moss 执行阶段的回调事件. """ - MOSS_TYPE_NAME = "Moss" MOSS_VALUE_NAME = "moss" @@ -84,7 +68,6 @@ """ pycontext.module 源码某一行以这个标记开头, 其后的代码都展示到 prompt 里. """ -@cls_source_code() class Moss(ABC): """ Language Model-oriented Operating System Simulation. @@ -93,7 +76,16 @@ class Moss(ABC): SerializeType means: int, float, str, None, list, dict, BaseModel, TypedDict You can edit them if you need. """ - pass + + T = TypeVar('T') + + @abstractmethod + def fetch(self, abstract: Type[T]) -> Optional[T]: + """ + fetch an implementation from IoC Container + if the abstract type is not bound with any implementation, return None. + """ + pass class Injection(ABC): @@ -196,6 +188,7 @@ def compile( 正式编译出一个 MOSSRuntime. 每一个 Compiler 只能编译一次. :param modulename: 生成的 ModuleType 所在的包名. 相关代码会在这个临时 ModuleType 里生成. 如果 modulename为空, 则使用原来的 """ + from ghostos.core.moss.lifecycle import __moss_compile__ if self.__compiling__: raise RuntimeError('recursively calling compile method') if self.__compiled__: @@ -205,10 +198,10 @@ def compile( # 使用 locals 和 pycontext.module 对应的代码, 生成 ModuleType. module = self._compile(modulename) # 在生成的 ModuleType 里查找魔术方法, 提供 provider 等, 为依赖注入做准备. - if hasattr(module, MOSS_COMPILE_EVENT): + if hasattr(module, __moss_compile__.__name__): # 完成编译 moss_compiled 事件. # 可以在这个环节对 MOSS 做一些依赖注入的准备. - fn = getattr(module, MOSS_COMPILE_EVENT) + fn = getattr(module, __moss_compile__.__name__) fn(self) runtime = self._new_runtime(module) return runtime @@ -272,16 +265,16 @@ def module(self) -> ModuleType: @abstractmethod def pycontext_code( self, - exclude_moss_mark_code: bool = True, + exclude_hide_code: bool = True, ) -> str: """ 返回通过 pycontext.module 预定义的代码. 第一行应该是 from __future__ import annotations. 解决上下文乱续的提示问题. - :param exclude_moss_mark_code: 如果为 True, 只返回大模型可以阅读的代码. + :param exclude_hide_code: 如果为 True, 只返回大模型可以阅读的代码. """ pass - def pycontext_attr_prompts(self, excludes: Optional[set] = None) -> AttrPrompts: + def reflect_module_attr_prompts(self) -> AttrPrompts: """ 结合已编译的本地变量, 用系统自带的方法反射出上下文属性的 prompts. """ @@ -291,39 +284,47 @@ def pycontext_attr_prompts(self, excludes: Optional[set] = None) -> AttrPrompts: yield from reflect_module_locals( name, local_values, - excludes=excludes, - excludes_module_prefixes={'pydantic', 'typing'}, ) - def pycontext_code_prompt(self, auto_generation: bool = True) -> str: + def imported_attrs_prompt(self, auto_generation: bool = True) -> str: """ 基于 pycontext code 生成的 Prompt. 用来描述当前上下文里的各种变量. 主要是从其它库引入的变量. :return: 用 python 风格描述的上下文变量. """ + from ghostos.core.moss.lifecycle import __moss_attr_prompts__ done = {} names = [] # 查看是否有源码自带的魔术方法. module = self.module() - if hasattr(module, MOSS_ATTR_PROMPTS_EVENT): - fn = getattr(module, MOSS_ATTR_PROMPTS_EVENT) + if hasattr(module, __moss_attr_prompts__.__name__): + fn = getattr(module, __moss_attr_prompts__.__name__) predefined_prompts: AttrPrompts = fn() for name, prompt in predefined_prompts: + if prompt is None: + continue if name and name not in done: names.append(name) done[name] = prompt # 合并系统自动生成的. if auto_generation: - attr_prompts = self.pycontext_attr_prompts(excludes=set(names)) + attr_prompts = self.reflect_module_attr_prompts() for name, prompt in attr_prompts: if name not in done: names.append(name) - done[name] = prompt + done[name] = prompt # 保证一下顺序. prompts = [(name, done[name]) for name in names] - return compile_attr_prompts(self.module(), prompts) + return compile_attr_prompts(prompts) + + @abstractmethod + def moss_injections_prompt(self) -> str: + """ + prompt for moss injections. + """ + pass def dump_context_prompt(self) -> str: """ @@ -333,17 +334,13 @@ def dump_context_prompt(self) -> str: 2. pycontext_code_prompt: 对 predefined code 里各种引用类库的描述 prompt. 会包裹在 `\"""` 中展示. 3. moss_prompt: moss 会注入到当前上下文里, 因此会生成 MOSS Prompt. """ + from ghostos.core.moss.lifecycle import __moss_code_prompt__ compiled = self.module() - # 使用目标 module 自带的 prompt, 不做任何干预. - if PROMPT_MAGIC_ATTR in compiled.__dict__: - fn = compiled.__dict__[PROMPT_MAGIC_ATTR] - return fn() # 基于 moss prompter 来生成. - if hasattr(compiled, MOSS_PROMPT_EVENT): - fn = getattr(compiled, MOSS_PROMPT_EVENT) + if hasattr(compiled, __moss_code_prompt__.__name__): + fn = getattr(compiled, __moss_code_prompt__.__name__) return fn(self) - from ghostos.core.moss.lifecycle import __moss_prompt__ - return __moss_prompt__(self) + return __moss_code_prompt__(self) class MossRuntime(ABC): @@ -377,20 +374,23 @@ def locals(self) -> Dict[str, Any]: pass @abstractmethod - def moss(self) -> object: + def moss(self) -> Moss: """ 基于上下文生成的 MOSS. 依赖注入已经完成. """ pass - def moss_type(self) -> Type: + def moss_type(self) -> Type[Moss]: """ get defined MOSS type :return: MOSS class """ module = self.module() if MOSS_TYPE_NAME in module.__dict__: - return module.__dict__[MOSS_TYPE_NAME] + moss_type = module.__dict__[MOSS_TYPE_NAME] + if not issubclass(moss_type, Moss): + raise TypeError(f"Moss type {moss_type} is not subclass of {Moss}") + return moss_type return Moss @abstractmethod @@ -409,7 +409,7 @@ def dump_std_output(self) -> str: pass @abstractmethod - def runtime_ctx(self): + def redirect_stdout(self): """ with runtime.exec_ctx(): ... @@ -440,18 +440,18 @@ def execute( :return: 根据 result_name 从 code 中获取返回值. :exception: any exception will be raised, handle them outside """ + from ghostos.core.moss.lifecycle import __moss_exec__ if self.__executing__: raise RuntimeError(f"Moss already executing") try: self.__executing__ = True compiled = self.module() fn = None - with self.runtime_ctx(): + with self.redirect_stdout(): # 使用 module 自定义的 exec - if hasattr(compiled, MOSS_EXEC_EVENT): - fn = getattr(compiled, MOSS_EXEC_EVENT) + if hasattr(compiled, __moss_exec__.__name__): + fn = getattr(compiled, __moss_exec__.__name__) if fn is None: - from ghostos.core.moss.lifecycle import __moss_exec__ fn = __moss_exec__ # 使用系统默认的 exec return fn( diff --git a/ghostos/core/moss/decorators.py b/ghostos/core/moss/decorators.py index fc7b4bd6..cc809adc 100644 --- a/ghostos/core/moss/decorators.py +++ b/ghostos/core/moss/decorators.py @@ -1,6 +1,6 @@ import inspect from typing import Callable, Optional, Any, Type -from ghostos.core.moss.prompts import set_prompter, set_class_prompter +from ghostos.prompter import set_prompter, set_class_prompter from ghostos.core.moss.utils import ( get_callable_definition, make_class_prompt, strip_source_indent, @@ -28,11 +28,10 @@ def no_prompt(func: Callable) -> Callable: no_prompt.__prompt__ = "" -def cls_source_code(*, force: bool = False, doc: Optional[str] = None) -> DECORATOR: +def cls_source_code(*, force: bool = False) -> DECORATOR: """ decorator that add source code as prompt to the class :param force: if force true, add prompt event the prompter exists in target - :param doc: docstring that shall replace the source code's docstring """ def decorator(cls: Type) -> Type: @@ -57,7 +56,7 @@ def decorator(fn: Callable) -> Callable: if not (inspect.isfunction(fn) or inspect.ismethod(fn)): raise AttributeError(f"fn '{fn}' has to be a function or method") - def prompter(): + def prompter() -> str: source = inspect.getsource(fn) source = strip_source_indent(source) return source diff --git a/ghostos/core/moss/examples/baseline.py b/ghostos/core/moss/examples/baseline.py index a9795a59..a4d9df24 100644 --- a/ghostos/core/moss/examples/baseline.py +++ b/ghostos/core/moss/examples/baseline.py @@ -1,8 +1,11 @@ -import logging from abc import ABC, abstractmethod -from typing import Optional, List -from ghostos.core.moss.abc import Moss as Parent, attr -from inspect import getsource, getmembers +from typing import List + +from ghostos.container import Container +from ghostos.core.moss.abcd import Moss as Parent +from ghostos.prompter import Prompter +from inspect import getmembers, getsource +from pydantic import BaseModel class Foo(ABC): @@ -18,17 +21,26 @@ def plus(a: int, b: int) -> int: return a + b -class Moss(Parent): +class TestPrompter(Prompter): + line: str = "TestPrompter" + + def self_prompt(self, container: Container, depth: int = 0) -> str: + return self.line + + +class Moss(Parent, ABC): """ 本地定义的 Moss 类. 每个 MOSS 文件里都应该有一个 Moss 类, 可以是 import 的也可以是本地定义的. 记得它要继承自 Moss. """ - life: List[str] = attr(default_factory=list, desc="用来记录发生过的生命周期.") + life: List[str] = [] """测试 attr 方法用来定义可持久化的属性. """ foo: Foo """依赖注入 Foo 的测试用例. """ + tester: TestPrompter + # # !!! 使用 `# ` 和 `# ` 包裹的代码不会对大模型呈现. @@ -36,7 +48,7 @@ class Moss(Parent): from typing import TYPE_CHECKING if TYPE_CHECKING: - from ghostos.core.moss.abc import MossCompiler, MossRuntime, AttrPrompts, MossPrompter, Execution + from ghostos.core.moss.abcd import MossCompiler, AttrPrompts, MossPrompter, Execution def __moss_compile__(compiler: "MossCompiler") -> "MossCompiler": @@ -47,7 +59,7 @@ def __moss_compile__(compiler: "MossCompiler") -> "MossCompiler": 主要解决各种注入方面的需求: """ # 单测里应该有这个. moss.bar == 123 - compiler.injects(bar=123) + compiler.injects(bar=123, tester=TestPrompter()) # 插入生命周期事件, 直接赋值到 moss 上. Moss.life.append("__moss_compile__") @@ -65,7 +77,6 @@ def foo(self) -> str: def __moss_attr_prompts__() -> "AttrPrompts": - Moss.life.append("__moss_attr_prompts__") return [ # 重写了 getsource 的 prompt, 它就应该不存在了. ("getsource", ""), @@ -76,14 +87,12 @@ def __moss_attr_prompts__() -> "AttrPrompts": def __moss_prompt__(prompter: "MossPrompter") -> str: # 测试生命周期生效. - Moss.life.append("__moss_prompt__") - from ghostos.core.moss.lifecycle import __moss_prompt__ - return __moss_prompt__(prompter) + from ghostos.core.moss.lifecycle import __moss_code_prompt__ + return __moss_code_prompt__(prompter) def __moss_exec__(*args, **kwargs) -> "Execution": # 测试生命周期生效. - Moss.life.append("__moss_exec__") from ghostos.core.moss.lifecycle import __moss_exec__ return __moss_exec__(*args, **kwargs) @@ -105,4 +114,4 @@ def main(moss: Moss) -> int: """ return plus(2, 2) -# \ No newline at end of file +# diff --git a/ghostos/core/moss/examples/mem_baseline.py b/ghostos/core/moss/examples/mem_baseline.py deleted file mode 100644 index 21b672f1..00000000 --- a/ghostos/core/moss/examples/mem_baseline.py +++ /dev/null @@ -1,57 +0,0 @@ -from ghostos.core.moss.abc import Moss as Parent, attr -from ghostos.mocks.libraries.auto_text_memory import Mem0TextMemory -from ghostos.framework.libraries.auto_memory import ProxyConfig - - -class Moss(Parent): - """ - 本地定义的 Moss 类. 每个 MOSS 文件里都应该有一个 Moss 类, 可以是 import 的也可以是本地定义的. - 记得它要继承自 Moss. - """ - text_memory: Mem0TextMemory - - -# - -def test_main(moss: Moss) -> int: - """ - 模拟一个 main 方法, 测试 moss 的调用. - assert 返回值是 3. 外部的 MOSSRuntime 调用这个方法. - """ - import os - - openai_proxy = os.environ.get('OPENAI_PROXY') - if openai_proxy: - moss.text_memory = Mem0TextMemory(proxy_config=ProxyConfig(proxy_url=openai_proxy)) - else: - moss.text_memory = Mem0TextMemory() - - m = moss.text_memory - # 1. Add: Store a memory from any unstructured text - result = m.add("I am working on improving my tennis skills. Suggest some online courses.", agent_id="alice") - print(result) - all_memories = m.get_all() - memory_id = all_memories[0]["id"] # get a memory_id - - # Created memory --> 'Improving her tennis skills.' and 'Looking for online suggestions.' - - # 2. Update: update the memory - result = m.update(memory_id=memory_id, data="Likes to play tennis on weekends") - print(result) - - # Updated memory --> 'Likes to play tennis on weekends.' and 'Looking for online suggestions.' - - # 3. Search: search related memories - related_memories = m.search(query="What are Alice do on weekends ?", agent_id="alice") - print(related_memories) - - # 5. Get memory history for a particular memory_id - history = m.history(memory_id=memory_id) - print(history) - -# - - -if __name__ == "__main__": - test_main(Moss()) - diff --git a/ghostos/core/moss/examples/test_suite.py b/ghostos/core/moss/examples/test_suite.py index c4a3180d..722cc58c 100644 --- a/ghostos/core/moss/examples/test_suite.py +++ b/ghostos/core/moss/examples/test_suite.py @@ -1,4 +1,4 @@ -from ghostos.core.moss.abc import Moss +from ghostos.core.moss.abcd import Moss def plus(a: int, b: int) -> int: diff --git a/ghostos/core/moss/exports.py b/ghostos/core/moss/exports.py deleted file mode 100644 index b4c2baa5..00000000 --- a/ghostos/core/moss/exports.py +++ /dev/null @@ -1,173 +0,0 @@ -from typing import Any, Dict, Iterable, Tuple, Optional, Callable, List -from ghostos.core.moss.utils import make_class_prompt, get_callable_definition -import inspect - -__all__ = ['Exporter'] - - -class Exporter(object): - """ - Exporter is useful to export multiple objects with prompts from a module - The Subject module can import a exporter instance from a object module, - the prompt generate from the exporter like this: - - > from foo.bar import baz - > - > # value of baz.a - > class A: - > ... - > # value of baz.b - > def B(): - > ... - - Exporter is harmless then the moss decorators. - """ - - def __init__(self, **kwargs): - self._prompts: Dict[str, str] = {} - # with kwargs values - for name, value in kwargs.items(): - if isinstance(value, Exporter): - self.with_exporter(name, value) - elif inspect.isclass(value): - self.with_class(value, name=name) - elif inspect.isfunction(value): - self.with_func(value, name=name) - else: - self.with_raw(name, value, "") - - def prompts(self) -> Iterable[Tuple[str, str]]: - """ - iterate the attr's prompt of the Exporter - :return: A generator that yields tuples of attr name and prompt value in the Exporter. - """ - return self._prompts.items() - - def gene_prompt(self, self_name: str) -> str: - """ - this method is used in other module. - generate prompt for the Exporter with a attribute name in other module. - :param self_name: attribute name of the Exporter in other module. - :return: full prompt - """ - lines = [] - for attr, prompt in self.prompts(): - comment = f"# value of {self_name}.{attr}" - lines.append(f"{comment}:\n{prompt}") - return "\n\n".join(lines) - - def with_raw(self, name: str, value: Any, prompt: str) -> "Exporter": - """ - add a attribute to the Exporter with a specific prompt. - :param name: attribute name in the Exporter - :param value: real value - :param prompt: predefined prompt - :return: self, chain calling. - """ - if name in self.__dict__: - raise NameError(f"'{name}' already exists in Exporter") - self.__dict__[name] = value - if not prompt: - prompt = f"# {value}" - self._prompts[name] = prompt - return self - - def with_class(self, cls: type, *, abc: Optional[type] = None, name: Optional[str] = None) -> "Exporter": - """ - add a class attribute to the Exporter. prompt will be the class source code. - :param cls: the class type - :param abc: if given, the prompt is the abc class's source code - :param name: if not given, the attribute name will be the class name - :return: self, chain calling. - """ - if abc is not None: - prompt = inspect.getsource(abc) - else: - prompt = inspect.getsource(cls) - if name is None: - name = cls.__name__ - return self.with_raw(name, cls, prompt) - - def with_interface( - self, - cls: type, - members: Optional[List[str]] = None, - *, - doc: Optional[str] = None, - name: Optional[str] = None, - ) -> "Exporter": - """ - add a class attribute to the Exporter. - prompt will be interface pattern, which means class definition plus public method definitions. - - :param cls: the value class - :param members: method name that should be added. if none, all public methods will be added. - :param doc: if given, replace the class docstring in the prompt - :param name: if not given, using class name as attribute name - """ - if name is None: - name = cls.__name__ - source = inspect.getsource(cls) - prompt = make_class_prompt(source=source, name=name, attrs=members, doc=doc) - return self.with_raw(name, cls, prompt) - - def with_func(self, func: Callable, *, doc: Optional[str] = None, name: Optional[str] = None) -> "Exporter": - """ - add a function attribute to the Exporter. prompt will be the function definition and doc. - :param func: - :param doc: if given, the function's doc in the prompt will be replaced by the argument. - :param name: if not given, the attribute name will be the function name - """ - prompt = get_callable_definition(func, doc=doc) - if name is None: - name = func.__name__ - return self.with_raw(name, func, prompt) - - def with_exporter(self, name: str, value: "Exporter") -> "Exporter": - """ - add another exporter to the Exporter. - prompt of each attribute in the value will be handled like: - self_name.self_attr_name.value_attr_name => prompt - """ - for attr, prompt in value.prompts(): - real_name = f"{name}.{attr}" - self._prompts[real_name] = prompt - self.__dict__[name] = value - return self - - -# --- tests --- # - -class Foo: - - def foo(self): - return 123 - - -class Bar(Foo): - pass - - -tester = (Exporter(Any=Any, Dict=Dict) - .with_interface(Exporter, ['with_func'], name="exp1") - .with_class(Foo, name="foo") - .with_class(Bar, abc=Foo) - .with_func(make_class_prompt) - .with_func(make_class_prompt, name="make_cls_pr", doc="hello")) - - -def test_each_value_of_tester(): - values = { - "Any": Any, - "Dict": Dict, - "exp1": Exporter, - "foo": Foo, - "make_class_prompt": make_class_prompt, - "make_cls_pr": make_class_prompt, - } - for attr, value in values.items(): - assert getattr(tester, attr) is value - - -def test_gen_prompt(): - print(tester.gene_prompt("tester")) diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index 875a8b75..ed142d10 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -1,18 +1,21 @@ import inspect from types import ModuleType -from typing import Optional, Any, Dict, get_type_hints, Type, List +from abc import abstractmethod +from typing import Optional, Any, Dict, get_type_hints, Type, List, Protocol import io from ghostos.container import Container, Provider -from ghostos.core.moss.abc import ( +from ghostos.contracts.modules import Modules, ImportWrapper +from ghostos.core.moss.abcd import ( Moss, MossCompiler, MossRuntime, MossPrompter, MOSS_VALUE_NAME, MOSS_TYPE_NAME, MOSS_HIDDEN_MARK, MOSS_HIDDEN_UNMARK, ) -from ghostos.contracts.modules import Modules, ImportWrapper -from ghostos.core.moss.prompts import AttrPrompts -from ghostos.core.moss.pycontext import PyContext, Property -from ghostos.helpers import get_module_spec +from ghostos.core.moss.prompts import get_defined_prompt +from ghostos.core.moss.utils import add_comment_mark +from ghostos.core.moss.pycontext import PyContext +from ghostos.prompter import Prompter +from ghostos.helpers import generate_module_spec from contextlib import contextmanager, redirect_stdout IMPORT_FUTURE = "from __future__ import annotations" @@ -120,19 +123,32 @@ def destroy(self) -> None: del self._injections -def new_moss_stub(cls: Type[Moss], pycontext: PyContext): +def new_moss_stub(cls: Type[Moss], container: Container, pycontext: PyContext) -> Moss: # cls 必须不包含参数. - obj = cls() - obj.__pycontext__ = pycontext - # 反向注入 + class MossType(cls): + __pycontext__ = pycontext + __container__ = container + + def fetch(self, abstract: Type[cls.T]) -> Optional[cls.T]: + return self.__container__.fetch(abstract) + + def __setattr__(self, _name, _value): + if self.__pycontext__.allow_prop(_value): + self.__pycontext__.set_prop(_name, _value) + self.__dict__[_name] = _value + + def destroy(self) -> None: + MossType.__pycontext__ = None + MossType.__container__ = None + + stub = MossType() for name, value in cls.__dict__.items(): - if not name.startswith('_') and isinstance(value, Property) and name not in pycontext.properties: - pycontext.define(value) - # 初始化 pycontext variable - for name, var in pycontext.properties.items(): - # 直接用 property 作为值. - setattr(obj, name, var) - return obj + if name in pycontext.properties or name.startswith("_"): + continue + if stub.__pycontext__.allow_prop(value): + stub.__pycontext__.set_prop(name, value) + + return stub class MossRuntimeImpl(MossRuntime, MossPrompter): @@ -155,52 +171,29 @@ def __init__( self._runtime_std_output = "" # 初始化之后不应该为 None 的值. self._built: bool = False - self._moss: Optional[Moss] = None self._moss_prompt: Optional[str] = None self._attr_prompts: Dict[str, str] = attr_prompts - self._attr_prompts["print"] = "" - self._bootstrap_moss() + self._moss: Moss = self._compile_moss() + self._destroyed: bool = False - def _bootstrap_moss(self): - if self._built: - return - self._built = True - self._compile_moss() - - def _compile_moss(self): + def _compile_moss(self) -> Moss: moss_type = self.moss_type() if not issubclass(moss_type, Moss): - raise TypeError(f"Moss type {moss_type} is not subclass of Moss") + raise TypeError(f"Moss type {moss_type} is not subclass of {generate_module_spec(Moss)}") # 创建 stub. pycontext = self._pycontext - for prop in pycontext.properties.values(): - # 基于上下文还原变量. - prop.generate_value(self._compiled) - - moss = new_moss_stub(moss_type, pycontext) - - # 初始化 injection. 强制赋值. - injections = self._injections.copy() - # 初始化 pycontext injection. 替代掉系统默认的 injection. - for name, injection in self._pycontext.injections.items(): - injection_module, injection_spec = injection.get_from_module_attr() - # 如果要注入的对象就是当前包, 则直接返回当前包的和苏剧. - if injection_module == self._compiled.__name__: - module = get_module_spec(self._compiled, injection_spec) - else: - module = self._modules.import_module(injection_module) - # 否则返回查找结果. - if injection_spec: - value = get_module_spec(module, injection_spec) - else: - value = module - injections[name] = value + moss = new_moss_stub(moss_type, self._container, pycontext) + + # 初始化 pycontext variable + for name, prop in pycontext.iter_props(self._compiled): + # 直接用 property 作为值. + setattr(moss, name, prop) + + # 反向注入 - # 将 Injections 直接注入. - for name, value in injections.items(): - if name not in pycontext.properties: - setattr(moss, name, value) + for name, injection in self._injections.items(): + setattr(moss, name, injection) # 初始化基于容器的依赖注入. typehints = get_type_hints(moss_type, localns=self._compiled.__dict__) @@ -209,18 +202,20 @@ def _compile_moss(self): continue # 已经有的就不再注入. + item = None if hasattr(moss, name): + item = getattr(moss, name) + if item is not None: continue - value = getattr(moss_type, name, None) - if value is None: - # 为 None 才依赖注入. - value = self._container.get(typehint) - # 依赖注入. - setattr(moss, name, value) - self._moss = moss + + # 为 None 才依赖注入. + value = self._container.force_fetch(typehint) + # 依赖注入. + setattr(moss, name, value) + self._compiled.__dict__[MOSS_VALUE_NAME] = moss self._compiled.__dict__[MOSS_TYPE_NAME] = moss_type - self._compiled.__dict__["print"] = self._print + return moss def container(self) -> Container: return self._container @@ -234,39 +229,55 @@ def module(self) -> ModuleType: def locals(self) -> Dict[str, Any]: return self._compiled.__dict__ - def moss(self) -> object: + def moss(self) -> Moss: return self._moss def dump_pycontext(self) -> PyContext: - return self._pycontext + if not self._moss: + return self._pycontext + if self._moss.__pycontext__ is self._pycontext: + return self._pycontext + return self._moss.__pycontext__ def dump_std_output(self) -> str: return self._runtime_std_output - def _print(self, *args, **kwargs): + @contextmanager + def redirect_stdout(self): buffer = io.StringIO() with redirect_stdout(buffer): - print(*args, **kwargs) - self._runtime_std_output += str(buffer.getvalue()) - - @contextmanager - def runtime_ctx(self): - yield + yield + self._runtime_std_output += str(buffer.getvalue()) def pycontext_code( self, - exclude_moss_mark_code: bool = True, + exclude_hide_code: bool = True, ) -> str: code = self._source_code - return self._parse_pycontext_code(code, exclude_moss_mark_code) + return self._parse_pycontext_code(code, exclude_hide_code) - def pycontext_attr_prompts(self, excludes: Optional[set] = None) -> AttrPrompts: - yield from super().pycontext_attr_prompts(excludes=excludes) - yield from self._attr_prompts.items() + def moss_injections_prompt(self) -> str: + moss = self.moss() + prompts = {} + for name, value in moss.__dict__.items(): + if name.startswith('_'): + continue + key = f"Moss.{name}" + if isinstance(value, Prompter): + prompt = value.get_prompt(self.container()) + prompts[key] = add_comment_mark(prompt) + continue + prompt = get_defined_prompt(value) + if prompt: + prompts[name] = add_comment_mark(prompt) + result = "" + for name, prompt in prompts.items(): + result += f"# \n{prompt}\n# \n" + return result.strip() @staticmethod - def _parse_pycontext_code(code: str, exclude_moss_mark_code: bool = True) -> str: - if not exclude_moss_mark_code: + def _parse_pycontext_code(code: str, exclude_hide_code: bool = True) -> str: + if not exclude_hide_code: return code lines = code.split("\n") @@ -290,13 +301,17 @@ def _parse_pycontext_code(code: str, exclude_moss_mark_code: bool = True) -> str return "\n".join(results) def destroy(self) -> None: + if self._destroyed: + return + self._destroyed = True + if hasattr(self._moss, "destroy"): + self._moss.destroy() self._container.destroy() - if hasattr(self._moss, "__pycontext__"): - del self._moss.__pycontext__ del self._container del self._injections del self._compiled del self._moss + del self._pycontext class DefaultMOSSProvider(Provider[MossCompiler]): diff --git a/ghostos/core/moss/lifecycle.py b/ghostos/core/moss/lifecycle.py index 1d760a63..b85a4366 100644 --- a/ghostos/core/moss/lifecycle.py +++ b/ghostos/core/moss/lifecycle.py @@ -4,7 +4,7 @@ if TYPE_CHECKING: from ghostos.core.moss.prompts import AttrPrompts - from ghostos.core.moss.abc import MossPrompter, Execution, MossRuntime, MossCompiler + from ghostos.core.moss.abcd import MossPrompter, Execution, MossRuntime, MossCompiler """ 这个文件提供了 MOSS 生命周期的关键方法, 每一个都是可选的. @@ -14,7 +14,7 @@ __all__ = [ '__moss_compile__', '__moss_attr_prompts__', - '__moss_prompt__', + '__moss_code_prompt__', '__moss_exec__', ] @@ -44,15 +44,6 @@ def __moss_compile__(compiler: "MossCompiler") -> "MossCompiler": return compiler -def __prompt__() -> str: - """ - 可选的魔术方法. - 使用这个方法, 可以完全自定义当前文件生成的 prompt. - 系统不做任何干预. - """ - pass - - def __moss_attr_prompts__() -> "AttrPrompts": """ 生成本地变量生成的 prompt. @@ -62,7 +53,7 @@ def __moss_attr_prompts__() -> "AttrPrompts": 默认的反射方法见 ghostos.moss.prompts.prompts.py 文件. 而这个方法则可以替代或追加必要的 prompt, 优先于系统生成的反射. - 还有一些在 标记内定义的代码, 想要在 prompt 里呈现, 也可以在这个方法里定义. + 还有一些在 标记内定义的代码, 想要在 prompt 里呈现, 也可以在这个方法里定义. 推荐使用 ghostos.moss.prompts 模块下提供的各种反射方法. :returns: Iterable[Tuple[name, prompt]] . 其中 name 只是为了去重. @@ -70,36 +61,48 @@ def __moss_attr_prompts__() -> "AttrPrompts": return [] -def __moss_prompt__(prompter: "MossPrompter") -> str: +def __moss_code_prompt__(prompter: "MossPrompter") -> str: """ 使用 MOSS Runtime 生成 prompt 的方法. 可选的魔术方法. 定义的话, runtime.moss_context_prompt 实际上会使用这个方法. 这个方法生成的 Prompt, 会用来描述当前文件, 其中包含了注入的 MOSS 类和 moss 实例. """ - from ghostos.core.moss.prompts import escape_string_quotes + from ghostos.core.moss.utils import escape_string_quotes + # 获取原始的代码. - origin_code = prompter.pycontext_code(exclude_moss_mark_code=True) + origin_code = prompter.pycontext_code(exclude_hide_code=True) + # 基于 origin code 生成关于这些变量的 prompt. - escaped_code_prompt = prompter.pycontext_code_prompt() - # 这部分变量的描述, 放到一个 string 里表示不污染当前上下文. - escaped_code_prompt = escape_string_quotes(escaped_code_prompt, '"""') + attrs_prompt = prompter.imported_attrs_prompt() code_prompt_part = "" - if escaped_code_prompt: + if attrs_prompt: + # 这部分变量的描述, 放到一个 string 里表示不污染当前上下文. + attrs_prompt = escape_string_quotes(attrs_prompt, '"""') code_prompt_part = f''' -# information about values above: -{escaped_code_prompt} + +# more details about some module attrs above, are list below (quoted by ): +""" +{attrs_prompt} +""" +''' + + injection_prompt = prompter.moss_injections_prompt() + injection_prompt_part = "" + if injection_prompt: + injection_prompt = escape_string_quotes(injection_prompt, '"""') + injection_prompt_part = f''' +# information about moss injections: +""" +{injection_prompt} +""" ''' # 生成完整的 prompt. 预计 MOSS 的描述已经在上下文里了. prompt = f""" {origin_code} -\""" {code_prompt_part} -\""" - -# Notice: type, method and values defined in the code above are immutable in multi-turns chat or thought. -# You are equipped with a MOSS interface below, which can inject module or define attributes in multi-turns. +{injection_prompt_part} """ return prompt @@ -126,18 +129,17 @@ def __moss_exec__( :param kwargs: 从外部注入的参数变量. """ from typing import Callable - from ghostos.core.moss.abc import Execution + from ghostos.core.moss.abcd import Execution pycontext = runtime.dump_pycontext() pycontext.execute_code = code pycontext.executed = False local_values = runtime.locals() # 注意使用 runtime.exec_ctx 包裹有副作用的调用. - with runtime.runtime_ctx(): - if code: - filename = pycontext.module if pycontext.module is not None else "" - compiled = compile(code, filename=filename, mode='exec') - exec(compiled, local_values) + if code: + filename = pycontext.module if pycontext.module is not None else "" + compiled = compile(code, filename=filename, mode='exec') + exec(compiled, local_values) if target not in local_values: raise NotImplementedError(f"target `{target}` not implemented") @@ -165,7 +167,7 @@ def __moss_exec__( if kwargs: real_kwargs.update(kwargs) # 注意使用 runtime.exec_ctx 包裹有副作用的调用. - with runtime.runtime_ctx(): + with runtime.redirect_stdout(): returns = target_module_attr(*real_args, **real_kwargs) elif has_args: raise TypeError(f"target '{target}' value '{target_module_attr}' is not callable") diff --git a/ghostos/core/moss/prompts.py b/ghostos/core/moss/prompts.py index a4b0d58a..e54f34df 100644 --- a/ghostos/core/moss/prompts.py +++ b/ghostos/core/moss/prompts.py @@ -1,21 +1,11 @@ -from typing import Any, Optional, Union, Callable, Dict, Tuple, Iterable, Set, Type -from types import ModuleType +from typing import Any, Optional, Dict, Tuple, Iterable from ghostos.core.moss.utils import ( - unwrap_str, - is_typing, - is_code_same_as_print, - escape_string_quotes, get_modulename, get_callable_definition, - add_source_indent, ) -from ghostos.core.moss.exports import Exporter -from ghostos.common import Prompter, PrompterClass -from ghostos.helpers import generate_import_path +from ghostos.prompter import get_defined_prompt import inspect -# todo: I really dislike this module and hope for a more systematic and rule-based implementation to replace it. - """ 将上下文引用的 变量/方法/类型 反射出 Prompt 的机制. 主要解决一个问题, 如何让一个属性能够被大模型所理解. @@ -35,15 +25,11 @@ 2. 如果变量拥有 __prompt__ 属性, 通过它 (可以是方法或字符串) 生成 prompt. """ -PROMPT_MAGIC_ATTR = "__prompt__" -"""通过这个属性名来判断一个实例 (module/function/instance of class) 是否有预定义的 prompt. """ - -CLASS_PROMPT_MAGIC_ATTR = "__class_prompt__" - -PromptFn = Callable[[], str] -"""生成 Prompt 的方法. """ - -Numeric = Union[int, float] +__all__ = [ + 'get_prompt', 'reflect_module_locals', 'join_prompt_lines', 'compile_attr_prompts', + 'get_defined_prompt', + 'AttrPrompts', +] AttrPrompts = Iterable[Tuple[str, str]] """ @@ -58,20 +44,12 @@ 多条 prompt 用 "\n\n".join(prompts) 的方式拼接. """ +ignore_modules = {"pydantic"} + def reflect_module_locals( modulename: str, local_values: Dict[str, Any], - *, - includes: Optional[Set[str]] = None, - excludes: Optional[Set[str]] = None, - includes_module_prefixes: Optional[Set[str]] = None, - excludes_module_prefixes: Optional[Set[str]] = None, - _cls: bool = True, - _typing: bool = True, - _func: bool = True, - _module: bool = False, - _prompter: bool = True, ) -> AttrPrompts: """ MOSS 系统自带的反射方法, 对一个module 的本地变量做最小化的反射展示. @@ -90,30 +68,15 @@ def reflect_module_locals( 6. 如果目标是 class - 包含 __class_prompt__ 方法时, 用它生成. - __is_abstract__ 的 class, 直接返回源码. - 7. 如果目标是 typing - - 如果目标就是 typing 库, 则不展示. - - 否则用字符串形式展示. - 8. 如果目标是其它 attr + 7. 如果目标是其它 attr _ 只有包含 prompt 方法时才展示. :param modulename: 当前模块名. 所有当前模块的变量默认不展示. :param local_values: 传入的上下文变量. - :param includes: if given, only prompt the attrs that name in it - :param excludes: if given, any attr that name in it will not be prompted - :param includes_module_prefixes: if given, the other module's value will only be prompted if the module match prefix - :param excludes_module_prefixes: if given, the other module's value will not be prompted if the module match prefix - :param _cls: 是否允许反射类. - :param _module: 是否允许反射模块. - :param _typing: 是否允许反射 typing - :param _func: 是否允许反射 function. - :param _prompter: 拥有 __prompt__ 的其它类型. """ for name, value in local_values.items(): try: - prompt = reflect_module_attr( - name, value, modulename, includes, excludes, includes_module_prefixes, excludes_module_prefixes, - _cls, _module, _func, _prompter, - ) + prompt = reflect_module_attr(name, value, modulename) except Exception as e: raise RuntimeError(f"failed to reflect local value {name!r}: {e}") if prompt is not None: @@ -123,26 +86,12 @@ def reflect_module_locals( def reflect_module_attr( name: str, value: Any, - current_module: Optional[str] = None, - includes: Optional[Set[str]] = None, - excludes: Optional[Set[str]] = None, - includes_module_prefixes: Optional[Set[str]] = None, - excludes_module_prefixes: Optional[Set[str]] = None, - _cls: bool = True, - _typing: bool = True, - _func: bool = True, - _module: bool = False, - _prompter: bool = True, + current_module: str, ) -> Optional[str]: """ 反射其中的一个值. """ - # 名字相关的过滤逻辑. - if excludes and name in excludes: - return None - if includes is not None and name not in includes: - return None - if name.startswith('_') and not (includes and name in includes): + if name.startswith('_'): # 私有变量不展示. return None if inspect.isbuiltin(value): @@ -155,138 +104,36 @@ def reflect_module_attr( return None elif value_modulename == current_module: return None - - if excludes_module_prefixes: - for prefix in excludes_module_prefixes: - if value_modulename.startswith(prefix): - return None - - elif includes_module_prefixes: - has_prefix = False - for prefix in includes_module_prefixes: - if value_modulename.startswith(prefix): - has_prefix = True - break - if not has_prefix: - return None - return default_reflect_local_value_prompt( - name, value, - _cls=_cls, _typing=_typing, _module=_module, _func=_func, _prompter=_prompter, - ) + elif value_modulename in ignore_modules: + return None + return get_prompt(value) -def default_reflect_local_value_prompt( - name: str, - value: Any, - _cls: bool = True, - _module: bool = True, - _typing: bool = True, - _func: bool = True, - _prompter: bool = True, -) -> Optional[str]: +def get_prompt(value: Any) -> Optional[str]: """ - 默认的反射方法, 用来反射当前上下文(module) 里的某个变量, 生成上下文相关的 prompt (assignment or definition). - :param name: 变量名. - :param value: 变量值 - :param _cls: 是否允许反射类. - :param _module: 是否允许反射模块. - :param _typing: 是否允许反射 typing - :param _func: 是否允许反射 function. - :param _prompter: 其它类型. - :return: + get prompt from value. + only: + 1. predefined PromptAble + 2. abstract class + 3. function or method + will generate prompt """ - if isinstance(value, Exporter): - return value.gene_prompt(name) - elif is_typing(value): - if not _typing: - return None - if value.__module__ == "typing": - if value.__name__ in _typing.__dict__ and _typing.__dict__[value.__name__] is value: - return None - return f"{name} = {value}" + defined_prompt = get_defined_prompt(value) + if defined_prompt: + return defined_prompt - elif inspect.isclass(value): - if not _cls: - return None - # class 类型. - prompt = get_class_magic_prompt(value) - if prompt is not None: - return prompt + if inspect.isclass(value): + # only reflect abstract class if inspect.isabstract(value): source = inspect.getsource(value) return source - elif inspect.isfunction(value) or inspect.ismethod(value): - if not _func: - return None - # 方法类型. - prompt = get_magic_prompt(value) - if prompt is not None: - return prompt # 默认都给方法展示 definition. - return get_callable_definition(value, name) - elif inspect.ismodule(value): - if not _module: - return None - # 只有包含 __prompt__ 的库才有展示. - prompt = get_magic_prompt(value) - if prompt: - parsed = escape_string_quotes(prompt, '"""') - # 增加缩进. - parsed = add_source_indent(parsed, indent=4) - return f''' -# information of `{name}` (module `{value.__name__}`) : -""" -{parsed} -""" -# information of `{name}` over. -''' + return get_callable_definition(value) - else: - if not _prompter: - return None - # attr, 也可能是 module. - prompt = get_magic_prompt(value) - if prompt: - parsed = escape_string_quotes(prompt, '"""') - return f''' -# value of `{name}`: -""" -{parsed} -""" -# value of `{name}` over. -''' return None -def get_prompt(value: Any) -> Optional[str]: - if inspect.isclass(value): - return get_class_magic_prompt(value) - return get_magic_prompt(value) - - -def get_magic_prompt(value: Any) -> Optional[str]: - """ - 不做类型校验, 直接返回 PROMPT_MAGIC_ATTR 生成 prompt 的结果. - :param value: 合理类型是 module, function, method, instance of class - """ - if isinstance(value, Prompter): - return value.__prompt__() - fn = getattr(value, PROMPT_MAGIC_ATTR, None) - return unwrap_str(fn) if fn is not None else None - - -def get_class_magic_prompt(value: Any) -> Optional[str]: - """ - 不做类型校验, 直接返回 CLASS_PROMPT_MAGIC_ATTR 生成 prompt 的结果. - :param value: 合理的类型是 class. - """ - if issubclass(value, PrompterClass): - return value.__class_prompt__() - fn = getattr(value, CLASS_PROMPT_MAGIC_ATTR, None) - return unwrap_str(fn) if fn is not None else None - - def join_prompt_lines(*prompts: Optional[str]) -> str: """ 将多个可能为空的 prompt 合并成一个 python 代码风格的 prompt. @@ -296,100 +143,17 @@ def join_prompt_lines(*prompts: Optional[str]) -> str: line = prompt.rstrip() if line: result.append(prompt) - return '\n\n\n'.join(result) + return '\n\n'.join(result) -def assign_prompt(typehint: Optional[Any], assigment: Optional[Any]) -> str: - """ - 拼装一个赋值的 Prompt. - :param typehint: 拼装类型描述, 如果是字符串直接展示, 否则会包在双引号里. - :param assigment: - :return: - """ - if isinstance(typehint, str): - typehint_str = f': {typehint}' - else: - s = escape_string_quotes(str(typehint), '"') - typehint_str = f': "{s}"' - assigment_str = "" - if isinstance(assigment, str): - s = escape_string_quotes(str(typehint), '"') - assigment_str = f' = "{s}"' - elif is_code_same_as_print(assigment): - assigment_str = f' = {assigment}' - return f"{typehint_str}{assigment_str}" - - -def compile_attr_prompts(module: ModuleType, attr_prompts: AttrPrompts) -> str: - """ - 将 Attr prompt 进行合并. - :param module: 用来做类型判断, 如何处理 name. - :param attr_prompts: - :return: prompt in real python code pattern - """ - prompt_lines = [] - local_values = module.__dict__ - local_module = module.__name__ +def compile_attr_prompts(attr_prompts: AttrPrompts) -> str: + prompts = [] for name, prompt in attr_prompts: - line = prompt.strip() - if not line: - # 空值跳过. + prompt = prompt.strip() + if not prompt: continue - value = local_values.get(name, None) - if value is None: - # 不在当前的变量里, 直接加入上下文. - prompt_lines.append(line) - elif getattr(value, "__module__", None) == local_module: - prompt_lines.append(line) - elif inspect.isclass(value): - # 考虑到重命名. - if name != value.__name__: - line = xml_wrap_code(line, "class", name=name, path=value.__module__ + ':' + value.__qualname__) - prompt_lines.append(line) - elif inspect.isfunction(value): - # 考虑到重命名. - if name != value.__name__: - line = xml_wrap_code(line, "func", name=name, path=value.__module__ + ':' + value.__qualname__) - prompt_lines.append(line) - elif inspect.ismodule(value): - # 考虑到重命名. - line = xml_wrap_code(line, "module", name=name, module=value.__name__) - prompt_lines.append(line) - else: - # 使用注释 + 描述的办法. - prompt_lines.append(f"# value '{name}':\n{line}") - return join_prompt_lines(*prompt_lines) - - -def xml_wrap_code(value: str, mark: str, **kwargs: str) -> str: - kwargs_str = "" - if kwargs: - lines = [f"{name}='{arg}'" for name, arg in kwargs.items()] - kwargs_str = ' ' + ' '.join(lines) - start = f"# <{mark}{kwargs_str}>" - end = f"# " - return "\n".join([start, value, end]) - - -def set_prompter(value: Any, prompter: Union[PromptFn, str], force: bool = False) -> None: - if not force and hasattr(value, PROMPT_MAGIC_ATTR): - return - setattr(value, PROMPT_MAGIC_ATTR, prompter) - - -def set_class_prompter(value: Type, class_prompter: Union[PromptFn, str], force: bool = False) -> None: - if not inspect.isclass(value): - raise TypeError(f'`value` should be a class, not {type(value)}') - class_name = generate_import_path(value) - if hasattr(value, CLASS_PROMPT_MAGIC_ATTR): - method = getattr(value, CLASS_PROMPT_MAGIC_ATTR) - if method is not None and isinstance(method, Callable): - cls_name = getattr(method, '__prompter_class__', None) - # 同一个类已经有 prompt 的情况, 必须加 force 参数才能修改原有的. - if cls_name == class_name and not force: - return - if isinstance(class_prompter, Callable): - # 会给 prompter 方法添加 __prompter_class__ 用来做归属判断. - # todo: 有没有更好的解决办法? - class_prompter.__prompter_class__ = class_name - setattr(value, CLASS_PROMPT_MAGIC_ATTR, class_prompter) + attr_prompt = f'''# +{prompt} +# ''' + prompts.append(attr_prompt) + return join_prompt_lines(*prompts) diff --git a/ghostos/core/moss/pycontext.py b/ghostos/core/moss/pycontext.py index ac044a52..24511550 100644 --- a/ghostos/core/moss/pycontext.py +++ b/ghostos/core/moss/pycontext.py @@ -1,16 +1,11 @@ from __future__ import annotations -from typing import Dict, Any, Union, List, Optional, Tuple, TypedDict, is_typeddict, Callable +from typing import Dict, Any, Optional, Tuple, Iterator from types import ModuleType -import inspect from pydantic import BaseModel, Field -from ghostos.core.moss.decorators import definition -from ghostos.helpers import ( - parse_import_module_and_spec, import_from_path, join_import_module_and_spec, - get_module_spec, -) +from ghostos.entity import EntityMeta, to_entity_meta, from_entity_meta, is_entity_type __all__ = [ - 'PyContext', 'Injection', 'Property', 'attr', 'SerializableType', 'SerializableData', + 'PyContext', ] @@ -32,11 +27,16 @@ class PyContext(BaseModel): # default_factory=dict, # description="通过 python 引入的包, 类, 方法 等. 会注入到 MOSS 上, 同时会实现它.", # ) - properties: Dict[str, Property] = Field( + + properties: Dict[str, EntityMeta] = Field( default_factory=dict, - description="在上下文中定义的变量. 会注入到 MOSS 上. 修改后也会保存到 pycontext 里. ", ) + # properties: Dict[str, Property] = Field( + # default_factory=dict, + # description="在上下文中定义的变量. 会注入到 MOSS 上. 修改后也会保存到 pycontext 里. ", + # ) + execute_code: Optional[str] = Field( default=None, description="the generated python code on this context", @@ -47,167 +47,196 @@ class PyContext(BaseModel): description="if the generated code is executed", ) - def inject(self, injected: "Injection") -> None: - self.injections[injected.import_from] = injected - - def define(self, d: "Property") -> None: - self.properties[d.name] = d + def set_prop(self, name: str, value: Any): + self.properties[name] = to_entity_meta(value) + + def get_prop(self, name: str, module: Optional[ModuleType] = None) -> Any: + if name not in self.properties: + return None + value = self.properties[name] + return from_entity_meta(value, module) + + @staticmethod + def allow_prop(value: Any) -> bool: + if isinstance(value, BaseModel): + return True + elif isinstance(value, bool): + return True + elif isinstance(value, str): + return True + elif isinstance(value, int): + return True + elif isinstance(value, float): + return True + elif isinstance(value, list): + return True + elif isinstance(value, dict): + return True + elif is_entity_type(value): + return True + return False + + def iter_props(self, module: Optional[ModuleType] = None) -> Iterator[Tuple[str, Any]]: + for name in self.properties: + value = self.properties[name] + yield name, from_entity_meta(value, module) def join(self, ctx: "PyContext") -> "PyContext": """ 合并两个 python context, 以右侧的为准. 并返回一个新的 PyContext 对象. 避免左向污染. """ copied = self.model_copy(deep=True) - if ctx.module: + if copied.module is None: copied.module = ctx.module - if ctx.code: + if copied.code is None: copied.code = ctx.code - for imp in ctx.injections.values(): - copied.inject(imp) - for var in ctx.properties.values(): - copied.define(var) - return copied - - -class Injection(BaseModel): - """ - from module import specific attribute then inject to MOSS Context - """ - import_from: str = Field( - description="the imported module name or use module path pattern such as 'modulename:attr_name'", - ) - alias: Optional[str] = Field(default=None, description="context attr alias for the imported value") - - @classmethod - def reflect(cls, value: Any, alias: Optional[str] = None) -> "Injection": - """ - reflect a value and generate Imported value. - :param value: - :param alias: - :return: - """ - modulename = inspect.getmodule(value).__name__ - if inspect.ismodule(value): - spec = None - else: - spec = getattr(value, '__name__', None) - import_from = join_import_module_and_spec(modulename, spec) - return Injection( - import_from=import_from, - alias=alias, - ) - - def get_name(self) -> str: - if self.alias: - return self.alias - _, spec = self.get_from_module_attr() - return spec - - def get_from_module_attr(self) -> Tuple[str, Optional[str]]: - """ - :return: modulename and attribute name from the module - """ - return parse_import_module_and_spec(self.import_from) - - -SerializableType = Union[str, int, float, bool, None, list, dict, BaseModel, TypedDict] -"""系统支持的各种可序列化类型, 可以被存储到 Serializable 里. 更多情况下可以用别的变量类型. """ -SerializableData = Union[str, int, float, bool, None, List, Dict] + if copied.execute_code is None: + copied.execute_code = ctx.execute_code + copied.executed = ctx.executed + for key, val in ctx.properties.items(): + copied.properties[key] = val + return copied -class Property(BaseModel): - """ - 可以在 MOSS 上下文中声明的变量. - """ - name: str = Field(default="", description="property name in the moss context") - desc: str = Field(default="", description="describe the property's purpose") - value: Union[str, int, float, bool, None, list, dict, BaseModel, TypedDict] = Field( - default=None, - description="the serializable value", - ) - model: Optional[str] = Field( - default=None, - description="如果是 pydantic 等类型, 可以通过类进行封装. 类应该在 imports 或者 defines 里.", - ) - - def __set_name__(self, owner, name): - self.name = name - if hasattr(owner, '__pycontext__') and isinstance(owner.__pycontext__, PyContext): - owner.__pycontext__.define(self) - - def __set__(self, instance, value): - if value is self: - return - if isinstance(value, Property): - self.value = value.value - self.model = value.model - self.name = value.name - self.model = value.model - self.set_value(value) - - def __get__(self, instance, owner): - return self.generate_value() - - def __delete__(self): - self.__value__ = None - self.value = None - self.model = None - - @classmethod - def from_value(cls, *, name: str = "", value: SerializableType, desc: str = "") -> Optional["Property"]: - p = cls(name=name, desc=desc) - p.set_value(value) - return p - - def set_value(self, value: Any) -> None: - if not isinstance(value, SerializableType): - # 赋值的时候报错. - raise AttributeError(f"{value} is not property serializable type {SerializableType}") - model = None - has_model = ( - value is not None and isinstance(value, BaseModel) - or is_typeddict(value) - ) - if has_model: - type_ = type(value) - if type_.__qualname__: - model = type_.__module__ + ':' + type_.__qualname__ - else: - model = type_.__module__ + ':' + type_.__name__ - self.value = value - self.model = model - - def generate_value(self, module: Optional[ModuleType] = None) -> Any: - model = self.model - value = self.value - if isinstance(self.value, dict) and model is not None: - if not isinstance(value, Dict): - raise AttributeError(f"'{value}' is not dict while model class is '{model}'") - cls = None - if module is not None: - modulename, spec = parse_import_module_and_spec(model) - if modulename == module.__name__: - # 用这种方法解决临时模块里的变量问题. - cls = get_module_spec(module.__dict__, spec) - if cls is None: - cls = import_from_path(model) - if issubclass(cls, BaseModel): - self.value = cls(**value) - return self.value - - -@definition() -def attr( - default: SerializableType = None, *, - default_factory: Optional[Callable[[], Any]] = None, - desc: str = "", -) -> SerializableType: - """ - 用于定义一个要绑定到 MOSS 上的属性, 它的值可以在多轮对话和思考过程中保存和修改. - :param default: 属性的默认值, 目前支持 str, int, float, bool, None, list, dict, BaseModel, TypedDict - :param default_factory: 可以传入一个 lambda 或闭包, 当 default 为 None 时生成值. - :param desc: 属性的描述. - """ - if default is None and default_factory: - default = default_factory() - return Property.from_value(value=default, desc=desc) +# class Injection(BaseModel): +# """ +# from module import specific attribute then inject to MOSS Context +# """ +# import_from: str = Field( +# description="the imported module name or use module path pattern such as 'modulename:attr_name'", +# ) +# alias: Optional[str] = Field(default=None, description="context attr alias for the imported value") +# +# @classmethod +# def reflect(cls, value: Any, alias: Optional[str] = None) -> "Injection": +# """ +# reflect a value and generate Imported value. +# :param value: +# :param alias: +# :return: +# """ +# modulename = inspect.getmodule(value).__name__ +# if inspect.ismodule(value): +# spec = None +# else: +# spec = getattr(value, '__name__', None) +# import_from = join_import_module_and_spec(modulename, spec) +# return Injection( +# import_from=import_from, +# alias=alias, +# ) +# +# def get_name(self) -> str: +# if self.alias: +# return self.alias +# _, spec = self.get_from_module_attr() +# return spec +# +# def get_from_module_attr(self) -> Tuple[str, Optional[str]]: +# """ +# :return: modulename and attribute name from the module +# """ +# return parse_import_module_and_spec(self.import_from) +# +# +# SerializableType = Union[str, int, float, bool, None, list, dict, BaseModel, TypedDict] +# """系统支持的各种可序列化类型, 可以被存储到 Serializable 里. 更多情况下可以用别的变量类型. """ +# SerializableData = Union[str, int, float, bool, None, List, Dict] +# +# +# class Property(BaseModel): +# """ +# 可以在 MOSS 上下文中声明的变量. +# """ +# name: str = Field(default="", description="property name in the moss context") +# desc: str = Field(default="", description="describe the property's purpose") +# value: Union[str, int, float, bool, None, list, dict, BaseModel, TypedDict] = Field( +# default=None, +# description="the serializable value", +# ) +# model: Optional[str] = Field( +# default=None, +# description="如果是 pydantic 等类型, 可以通过类进行封装. 类应该在 imports 或者 defines 里.", +# ) +# +# def __set_name__(self, owner, name): +# self.name = name +# if hasattr(owner, '__pycontext__') and isinstance(owner.__pycontext__, PyContext): +# owner.__pycontext__.define(self) +# +# def __set__(self, instance, value): +# if value is self: +# return +# if isinstance(value, Property): +# self.value = value.value +# self.model = value.model +# self.name = value.name +# self.model = value.model +# self.set_value(value) +# +# def __get__(self, instance, owner): +# return self.generate_value() +# +# def __delete__(self): +# self.__value__ = None +# self.value = None +# self.model = None +# +# @classmethod +# def from_value(cls, *, name: str = "", value: SerializableType, desc: str = "") -> Optional["Property"]: +# p = cls(name=name, desc=desc) +# p.set_value(value) +# return p +# +# def set_value(self, value: Any) -> None: +# if not isinstance(value, SerializableType): +# # 赋值的时候报错. +# raise AttributeError(f"{value} is not property serializable type {SerializableType}") +# model = None +# has_model = ( +# value is not None and isinstance(value, BaseModel) +# or is_typeddict(value) +# ) +# if has_model: +# type_ = type(value) +# if type_.__qualname__: +# model = type_.__module__ + ':' + type_.__qualname__ +# else: +# model = type_.__module__ + ':' + type_.__name__ +# self.value = value +# self.model = model +# +# def generate_value(self, module: Optional[ModuleType] = None) -> Any: +# model = self.model +# value = self.value +# if isinstance(self.value, dict) and model is not None: +# if not isinstance(value, Dict): +# raise AttributeError(f"'{value}' is not dict while model class is '{model}'") +# cls = None +# if module is not None: +# modulename, spec = parse_import_module_and_spec(model) +# if modulename == module.__name__: +# # 用这种方法解决临时模块里的变量问题. +# cls = get_module_spec(module.__dict__, spec) +# if cls is None: +# cls = import_from_path(model) +# if issubclass(cls, BaseModel): +# self.value = cls(**value) +# return self.value +# +# +# @definition() +# def attr( +# default: SerializableType = None, *, +# default_factory: Optional[Callable[[], Any]] = None, +# desc: str = "", +# ) -> SerializableType: +# """ +# 用于定义一个要绑定到 MOSS 上的属性, 它的值可以在多轮对话和思考过程中保存和修改. +# :param default: 属性的默认值, 目前支持 str, int, float, bool, None, list, dict, BaseModel, TypedDict +# :param default_factory: 可以传入一个 lambda 或闭包, 当 default 为 None 时生成值. +# :param desc: 属性的描述. +# """ +# if default is None and default_factory: +# default = default_factory() +# return Property.from_value(value=default, desc=desc) diff --git a/ghostos/core/moss/test_suites.py b/ghostos/core/moss/test_suites.py index 350d099d..defe41af 100644 --- a/ghostos/core/moss/test_suites.py +++ b/ghostos/core/moss/test_suites.py @@ -1,5 +1,5 @@ from typing import List, Dict, Optional, Callable -from ghostos.core.moss.abc import MossCompiler, Execution +from ghostos.core.moss.abcd import MossCompiler, Execution from ghostos.core.moss.pycontext import PyContext from ghostos.container import Container from queue import Queue diff --git a/ghostos/core/moss/utils.py b/ghostos/core/moss/utils.py index 6a7e6621..e13d92c6 100644 --- a/ghostos/core/moss/utils.py +++ b/ghostos/core/moss/utils.py @@ -2,7 +2,6 @@ import re from typing import Any, Dict, Callable, Optional, List, Iterable, TypedDict, is_typeddict from pydantic import BaseModel -from ghostos.common import Identical, Descriptive __all__ = [ @@ -10,7 +9,7 @@ 'get_modulename', 'is_typing', 'is_builtin', 'is_classmethod', - 'is_model_class', 'get_model_object_meta', + 'is_model_class', 'parse_comments', 'parse_doc_string', 'escape_string_quotes', 'strip_source_indent', 'add_source_indent', 'make_class_prompt', @@ -312,19 +311,6 @@ def is_model_class(typ: type) -> bool: return issubclass(typ, BaseModel) or is_typeddict(typ) -def get_model_object_meta(obj: Any) -> Optional[Dict]: - if isinstance(obj, BaseModel): - return obj.model_dump() - elif isinstance(obj, TypedDict): - result = {} - for k, v in obj.items(): - result[k] = v - return result - elif isinstance(obj, EntityClass): - return obj.to_entity_meta() - return None - - def is_callable(obj: Any) -> bool: return isinstance(obj, Callable) @@ -352,7 +338,6 @@ def get_calling_modulename(skip: int = 0) -> Optional[str]: return None - def is_code_same_as_print(value: Any) -> bool: return isinstance(value, bool) \ or isinstance(value, int) \ @@ -371,6 +356,7 @@ def get_modulename(val: Any) -> Optional[str]: return getattr(module, '__name__', None) return None + def add_comment_mark(text: str, comment: str = "# ") -> str: lines = text.split('\n') contents = [] diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index 1ba2ab49..b7fd9df2 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from enum import Enum from pydantic import BaseModel, Field -from ghostos.common import Identifier, Identical, EntityMeta +from ghostos.identifier import Identifier, Identical, EntityMeta from ghostos.core.messages import Payload from contextlib import contextmanager @@ -116,8 +116,8 @@ class GoTaskStruct(BaseModel): description="the global values that inherit from the parent task or shell", ) - properties: Dict[str, Dict] = Field( - default_factory=dict, + props: Optional[EntityMeta] = Field( + default=None, description="the state data of the task handler" ) diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index c1142e79..985cb31b 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -2,7 +2,7 @@ import time from abc import ABC, abstractmethod from pydantic import BaseModel, Field -from ghostos.common import EntityMeta +from ghostos.identifier import EntityMeta from ghostos.core.messages import Message, copy_messages, Role from ghostos.core.moss.pycontext import PyContext from ghostos.core.llms import Prompt diff --git a/ghostos/entity.py b/ghostos/entity.py index 2e3b4635..1e88e805 100644 --- a/ghostos/entity.py +++ b/ghostos/entity.py @@ -1,135 +1,158 @@ -from typing import Optional, TypedDict, Callable, Type, TypeVar -from types import ModuleType -from abc import ABC, abstractmethod +from __future__ import annotations -from typing_extensions import Required -from ghostos.helpers import generate_import_path, import_from_path +import json +from abc import ABC, abstractmethod +from typing import Union, Any, TypedDict, Required, Self, TypeVar, Type, Optional +from types import ModuleType from pydantic import BaseModel +from ghostos.helpers import generate_import_path, import_from_path, parse_import_module_and_spec +from typing_extensions import Protocol +import inspect +import pickle +import base64 +import yaml __all__ = [ - 'Entity', 'EntityMeta', - 'EntityFactory', - 'ModelEntity', - 'EntityFactoryImpl', - 'model_to_entity_meta', - 'model_from_entity_meta', -] - - -class EntityMeta(TypedDict, total=False): - """ - meta-data that could: - 1. transport as dict data, weak type-hint - 2. be used to regenerate [Meta] - """ - - type: Required[str] - """ different type of entity use different EntityFactory to initialize from meta""" - - data: Required[dict] - """ use dict to restore the serializable data""" - - -def model_to_entity_meta(model: BaseModel) -> EntityMeta: - type_ = generate_import_path(type(model)) - data = model.model_dump(exclude_defaults=True) - return EntityMeta( - type=type_, - data=data, - ) + 'to_entity_meta', 'from_entity_meta', 'get_entity', + 'is_entity_type', + 'EntityMeta', 'Entity', 'EntityType', 'EntityClass', -MODEL = TypeVar('MODEL', bound=BaseModel) - - -def model_from_entity_meta(meta: EntityMeta, wrapper: Type[MODEL] = BaseModel) -> MODEL: - type_ = meta['type'] - imported = import_from_path(type_) - if not issubclass(imported, wrapper): - raise TypeError(f"the type of the meta `{type_}` is not a subclass of `{wrapper}`") - return imported(**meta['data']) +] -class Entity(ABC): - """ - meta is a strong type-hint class that can generate meta-data to transport - """ +class Entity(Protocol): @abstractmethod - def to_entity_data(self) -> dict: + def __to_entity_meta__(self) -> EntityMeta: pass - def to_entity_meta(self) -> EntityMeta: - """ - generate transportable meta-data - """ - type_ = generate_import_path(self.__class__) - data = self.to_entity_data() - return EntityMeta(type=type_, data=data) - @classmethod @abstractmethod - def from_entity_meta(cls, factory: "EntityFactory", meta: EntityMeta) -> "Entity": + def __from_entity_meta__(cls, meta: EntityMeta) -> Self: pass -class ModelEntity(BaseModel, Entity, ABC): - """ - Entity based on pydantic.BaseModel - """ - - def to_entity_data(self) -> dict: - return self.model_dump(exclude_none=True) - - @classmethod - def from_entity_meta(cls, factory: "EntityFactory", meta: EntityMeta) -> "ModelEntity": - return cls(**meta['data']) - - -E = TypeVar('E', bound=Entity) - - -class EntityFactory(ABC): - """ - Factory for Entity - """ +class EntityClass(ABC): @abstractmethod - def new_entity(self, meta_data: EntityMeta) -> Optional[Entity]: - """ - try to new an entity from meta-data - """ + def __to_entity_meta__(self) -> EntityMeta: pass + @classmethod @abstractmethod - def force_new_entity(self, meta_data: EntityMeta, expect: Type[E]) -> E: - """ - :param meta_data: - :param expect: expect entity type - :return: EntityType instance - :exception: TypeError - """ + def __from_entity_meta__(cls, meta: EntityMeta) -> Self: pass -class EntityFactoryImpl(EntityFactory): - - def __init__(self, importer: Optional[Callable[[str], ModuleType]] = None): - self._importer = importer +class EntityMeta(TypedDict): + """ + I want python has an official way to marshal and unmarshal any instance and make it readable if allowed. + I found so many package-level implements like various kinds of Serializable etc. - def new_entity(self, meta_data: EntityMeta) -> Optional[Entity]: - type_ = meta_data['type'] - cls = import_from_path(type_, self._importer) - if cls is None: - return None - if not issubclass(cls, Entity): - raise TypeError(f"Entity type {type_} does not inherit from Entity") - return cls.from_entity_meta(self, meta_data) + So, I develop EntityMeta as a wrapper for any kind. + The EntityType will grow bigger with more marshaller, but do not affect who (me) is using the EntityMeta. - def force_new_entity(self, meta_data: EntityMeta, expect: Type[E]) -> E: - entity = self.new_entity(meta_data) - if entity is None: - raise TypeError(f"meta data {meta_data['type']} can not be instanced") - if not isinstance(entity, expect): - raise TypeError(f"Entity type {meta_data['type']} does not match {expect}") - return entity + One day I can replace it with any better way inside the functions (but in-compatible) + """ + type: Required[str] + content: Required[str] + + +EntityType = Union[Entity, EntityMeta, BaseModel] + + +def is_entity_type(value: Any) -> bool: + return hasattr(value, '__to_entity_meta__') + + +def to_entity_meta(value: Union[EntityType, Any]) -> EntityMeta: + if value is None: + return EntityMeta( + type="None", + content="", + ) + elif value is True or value is False: + return EntityMeta(type="bool", content=str(value)) + elif isinstance(value, int): + return EntityMeta(type="int", content=str(value)) + elif isinstance(value, float): + return EntityMeta(type="float", content=str(value)) + elif isinstance(value, list): + content = yaml.safe_dump(value) + return EntityMeta(type="list", content=content) + elif isinstance(value, dict): + content = yaml.safe_dump(value) + return EntityMeta(type="dict", content=content) + elif hasattr(value, '__to_entity_meta__'): + return getattr(value, '__to_entity_meta__')() + elif isinstance(value, BaseModel): + return EntityMeta( + type=generate_import_path(value.__class__), + content=value.model_dump_json(exclude_defaults=True), + ) + elif inspect.isfunction(value): + return EntityMeta( + type=generate_import_path(value), + content="", + ) + elif isinstance(value, BaseModel): + type_ = generate_import_path(value.__class__) + content = value.model_dump_json(exclude_defaults=True) + return EntityMeta(type=type_, content=content) + else: + content_bytes = pickle.dumps(value) + content = base64.encodebytes(content_bytes) + return EntityMeta( + type="pickle", + content=content.decode(), + ) + + +T = TypeVar("T") + + +def get_entity(meta: EntityMeta, expect: Type[T]) -> T: + entity = from_entity_meta(meta) + if not isinstance(entity, expect): + raise TypeError(f"Expected entity type {expect} but got {type(entity)}") + return entity + + +def from_entity_meta(meta: EntityMeta, module: Optional[ModuleType] = None) -> Any: + unmarshal_type = meta['type'] + if unmarshal_type == "None": + return None + elif unmarshal_type == "int": + return int(meta['content']) + elif unmarshal_type == "bool": + return meta['content'] == "True" + elif unmarshal_type == "float": + return float(meta['content']) + elif unmarshal_type == "list" or unmarshal_type == "dict": + return yaml.safe_load(meta['content']) + elif unmarshal_type == 'pickle': + content = meta['content'] + content_bytes = base64.decodebytes(content.encode()) + return pickle.loads(content_bytes) + + # raise if import error + cls = None + if module: + module_name, local_name = parse_import_module_and_spec(unmarshal_type) + if module_name == module.__name__: + cls = module.__dict__[local_name] + if cls is None: + cls = import_from_path(unmarshal_type) + + if inspect.isfunction(cls): + return cls + # method is prior + elif hasattr(cls, "__from_entity_meta__"): + return getattr(cls, "__from_entity_meta__")(meta) + + elif issubclass(cls, BaseModel): + data = json.loads(meta["content"]) + return cls(**data) + + raise TypeError(f"unsupported entity meta type: {unmarshal_type}") diff --git a/ghostos/framework/actions/moss_action.py b/ghostos/framework/actions/moss_action.py index ecb104ac..ffcfd2da 100644 --- a/ghostos/framework/actions/moss_action.py +++ b/ghostos/framework/actions/moss_action.py @@ -9,7 +9,7 @@ from ghostos.core.moss import MossRuntime, moss_message from ghostos.core.ghosts.operators import Operator from ghostos.core.runtime import Session -from ghostos.common import Identifier +from ghostos.identifier import Identifier from pydantic import BaseModel, Field from traceback import format_exc @@ -116,7 +116,7 @@ def identifier(self) -> Identifier: description=self._functional_token.description, ) - def process(self, chat: Prompt) -> Prompt: + def update_prompt(self, chat: Prompt) -> Prompt: # update functional tokens function_token = self._functional_token chat.functional_tokens.append(function_token) @@ -134,7 +134,7 @@ def process(self, chat: Prompt) -> Prompt: if name.startswith("_"): continue if isinstance(member, PromptPipe): - member.process(chat) + member.update_prompt(chat) return chat diff --git a/ghostos/framework/chatpreparers/assistant_preparer.py b/ghostos/framework/chatpreparers/assistant_preparer.py index 515e0012..03280010 100644 --- a/ghostos/framework/chatpreparers/assistant_preparer.py +++ b/ghostos/framework/chatpreparers/assistant_preparer.py @@ -15,7 +15,7 @@ def __init__(self, *, assistant_name: str, task_id: str = "", with_task_name: bo self._task_id = task_id self._with_task_name = with_task_name - def process(self, chat: Prompt) -> Prompt: + def update_prompt(self, chat: Prompt) -> Prompt: def filter_fn(message: Message) -> Optional[Message]: if message.role != Role.ASSISTANT.value: return message diff --git a/ghostos/framework/documents/storage_impl.py b/ghostos/framework/documents/storage_impl.py index c554deb6..f7bc08ea 100644 --- a/ghostos/framework/documents/storage_impl.py +++ b/ghostos/framework/documents/storage_impl.py @@ -1,9 +1,8 @@ from typing import Iterable, List, Dict, Optional from typing_extensions import Self -from ghostos.common import Identifier +from ghostos.identifier import Identifier from ghostos.contracts.storage import FileStorage -from ghostos.helpers import get_current_locale from ghostos.contracts.documents import Documents, DocumentRegistry from ghostos.container import Provider, Container from ghostos.contracts.configs import Configs, YamlConfig diff --git a/ghostos/framework/ghosts/basic.py b/ghostos/framework/ghosts/basic.py index 25b217c7..2b59d2ac 100644 --- a/ghostos/framework/ghosts/basic.py +++ b/ghostos/framework/ghosts/basic.py @@ -350,7 +350,7 @@ def trace(self) -> Dict[str, str]: def _make_trace(self, session: Session, shell: Shell) -> Dict: session_id = session.id() - process_id = session.process().process_id + process_id = session.update_prompt().process_id task_id = session.task().task_id identifier = self.conf().identifier() return { diff --git a/ghostos/framework/ghosts/demo.py b/ghostos/framework/ghosts/demo.py index 695b5dff..42c6da21 100644 --- a/ghostos/framework/ghosts/demo.py +++ b/ghostos/framework/ghosts/demo.py @@ -1,5 +1,5 @@ from typing import Optional, List -from ghostos.common import Identifier +from ghostos.identifier import Identifier from ghostos.core.ghosts import GhostConf, Shell, Workspace from ghostos.core.runtime import GoProcess, GoTaskStruct from ghostos.contracts.modules import Modules diff --git a/ghostos/framework/multitasks/basic.py b/ghostos/framework/multitasks/basic.py index e11d0f41..4ca26ebf 100644 --- a/ghostos/framework/multitasks/basic.py +++ b/ghostos/framework/multitasks/basic.py @@ -12,7 +12,7 @@ class MultiTaskBasicImpl(MultiTask): def __init__(self, ghost: Ghost): self._ghost = ghost - def process(self, chat: Prompt) -> Prompt: + def update_prompt(self, chat: Prompt) -> Prompt: children = self._ghost.session().get_task_briefs(children=True) if not children: return chat diff --git a/ghostos/helpers/__init__.py b/ghostos/helpers/__init__.py index 9927ed42..f0ae137c 100644 --- a/ghostos/helpers/__init__.py +++ b/ghostos/helpers/__init__.py @@ -23,6 +23,7 @@ from ghostos.helpers.coding import reflect_module_code, unwrap from ghostos.helpers.openai import get_openai_key +from ghostos.helpers.tree_sitter import tree_sitter_parse if TYPE_CHECKING: from typing import Callable diff --git a/ghostos/helpers/tree_sitter.py b/ghostos/helpers/tree_sitter.py index 11e182b8..910ebe9a 100644 --- a/ghostos/helpers/tree_sitter.py +++ b/ghostos/helpers/tree_sitter.py @@ -8,8 +8,10 @@ _PythonParser = get_parser('python') +__all__ = ['tree_sitter_parse'] -def parse(code: str) -> Tree: + +def tree_sitter_parse(code: str) -> Tree: return _PythonParser.parse(code) diff --git a/ghostos/common.py b/ghostos/identifier.py similarity index 51% rename from ghostos/common.py rename to ghostos/identifier.py index 4ef5fa12..a5fdb749 100644 --- a/ghostos/common.py +++ b/ghostos/identifier.py @@ -1,55 +1,24 @@ from __future__ import annotations -import json from abc import ABC, abstractmethod -from typing import Optional, Dict, Union, Callable, Any, TypedDict, Required, Self, TypeVar, Type -from types import ModuleType +from typing import Optional, Dict, Union, Callable, Any from pydantic import BaseModel, Field -from ghostos.helpers import generate_import_path, import_from_path +from ghostos.helpers import generate_import_path from typing_extensions import Protocol import inspect -import pickle __all__ = [ 'get_identifier', - 'Identifier', 'Identifiable', + 'identify_class', + 'identify_class_id', + 'Identifier', 'Identifiable', 'Identical', 'IdenticalClass', - 'identify_class', 'identify_class_id', 'IdenticalObject', - 'to_entity_meta', 'from_entity_meta', 'get_entity', - 'EntityMeta', 'Entity', 'EntityType', - - 'get_defined_prompt', - 'Prompter', 'PrompterClass', - ] -def get_defined_prompt(value: Any) -> Union[str, None]: - if value is None: - return None - elif isinstance(value, Prompter): - return value.__prompt__() - elif issubclass(value, PrompterClass): - return value.__class_prompt__() - - elif isinstance(value, type): - # class without __class_prompt__ is not defined as prompter - if hasattr(value, "__class_prompt___"): - return getattr(value, "__class_prompt___")() - - elif hasattr(value, "__prompter__"): - prompter = getattr(value, "__prompter__") - if prompter.__self__ is not None: - return prompter() - elif isinstance(value, ModuleType) and '__prompt__' in value.__dict__: - prompter = value.__dict__['__prompt__'] - return prompter() - return None - - def get_identifier(value: Any, throw: bool = False) -> Union[Identifier, None]: """ get identifier or not from any value @@ -190,129 +159,3 @@ def identify_class(cls: type) -> Identifier: def identify_class_id(cls: type) -> str: return generate_import_path(cls) - - -# ---- prompt ---- # - - -class Prompter(ABC): - """ - 拥有 __prompt__ 方法的类. - 这里只是一个示范, 并不需要真正继承这个类, 只需要有 __prompt__ 方法或属性. - """ - - @abstractmethod - def __prompt__(self) -> str: - pass - - -class PromptAbleObj(Protocol): - @abstractmethod - def __prompt__(self) -> str: - pass - - -class PrompterClass(ABC): - - @classmethod - @abstractmethod - def __class_prompt__(cls) -> str: - pass - - -class PromptAbleClass(Protocol): - - @classmethod - @abstractmethod - def __class_prompt__(cls) -> str: - pass - - -PromptAble = Union[PromptAbleObj, PromptAbleClass] - - -# ---- entity ---- # - -class Entity(Protocol): - - @abstractmethod - def __to_entity_meta__(self) -> EntityMeta: - pass - - @classmethod - @abstractmethod - def __from_entity_meta__(cls, meta: EntityMeta) -> Self: - pass - - -class EntityMeta(TypedDict): - """ - I want python has an official way to marshal and unmarshal any instance and make it readable if allowed. - I found so many package-level implements like various kinds of Serializable etc. - - So, I develop EntityMeta as a wrapper for any kind. - The EntityType will grow bigger with more marshaller, but do not affect who (me) is using the EntityMeta. - - One day I can replace it with any better way inside the functions (but in-compatible) - """ - type: Required[str] - content: Required[bytes] - - -EntityType = Union[Entity, EntityMeta, BaseModel] - - -def to_entity_meta(value: Union[EntityType, Any]) -> EntityMeta: - if isinstance(value, EntityMeta): - return value - elif hasattr(value, '__to_entity_meta__'): - return getattr(value, '__to_entity_meta__')() - elif isinstance(value, BaseModel): - return EntityMeta( - type=generate_import_path(value.__class__), - content=value.model_dump_json(exclude_defaults=True).encode(), - ) - elif inspect.isfunction(value): - return EntityMeta( - type=generate_import_path(value), - content=bytes(), - ) - else: - content = pickle.dumps(value) - return EntityMeta( - type="pickle", - content=content, - ) - - -T = TypeVar("T") - - -def get_entity(meta: EntityMeta, expect: Type[T]) -> T: - entity = from_entity_meta(meta) - if not isinstance(entity, expect): - raise TypeError(f"Expected entity type {expect} but got {type(entity)}") - return entity - - -def from_entity_meta(meta: EntityMeta) -> Any: - unmarshal_type = meta['type'] - if unmarshal_type == 'pickle': - return pickle.loads(meta['content']) - - # raise if import error - cls = import_from_path(unmarshal_type) - - if issubclass(cls, EntityMeta): - return meta - elif inspect.isfunction(cls): - return cls - # method is prior - elif hasattr(cls, "__from_entity_meta__"): - return getattr(cls, "__from_entity_meta__")(meta) - - elif issubclass(cls, BaseModel): - data = json.loads(meta["content"]) - return cls(**data) - - raise TypeError(f"unsupported entity meta type: {unmarshal_type}") diff --git a/ghostos/prompter.py b/ghostos/prompter.py new file mode 100644 index 00000000..ece03052 --- /dev/null +++ b/ghostos/prompter.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import inspect +from typing import ( + List, Self, Union, Callable, Any, Protocol, +) +from abc import ABC, abstractmethod +from types import ModuleType +from ghostos.container import Container +from ghostos.helpers import generate_import_path +import json + +from pydantic import BaseModel +from .entity import EntityClass, EntityMeta, from_entity_meta, to_entity_meta + +__all__ = [ + 'get_defined_prompt', + 'set_prompter', 'set_class_prompter', + 'Prompter', + 'PromptAbleObj', 'PromptAbleClass', +] + + +def get_defined_prompt(value: Any) -> Union[str, None]: + attr = get_defined_prompt_attr(value) + if attr is None: + return None + if isinstance(attr, str): + return attr + return attr() + + +def get_defined_prompt_attr(value: Any) -> Union[None, str, Callable[[], str]]: + if value is None: + return None + elif isinstance(value, PromptAbleObj): + return value.__prompt__ + + elif isinstance(value, type): + if issubclass(value, PromptAbleClass): + return value.__class_prompt__ + # class without __class_prompt__ is not defined as prompter + if hasattr(value, "__class_prompt__"): + return getattr(value, "__class_prompt__") + + elif hasattr(value, "__prompt__"): + prompter = getattr(value, "__prompt__") + if inspect.isfunction(value) or inspect.ismethod(value) or hasattr(prompter, '__self__'): + return prompter + elif isinstance(value, ModuleType) and '__prompt__' in value.__dict__: + prompter = value.__dict__['__prompt__'] + return prompter + return None + + +def set_prompter(obj: Any, prompter: Union[Callable[[], str], str], force: bool = False) -> None: + if force or not hasattr(obj, '__prompt__'): + setattr(obj, '__prompt__', prompter) + + +def set_class_prompter(cls: type, prompter: Union[Callable[[], str], str], force: bool = False) -> None: + if hasattr(cls, '__class__prompt__'): + fn = getattr(cls, '__class_prompt__') + cls_name = generate_import_path(cls) + if force or fn.__class_name__ != cls_name: + pass + else: + return + prompter.__class_name__ = generate_import_path(cls) + setattr(cls, '__class_prompt__', prompter) + + +class Prompter(BaseModel, EntityClass, ABC): + """ + is strong-typed model for runtime alternative properties of a ghost. + """ + + __children__: List[Prompter] = [] + """ children is fractal sub context nodes""" + + def with_children(self, *children: Prompter) -> Prompter: + self.__children__.extend(children) + return self + + @abstractmethod + def self_prompt(self, container: Container, depth: int = 0) -> str: + """ + generate prompt by self, without children + :param container: + :param depth: + :return: + """ + pass + + def get_prompt(self, container: Container, depth: int = 0) -> str: + """ + get prompt with container which provides libraries to generate prompt + :param container: + :param depth: + :return: + """ + self_prompt = self.self_prompt(container, depth=depth) + prompts = [self_prompt] + for child in self.__children__: + prompts.append(child.get_prompt(container, depth=depth + 1)) + return "\n\n".join([prompt.rstrip() for prompt in prompts]) + + def __to_entity_meta__(self) -> EntityMeta: + type_ = generate_import_path(self.__class__) + ctx_data = self.model_dump(exclude_defaults=True) + children_data = [] + for child in self.__children__: + children_data.append(to_entity_meta(child)) + data = {"ctx": ctx_data, "children": children_data} + content = json.dumps(data) + return EntityMeta(type=type_, content=content) + + @classmethod + def __from_entity_meta__(cls, meta: EntityMeta) -> Self: + data = json.loads(meta["content"]) + ctx_data = data["ctx"] + children_data = data["children"] + result = cls(**ctx_data) + children = [] + for child in children_data: + children.append(from_entity_meta(child)) + return result.with_children(*children) + + +class PromptAbleObj(ABC): + """ + 拥有 __prompt__ 方法的类. + 这里只是一个示范, 并不需要真正继承这个类, 只需要有 __prompt__ 方法或属性. + """ + + @abstractmethod + def __prompt__(self) -> str: + pass + + +class PromptAbleProtocol(Protocol): + @abstractmethod + def __prompt__(self) -> str: + pass + + +class PromptAbleClass(ABC): + + @classmethod + @abstractmethod + def __class_prompt__(cls) -> str: + pass + + +class PromptAbleClassProtocol(Protocol): + + @classmethod + @abstractmethod + def __class_prompt__(cls) -> str: + pass + + +PromptAble = Union[PromptAbleClass, PromptAbleObj, PromptAbleProtocol, PromptAbleClassProtocol] diff --git a/ghostos/prototypes/aifunc/app.py b/ghostos/prototypes/aifunc/app.py index c960c777..7b7ed7a8 100644 --- a/ghostos/prototypes/aifunc/app.py +++ b/ghostos/prototypes/aifunc/app.py @@ -8,7 +8,7 @@ from logging.config import dictConfig from ghostos.core.llms import Prompt from ghostos.core.messages import Message -from ghostos.core.moss import test_container +from ghostos.core.moss import moss_container from ghostos.core.aifunc import ( DefaultAIFuncExecutorImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncExecutor, AIFuncResult, @@ -51,7 +51,7 @@ def run_aifunc( init_logger(absolute_logger_conf) # prepare container - container = test_container() + container = moss_container() container.register(FileStorageProvider(root_dir)) container.register(NamedLoggerProvider(logger_name=logger_name)) container.register(MsgThreadRepoByStorageProvider(threads_dir=threads_path)) diff --git a/ghostos/prototypes/ghostfunc/prepare.py b/ghostos/prototypes/ghostfunc/prepare.py index 3c19ed23..1eebe84d 100644 --- a/ghostos/prototypes/ghostfunc/prepare.py +++ b/ghostos/prototypes/ghostfunc/prepare.py @@ -1,6 +1,6 @@ from typing import Optional from ghostos.container import Container -from ghostos.core.moss import test_container, MossCompiler +from ghostos.core.moss import moss_container, MossCompiler from ghostos.core.llms import LLMs from ghostos.framework.configs import ConfigsByStorageProvider from ghostos.framework.storage import FileStorageProvider @@ -30,7 +30,7 @@ def init_ghost_func_container( :param container: parent container. """ if container is None: - container = test_container() + container = moss_container() container.register(FileStorageProvider(workspace_dir)) container.register(ConfigsByStorageProvider(configs_dir)) container.register(ConfigBasedLLMsProvider(llm_conf_path)) diff --git a/ghostos/prototypes/mosstemp/template.py b/ghostos/prototypes/mosstemp/template.py index d09a7ee7..3d73dccf 100644 --- a/ghostos/prototypes/mosstemp/template.py +++ b/ghostos/prototypes/mosstemp/template.py @@ -34,7 +34,7 @@ def example_hello_world_main(moss: Moss) -> Optional[Operator]: from ghostos.core.moss.lifecycle import ( __moss_compile__ as __default_moss_compile__, __moss_attr_prompts__ as __default_moss_attr_prompts__, - __moss_prompt__ as __default_moss_prompt__, + __moss_code_prompt__ as __default_moss_prompt__, __moss_exec__ as __default_moss_exec__, ) diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py index f7a7383f..ead74a12 100644 --- a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py @@ -21,7 +21,7 @@ get_aifunc_result_type, ExecFrame, ExecStep, ) -from ghostos.common import Identifier, identify_class +from ghostos.identifier import Identifier, identify_class from ghostos.helpers import ( uuid, gettext as _, diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py index 01710364..578aa113 100644 --- a/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py @@ -13,7 +13,7 @@ ) import ghostos.core.aifunc.func as func import ghostos.core.aifunc.interfaces as interfaces -from ghostos.common import Identifier +from ghostos.identifier import Identifier from ghostos.helpers import ( gettext as _, reflect_module_code, diff --git a/ghostos/scripts/aifunc_test.py b/ghostos/scripts/aifunc_test.py index d7108d02..9cada308 100644 --- a/ghostos/scripts/aifunc_test.py +++ b/ghostos/scripts/aifunc_test.py @@ -8,7 +8,7 @@ from ghostos.scripts.logconf import prepare_logger from ghostos.core.llms import Prompt from ghostos.core.messages import Message -from ghostos.core.moss import test_container +from ghostos.core.moss import moss_container from ghostos.core.aifunc import DefaultAIFuncExecutorImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncExecutor from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.storage import FileStorageProvider @@ -30,7 +30,7 @@ def prepare_container(root_dir: str) -> Container: - container = test_container() + container = moss_container() container.register(FileStorageProvider(root_dir)) container.register(NamedLoggerProvider(logger_name="debug")) container.register(MsgThreadRepoByStorageProvider(threads_dir='runtime/threads')) diff --git a/ghostos/thoughts/basic.py b/ghostos/thoughts/basic.py index b3aef59c..878289e1 100644 --- a/ghostos/thoughts/basic.py +++ b/ghostos/thoughts/basic.py @@ -74,7 +74,7 @@ def think(self, g: Ghost, e: Event) -> Optional[Operator]: # prepare chat by actions for action in actions: - chat = action.process(chat) + chat = action.update_prompt(chat) # prepare llm api llm_api = self.get_llmapi(g) diff --git a/ghostos/thoughts/file_editor_thought.py b/ghostos/thoughts/file_editor_thought.py index fea9d7b8..6caaebec 100644 --- a/ghostos/thoughts/file_editor_thought.py +++ b/ghostos/thoughts/file_editor_thought.py @@ -68,7 +68,7 @@ def is_moss_code_delivery(self) -> bool: return self.thought.debug def new_task_id(self, g: Ghost) -> str: - process_id = g.session().process().process_id + process_id = g.session().update_prompt().process_id task_id = f"process_{process_id}_task_{self.thought.filepath}" # task_id in a same process will always be the same return md5(task_id) diff --git a/ghostos/thoughts/pymodule_editor.py b/ghostos/thoughts/pymodule_editor.py index 9b4936a2..cea253e1 100644 --- a/ghostos/thoughts/pymodule_editor.py +++ b/ghostos/thoughts/pymodule_editor.py @@ -71,7 +71,7 @@ def get_llmapi(self, g: Ghost) -> LLMApi: return g.llms().get_api(self.thought.llm_api_name) def new_task_id(self, g: Ghost) -> str: - process_id = g.session().process().process_id + process_id = g.session().update_prompt().process_id task_id = f"process_{process_id}_task_{self.thought.target_module}" # task_id in a same process will always be the same return md5(task_id) diff --git a/tests/core/moss/examples/test_baseline.py b/tests/core/moss/examples/test_baseline.py index 9166c338..7de456f3 100644 --- a/tests/core/moss/examples/test_baseline.py +++ b/tests/core/moss/examples/test_baseline.py @@ -1,12 +1,12 @@ -from ghostos.core.moss import test_container -from ghostos.core.moss.abc import MossCompiler, Moss, MOSS_TYPE_NAME +from ghostos.core.moss import moss_container, pycontext +from ghostos.core.moss.abcd import MossCompiler, Moss, MOSS_TYPE_NAME from ghostos.core.moss.pycontext import PyContext from ghostos.core.moss.examples import baseline from ghostos.contracts.modules import ImportWrapper def test_baseline_exec(): - container = test_container() + container = moss_container() compiler = container.force_fetch(MossCompiler) assert compiler is not None @@ -56,6 +56,7 @@ def test_baseline_exec(): assert "def getsource(" not in prompt # 添加的意义不明的注释也应该存在了. assert "# hello world" in prompt + assert "TestPrompter" in prompt # assert moss moss = runtime.moss() @@ -71,13 +72,11 @@ def test_baseline_exec(): life = result.pycontext.properties["life"] assert life is not None # 生命周期被执行. - value = life.value + value = result.pycontext.get_prop("life") assert isinstance(value, list) - assert "__moss_compile__" in ["__moss_compile__"], "in array test" + assert "__moss_compile__" in value, "in array test" assert '__moss_compile__' in value, "__moss_compile__ not found" - assert "__moss_attr_prompts__" in value, "__moss_attr_prompts__ not found" - assert "__moss_prompt__" in value, "__moss_prompt__ not found" - assert "__moss_exec__" in value, "__moss_exec__ not found" + assert 123 == result.pycontext.get_prop("bar") moss = runtime.moss() # 验证用 injections 注入. @@ -85,6 +84,7 @@ def test_baseline_exec(): # 验证依赖注入. foo = getattr(moss, 'foo') Foo = runtime.module().__dict__['Foo'] + moss.fetch(Foo) assert foo is not None and isinstance(foo, Foo) assert foo.foo() == "hello" @@ -93,7 +93,7 @@ def test_baseline_exec(): def test_baseline_in_test_mode(): - container = test_container() + container = moss_container() compiler = container.force_fetch(MossCompiler) assert compiler is not None @@ -102,85 +102,26 @@ def test_baseline_in_test_mode(): assert compiler.pycontext().module == baseline.__name__ # 获取目标代码. - runtime = compiler.compile("__test__") - assert runtime is not None - - module = runtime.module() - # 名字相同. - assert module.__name__ != baseline.__name__ - assert module.__name__ == "__test__" - hack_import = module.__dict__.get('__import__', None) - # 这时就不是原来的 module 了. - assert hack_import is not None - assert isinstance(hack_import, ImportWrapper) - - # 先测试 ctx - # with runtime.runtime_ctx(): - # print("hello") - # buffed = runtime.dump_std_output() - # assert buffed.startswith("hello") - - exists_moss_type = module.__dict__.get(MOSS_TYPE_NAME) - moss_type = runtime.moss_type() - # 使用了默认的 MOSS - assert issubclass(moss_type, Moss) - assert moss_type is exists_moss_type - - moss = runtime.moss() - assert isinstance(moss, Moss) - assert isinstance(moss, moss_type) - - prompter = runtime.prompter() - assert prompter is not None - prompt = prompter.dump_context_prompt() - - # 独立编译的模块和之前一样. - # plus 方法存在. - assert 'def plus' in prompt - # 在 moss 标记内的不展示. - assert "__test__" not in prompt - # 虽然import 了 inspect 的两个方法, 但一个的 prompt 被重置了. - assert "def getmembers(" in prompt - assert "def getsource(" not in prompt - # 添加的意义不明的注释也应该存在了. - assert "# hello world" in prompt - - # assert moss - moss = runtime.moss() - assert getattr(moss, "bar") is 123 - - # 运行 main 方法. - result = runtime.execute(target="main", local_args=["moss"]) - # main 方法的运行结果. - assert result.returns == 4 - - # 动态加载的 attr. - assert "life" in result.pycontext.properties, f"life is not found in dumped pycontext {result.pycontext}" - life = result.pycontext.properties["life"] - assert life is not None - # 生命周期被执行. - value = life.value - assert isinstance(value, list) - assert "__moss_compile__" in value, "__moss_compile__ not found" - - moss = runtime.moss() - # 验证用 injections 注入. - assert getattr(moss, 'bar') == 123 - # 验证依赖注入. - foo = getattr(moss, 'foo') - Foo = runtime.module().__dict__['Foo'] - assert foo is not None and isinstance(foo, Foo) - assert foo.foo() == "hello" - - # 最后成功销毁. - runtime.destroy() + with compiler: + runtime = compiler.compile("__test__") + assert runtime is not None + + module = runtime.module() + # 名字相同. + assert module.__name__ != baseline.__name__ + assert module.__name__ == "__test__" + with runtime: + moss = runtime.moss() + moss.hello = "world" + result = runtime.execute(target="test_main", local_args=["moss"]) + assert result.returns == 3 + assert result.pycontext.get_prop("hello") == "world" def test_baseline_with_pycontext_code(): - container = test_container() + container = moss_container() compiler = container.force_fetch(MossCompiler) assert compiler is not None - # join context line = "print('hello')" compiler.join_context(PyContext(module=baseline.__name__, code=line)) diff --git a/tests/core/moss/test_decorators.py b/tests/core/moss/test_decorators.py index 3a787046..b57fe389 100644 --- a/tests/core/moss/test_decorators.py +++ b/tests/core/moss/test_decorators.py @@ -28,9 +28,11 @@ class Case(NamedTuple): Case(cls_source_code()(Foo), strip_source_indent(inspect.getsource(Foo))), Case(Foo, strip_source_indent(inspect.getsource(Foo))), ] + idx = 0 for case in cases: prompt = get_prompt(case.value) - assert prompt == case.expect + assert prompt == case.expect, f"{idx} and case is {case}" + idx += 1 @definition(doc="test") @@ -68,6 +70,6 @@ class BarImpl(Bar): bar: int = 234 bar_prompt = get_prompt(Bar) - bar_impl_prompt = get_prompt(BarImpl) assert "Bar:" in bar_prompt + bar_impl_prompt = get_prompt(BarImpl) assert "BarImpl(Bar):" in bar_impl_prompt diff --git a/tests/core/moss/test_prompts.py b/tests/core/moss/test_prompts.py index 3dfdc75d..1c2fedf5 100644 --- a/tests/core/moss/test_prompts.py +++ b/tests/core/moss/test_prompts.py @@ -1,12 +1,11 @@ import inspect -from types import ModuleType from ghostos.core.moss import prompts from ghostos.core.moss.prompts import reflect_module_locals, compile_attr_prompts import unittest from ghostos.core.moss.impl import MossRuntimeImpl -from ghostos.core.moss.abc import ( +from ghostos.core.moss.abcd import ( MOSS_HIDDEN_MARK, MOSS_HIDDEN_UNMARK, ) @@ -22,15 +21,10 @@ def test_prompts_baseline(): array.append((name, prompt)) data[name] = prompt # 从 utils 模块里定义的. - assert "is_typing" in data + assert "get_callable_definition" in data # typing 库本身的不会出现. assert "Optional" not in data # 引用的抽象类应该存在. - assert "PromptAble" in data - - prompt = compile_attr_prompts(ModuleType("test"), array) - assert "class PromptAble" in prompt - def test_prompts_mark_judgement(): @@ -74,7 +68,7 @@ def hidden_function(): return "hidden" {MOSS_HIDDEN_UNMARK} print(foo())""" - assert parser(code3, exclude_moss_mark_code=False) == expected3 + assert parser(code3, exclude_hide_code=False) == expected3 # test_multiple_hidden_sections code4 = f"""def foo(): diff --git a/tests/core/moss/test_pycontext.py b/tests/core/moss/test_pycontext.py index d1d967b0..faa2b2b9 100644 --- a/tests/core/moss/test_pycontext.py +++ b/tests/core/moss/test_pycontext.py @@ -1,25 +1,19 @@ +import json from typing import NamedTuple, Any, List, TypedDict, Optional from types import ModuleType -from ghostos.core.moss.pycontext import PyContext, Injection, Property, attr +from ghostos.core.moss.pycontext import PyContext from pydantic import BaseModel, Field -def test_pycontext_imported(): - c = PyContext() - c.inject(Injection(import_from="foo:bar")) - assert len(c.injections) == 1 - - def test_pycontext_join_baseline(): left = PyContext() - i = Injection.reflect(Injection) - left.inject(i) + left.set_prop("foo", 123) right = PyContext() - right.inject(Injection(import_from="foo:bar")) + right.set_prop("bar", 234) joined = left.join(right) - assert len(left.injections) == 1 - assert len(right.injections) == 1 - assert len(joined.injections) == 2 + assert len(left.properties) == 1 + assert len(right.properties) == 1 + assert len(joined.properties) == 2 class Foo(BaseModel): @@ -31,26 +25,23 @@ class Bar(TypedDict, total=False): def test_property_with_values(): - case = NamedTuple("Case", [("name", str), ("value", Any), ("desc", str)]) + case = NamedTuple("Case", [("name", str), ("value", Any)]) cases: List[case] = [ - case("foo", 123, ""), - case("bar", None, "none"), - case("a", 1.0, "abc"), - case("", False, "abc"), - case("foo", Foo(), ""), - case("bar", Bar(), ""), + case("foo", 123), + case("bar", None), + case("a", 1.0), + case("", False), + case("foo", Foo()), + case("bar", Bar(bar="hello")), ] + pycontext = PyContext() for c in cases: - p = Property.from_value(name=c.name, value=c.value, desc=c.desc) - assert p.generate_value() is c.value - assert p.name is c.name - assert p.desc is c.desc - - j = p.model_dump(exclude_defaults=True) - p = Property(**j) - assert p.generate_value() == c.value - assert p.name == c.name - assert p.desc == c.desc + pycontext.set_prop(c.name, c.value) + j = pycontext.model_dump_json() + data = json.loads(j) + new_one = PyContext(**data) + value = new_one.get_prop(c.name) + assert c.value == value, j def test_property_with_local_module(): @@ -65,31 +56,10 @@ class Foo(BaseModel): exec(compiled, module.__dict__) foo = module.__dict__["foo"] assert foo.foo == 123 - p = Property.from_value(name="foo", value=foo) - assert foo is p.generate_value(module) - j = p.model_dump(exclude_defaults=True) - p = Property(**j) + pycontext = PyContext() + pycontext.set_prop(name="foo", value=foo) + assert foo == pycontext.get_prop("foo", module) + j = pycontext.model_dump(exclude_defaults=True) + p = PyContext(**j) # 从当前 module 里重新还原出来. - assert p.generate_value(module) == foo - - -def test_bind_property_as_attr(): - class Zoo: - foo: Foo = attr(Foo(foo=123), desc="foo") - bar: Optional[str] = attr(None, desc="bar") - - z = Zoo() - assert Zoo.foo is z.foo - assert Zoo.foo.foo is 123 - assert Zoo.bar is None - z.bar = "bar" - assert z.bar == "bar" - # 给实例赋值时污染了类. - assert Zoo.bar == "bar" - - foo_prop = Zoo.__dict__["foo"] - assert isinstance(foo_prop, Property) - assert foo_prop.name == "foo" - - assert str(foo_prop) - assert f"{foo_prop}" + assert p.get_prop("foo", module) == foo diff --git a/tests/helpers/test_tree_sitter.py b/tests/helpers/test_tree_sitter.py index 33376c38..6b60394c 100644 --- a/tests/helpers/test_tree_sitter.py +++ b/tests/helpers/test_tree_sitter.py @@ -1 +1,3 @@ -from ghostos.helpers.tree_sitter import PyNode, PyModuleNode +from ghostos.helpers.tree_sitter import tree_sitter_parse + + diff --git a/tests/python/test_class.py b/tests/python/test_class.py index 071b3ef1..3ddc55b7 100644 --- a/tests/python/test_class.py +++ b/tests/python/test_class.py @@ -232,3 +232,10 @@ class _Baz(_Bar): b = _Baz() assert b.foo == 1 + + +def test_attr_of_class(): + class Foo: + foo = 1 + + assert Foo.foo == 1 diff --git a/tests/test_abc.py b/tests/test_abc.py index e40ee152..83dafdb0 100644 --- a/tests/test_abc.py +++ b/tests/test_abc.py @@ -1,7 +1,7 @@ -from ghostos.common import Prompter, PrompterClass +from ghostos.identifier import PromptAbleObj, PromptAbleClass import inspect def test_is_abstract(): - assert inspect.isabstract(Prompter) - assert inspect.isabstract(PrompterClass) + assert inspect.isabstract(PromptAbleObj) + assert inspect.isabstract(PromptAbleClass) diff --git a/tests/test_entity.py b/tests/test_entity.py new file mode 100644 index 00000000..d286bcb4 --- /dev/null +++ b/tests/test_entity.py @@ -0,0 +1,38 @@ +from ghostos.entity import to_entity_meta, from_entity_meta +from pydantic import BaseModel + + +class Foo: + foo = 1 + + def __eq__(self, other): + return self.foo == other.foo + + +class Baz(BaseModel): + baz: str = "hello" + + +class Bar(BaseModel): + baz: Baz + + +def test_entities(): + cases = [ + 1, + 0.5, + None, + False, + True, + "hello world", + [1, 2, 3, 4.5, "hello world"], + {"a": 1, "b": 2}, + {1:"a", "b": 2}, + Foo(), + Baz(), + ] + + for c in cases: + meta = to_entity_meta(c) + value = from_entity_meta(meta) + assert value == c, f"{c}: {value}" From c0e30a239b3267ee313db8ab4a7168ae8fdd541b Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 10 Nov 2024 11:47:19 +0800 Subject: [PATCH 061/148] dev: seperate moss injected prompters --- ghostos/core/moss/abcd.py | 4 +- ghostos/core/moss/impl.py | 39 +++++--------- ghostos/core/moss/lifecycle.py | 12 ----- ghostos/prototypes/mosstemp/__init__.py | 17 ------ ghostos/prototypes/mosstemp/template.py | 66 ----------------------- tests/core/moss/examples/test_baseline.py | 5 +- 6 files changed, 18 insertions(+), 125 deletions(-) delete mode 100644 ghostos/prototypes/mosstemp/__init__.py delete mode 100644 ghostos/prototypes/mosstemp/template.py diff --git a/ghostos/core/moss/abcd.py b/ghostos/core/moss/abcd.py index cc72b9bb..79e17ea5 100644 --- a/ghostos/core/moss/abcd.py +++ b/ghostos/core/moss/abcd.py @@ -7,7 +7,7 @@ from ghostos.core.moss.prompts import ( AttrPrompts, reflect_module_locals, compile_attr_prompts ) -from ghostos.core.moss.decorators import cls_source_code +from ghostos.prompter import Prompter """ MOSS 是 Model-oriented Operating System Simulation 的简写. @@ -320,7 +320,7 @@ def imported_attrs_prompt(self, auto_generation: bool = True) -> str: return compile_attr_prompts(prompts) @abstractmethod - def moss_injections_prompt(self) -> str: + def moss_injected_prompters(self) -> Dict[str, Prompter]: """ prompt for moss injections. """ diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index ed142d10..e0c8066a 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -173,8 +173,9 @@ def __init__( self._built: bool = False self._moss_prompt: Optional[str] = None self._attr_prompts: Dict[str, str] = attr_prompts - self._moss: Moss = self._compile_moss() self._destroyed: bool = False + self._injected = set() + self._moss: Moss = self._compile_moss() def _compile_moss(self) -> Moss: moss_type = self.moss_type() @@ -194,6 +195,7 @@ def _compile_moss(self) -> Moss: for name, injection in self._injections.items(): setattr(moss, name, injection) + self._injected.add(name) # 初始化基于容器的依赖注入. typehints = get_type_hints(moss_type, localns=self._compiled.__dict__) @@ -202,16 +204,13 @@ def _compile_moss(self) -> Moss: continue # 已经有的就不再注入. - item = None if hasattr(moss, name): - item = getattr(moss, name) - if item is not None: continue - # 为 None 才依赖注入. value = self._container.force_fetch(typehint) # 依赖注入. setattr(moss, name, value) + self._injected.add(name) self._compiled.__dict__[MOSS_VALUE_NAME] = moss self._compiled.__dict__[MOSS_TYPE_NAME] = moss_type @@ -233,11 +232,7 @@ def moss(self) -> Moss: return self._moss def dump_pycontext(self) -> PyContext: - if not self._moss: - return self._pycontext - if self._moss.__pycontext__ is self._pycontext: - return self._pycontext - return self._moss.__pycontext__ + return self._pycontext def dump_std_output(self) -> str: return self._runtime_std_output @@ -256,24 +251,14 @@ def pycontext_code( code = self._source_code return self._parse_pycontext_code(code, exclude_hide_code) - def moss_injections_prompt(self) -> str: + def moss_injected_prompters(self) -> Dict[str, Prompter]: moss = self.moss() - prompts = {} - for name, value in moss.__dict__.items(): - if name.startswith('_'): - continue - key = f"Moss.{name}" - if isinstance(value, Prompter): - prompt = value.get_prompt(self.container()) - prompts[key] = add_comment_mark(prompt) - continue - prompt = get_defined_prompt(value) - if prompt: - prompts[name] = add_comment_mark(prompt) - result = "" - for name, prompt in prompts.items(): - result += f"# \n{prompt}\n# \n" - return result.strip() + prompters = {} + for name in self._injected: + injection = getattr(moss, name) + if isinstance(injection, Prompter): + prompters[name] = injection + return prompters @staticmethod def _parse_pycontext_code(code: str, exclude_hide_code: bool = True) -> str: diff --git a/ghostos/core/moss/lifecycle.py b/ghostos/core/moss/lifecycle.py index b85a4366..08bf0bbb 100644 --- a/ghostos/core/moss/lifecycle.py +++ b/ghostos/core/moss/lifecycle.py @@ -85,24 +85,12 @@ def __moss_code_prompt__(prompter: "MossPrompter") -> str: """ {attrs_prompt} """ -''' - - injection_prompt = prompter.moss_injections_prompt() - injection_prompt_part = "" - if injection_prompt: - injection_prompt = escape_string_quotes(injection_prompt, '"""') - injection_prompt_part = f''' -# information about moss injections: -""" -{injection_prompt} -""" ''' # 生成完整的 prompt. 预计 MOSS 的描述已经在上下文里了. prompt = f""" {origin_code} {code_prompt_part} -{injection_prompt_part} """ return prompt diff --git a/ghostos/prototypes/mosstemp/__init__.py b/ghostos/prototypes/mosstemp/__init__.py deleted file mode 100644 index 90de905c..00000000 --- a/ghostos/prototypes/mosstemp/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -import inspect -from typing import Optional - -from ghostos.prototypes.mosstemp import template -from ghostos.helpers import get_calling_modulename, rewrite_module_by_path - -__all__ = ['init_moss_module'] - - -def init_moss_module(modulename: Optional[str] = None): - """ - init moss file with default template - """ - if not modulename: - modulename = get_calling_modulename(1) - source = inspect.getsource(template) - rewrite_module_by_path(modulename, source) diff --git a/ghostos/prototypes/mosstemp/template.py b/ghostos/prototypes/mosstemp/template.py deleted file mode 100644 index 3d73dccf..00000000 --- a/ghostos/prototypes/mosstemp/template.py +++ /dev/null @@ -1,66 +0,0 @@ -from typing import Optional -from ghostos.core.ghosts import Operator -from ghostos.core.moss import Moss as Parent - - -# todo: import necessary libraries and methods - - -class Moss(Parent): - """ - todo: define attrs and dependency injection - """ - pass - - -# todo: can write in-context learning cases for llm -if __name__ == "__examples__": - def example_hello_world_main(moss: Moss) -> Optional[Operator]: - """ - todo: use docstring to describe the user query and planning thought of this example case - """ - # todo: the example codes - pass - -# the content between mark are not visible in the prompt for LLM - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - # todo: these libraries are useful for lifecycle functions - pass - -# todo: can define these OPTIONAL lifecycle hooks -from ghostos.core.moss.lifecycle import ( - __moss_compile__ as __default_moss_compile__, - __moss_attr_prompts__ as __default_moss_attr_prompts__, - __moss_code_prompt__ as __default_moss_prompt__, - __moss_exec__ as __default_moss_exec__, -) - -# todo: define or remove this __moss_compile__ -__moss_compile__ = __default_moss_compile__ -""" do something before MossCompiler.compile() """ - -# todo: define or remove this __moss_attr_prompts__ -__moss_attr_prompts__ = __default_moss_attr_prompts__ -""" define prompt for the module attr name. set [attr_name] to '' means not to prompt it. """ - -# todo: define or remove this __moss_prompt__ -__moss_prompt__ = __default_moss_prompt__ -""" define prompt generation """ - -# todo: define or remove this __moss_exec__ -__moss_exec__ = __default_moss_exec__ -""" redefine the moss exec function. not recommended""" - -# todo: can define a moss thought in a moss file -from ghostos.thoughts.moss_thought import MossThought - -thought = MossThought( - instruction="???", - moss_modulename=__name__, - llm_api_name="", -) - -# diff --git a/tests/core/moss/examples/test_baseline.py b/tests/core/moss/examples/test_baseline.py index 7de456f3..aa1184f1 100644 --- a/tests/core/moss/examples/test_baseline.py +++ b/tests/core/moss/examples/test_baseline.py @@ -47,6 +47,9 @@ def test_baseline_exec(): assert prompter is not None prompt = prompter.dump_context_prompt() + prompters = prompter.moss_injected_prompters() + assert "tester" in prompters + # plus 方法存在. assert 'def plus' in prompt # 在 moss 标记内的不展示. @@ -56,7 +59,6 @@ def test_baseline_exec(): assert "def getsource(" not in prompt # 添加的意义不明的注释也应该存在了. assert "# hello world" in prompt - assert "TestPrompter" in prompt # assert moss moss = runtime.moss() @@ -84,6 +86,7 @@ def test_baseline_exec(): # 验证依赖注入. foo = getattr(moss, 'foo') Foo = runtime.module().__dict__['Foo'] + assert Foo is baseline.Foo moss.fetch(Foo) assert foo is not None and isinstance(foo, Foo) assert foo.foo() == "hello" From 192b61bc5d8391c4e6b63bc91f33320ced98e21f Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 10 Nov 2024 12:13:46 +0800 Subject: [PATCH 062/148] dev: update prompter with test --- ghostos/core/abcd/concepts.py | 2 +- ghostos/core/abcd/prompters.py | 2 +- ghostos/core/moss/examples/baseline.py | 5 +- ghostos/prompter.py | 79 +++++++++++++++++++++++--- tests/test_prompter.py | 19 +++++++ 5 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 tests/test_prompter.py diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index b979cb4b..eba6ed68 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -115,7 +115,7 @@ class Context(Payload, Prompter, ABC): key = "ghostos_context" @abstractmethod - def self_prompt(self, container: Container, depth: int = 0) -> str: + def self_prompt(self, container: Container) -> str: """ generate prompt from model values with libraries that container provides. :param container: IoC container provides library implementation. diff --git a/ghostos/core/abcd/prompters.py b/ghostos/core/abcd/prompters.py index 5651ac12..c908a371 100644 --- a/ghostos/core/abcd/prompters.py +++ b/ghostos/core/abcd/prompters.py @@ -12,5 +12,5 @@ class SystemPrompter(Prompter): description="meta prompt for agent", ) - def self_prompt(self, container: Container, depth: int = 0) -> str: + def self_prompt(self, container: Container) -> str: return self.meta_prompt diff --git a/ghostos/core/moss/examples/baseline.py b/ghostos/core/moss/examples/baseline.py index a4d9df24..eeb0703c 100644 --- a/ghostos/core/moss/examples/baseline.py +++ b/ghostos/core/moss/examples/baseline.py @@ -24,9 +24,12 @@ def plus(a: int, b: int) -> int: class TestPrompter(Prompter): line: str = "TestPrompter" - def self_prompt(self, container: Container, depth: int = 0) -> str: + def self_prompt(self, container: Container) -> str: return self.line + def get_title(self) -> str: + return "" + class Moss(Parent, ABC): """ diff --git a/ghostos/prompter.py b/ghostos/prompter.py index ece03052..ff28cbd6 100644 --- a/ghostos/prompter.py +++ b/ghostos/prompter.py @@ -2,7 +2,7 @@ import inspect from typing import ( - List, Self, Union, Callable, Any, Protocol, + List, Self, Union, Callable, Any, Protocol, Optional, Dict, ) from abc import ABC, abstractmethod from types import ModuleType @@ -10,13 +10,14 @@ from ghostos.helpers import generate_import_path import json -from pydantic import BaseModel +from pydantic import BaseModel, Field from .entity import EntityClass, EntityMeta, from_entity_meta, to_entity_meta __all__ = [ 'get_defined_prompt', 'set_prompter', 'set_class_prompter', 'Prompter', + 'GroupPrmt', 'ParagraphPrmt', 'PromptAbleObj', 'PromptAbleClass', ] @@ -75,23 +76,35 @@ class Prompter(BaseModel, EntityClass, ABC): is strong-typed model for runtime alternative properties of a ghost. """ - __children__: List[Prompter] = [] + prompt_priority: float = Field(0.0, description='Priority of the prompter.') + + __children__: List[Prompter] """ children is fractal sub context nodes""" + __self_prompt__: Optional[str] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__children__ = [] + self.__self_prompt__ = None + def with_children(self, *children: Prompter) -> Prompter: self.__children__.extend(children) return self @abstractmethod - def self_prompt(self, container: Container, depth: int = 0) -> str: + def self_prompt(self, container: Container) -> str: """ generate prompt by self, without children :param container: - :param depth: :return: """ pass + @abstractmethod + def get_title(self) -> str: + pass + def get_prompt(self, container: Container, depth: int = 0) -> str: """ get prompt with container which provides libraries to generate prompt @@ -99,11 +112,26 @@ def get_prompt(self, container: Container, depth: int = 0) -> str: :param depth: :return: """ - self_prompt = self.self_prompt(container, depth=depth) - prompts = [self_prompt] + title = self.get_title() + if title: + title = '#' * (depth + 1) + ' ' + title + + if self.__self_prompt__ is not None: + self_prompt = self.__self_prompt__ + else: + self_prompt = self.self_prompt(container) + self.__self_prompt__ = self_prompt + + prompts = [title, self_prompt] for child in self.__children__: - prompts.append(child.get_prompt(container, depth=depth + 1)) - return "\n\n".join([prompt.rstrip() for prompt in prompts]) + child_prompt = child.get_prompt(container, depth=depth + 1) + prompts.append(child_prompt) + output = "" + for paragraph in prompts: + paragraph = paragraph.strip() + if paragraph: + output += "\n\n" + paragraph + return output.strip() def __to_entity_meta__(self) -> EntityMeta: type_ = generate_import_path(self.__class__) @@ -126,6 +154,39 @@ def __from_entity_meta__(cls, meta: EntityMeta) -> Self: children.append(from_entity_meta(child)) return result.with_children(*children) + def flatten(self, index: str = "") -> Dict[str, Self]: + if not index: + index = "0" + result = {index: self} + idx = 0 + for child in self.__children__: + sub_index = index + "." + str(idx) + sub_flatten = child.flatten(sub_index) + for key in sub_flatten: + result[key] = sub_flatten[key] + return result + + +class GroupPrmt(Prompter): + title: str = "" + + def self_prompt(self, container: Container) -> str: + return "" + + def get_title(self) -> str: + return self.title + + +class ParagraphPrmt(Prompter): + title: str = "" + content: str = "" + + def self_prompt(self, container: Container) -> str: + return self.content + + def get_title(self) -> str: + return self.title + class PromptAbleObj(ABC): """ diff --git a/tests/test_prompter.py b/tests/test_prompter.py new file mode 100644 index 00000000..6effff15 --- /dev/null +++ b/tests/test_prompter.py @@ -0,0 +1,19 @@ +from ghostos.prompter import Prompter, GroupPrmt, ParagraphPrmt +from ghostos.container import Container + + +def test_group_prompters(): + prompter = GroupPrmt( + title="1" + ).with_children( + GroupPrmt(title="1.1"), + GroupPrmt(title="1.2").with_children( + GroupPrmt(title="1.2.1"), + ParagraphPrmt(title="1.2.2", content="hello world"), + ) + ) + + c = Container() + p = prompter.get_prompt(container=c) + assert "# 1\n" in p + assert "\n### 1.2.2\n" in p From ab65fd4020e2c4de37522a6d4867ee0e11c960c5 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 10 Nov 2024 12:22:00 +0800 Subject: [PATCH 063/148] dev: fix aifunc imported libraries --- ghostos/core/aifunc/func.py | 6 +++--- ghostos/core/aifunc/interfaces.py | 10 +++++----- ghostos/core/llms/__init__.py | 2 -- ghostos/core/messages/openai.py | 6 +++--- ghostos/core/runtime/tasks.py | 3 ++- ghostos/core/runtime/threads.py | 1 - tests/python/test_pkg.py | 4 ++-- 7 files changed, 15 insertions(+), 17 deletions(-) diff --git a/ghostos/core/aifunc/func.py b/ghostos/core/aifunc/func.py index 418bd6cd..15eea42c 100644 --- a/ghostos/core/aifunc/func.py +++ b/ghostos/core/aifunc/func.py @@ -3,10 +3,10 @@ from abc import ABC from pydantic import BaseModel from ghostos.helpers import generate_import_path, import_from_path -from ghostos.identifier import PromptAbleClass +from ghostos.prompter import PromptAbleClass from ghostos.core.llms import LLMs, LLMApi from ghostos.core.moss.utils import make_class_prompt, add_comment_mark -from ghostos.core.moss.prompts import get_class_magic_prompt +from ghostos.core.moss.prompts import get_prompt from ghostos.core.moss.pycontext import PyContext import inspect @@ -38,7 +38,7 @@ def __class_prompt__(cls) -> str: return make_class_prompt(source=source, doc=AIFunc.__doc__, attrs=[]) source = inspect.getsource(cls) result_type = cls.__aifunc_result__ if cls.__aifunc_result__ is not None else get_aifunc_result_type(cls) - result_prompt = get_class_magic_prompt(result_type) + result_prompt = get_prompt(result_type) result_prompt = f"result type of {cls.__name__} (which maybe not imported yet) is :\n{result_prompt}" return source + "\n\n" + add_comment_mark(result_prompt) diff --git a/ghostos/core/aifunc/interfaces.py b/ghostos/core/aifunc/interfaces.py index 56bdeea3..3d55303f 100644 --- a/ghostos/core/aifunc/interfaces.py +++ b/ghostos/core/aifunc/interfaces.py @@ -9,7 +9,7 @@ from ghostos.identifier import Identifier from ghostos.helpers import generate_import_path, uuid from ghostos.container import Container -from ghostos.entity import EntityMeta, model_to_entity_meta, model_from_entity_meta +from ghostos.entity import EntityMeta, to_entity_meta, get_entity from pydantic import BaseModel, Field __all__ = [ @@ -144,7 +144,7 @@ class ExecFrame(BaseModel): @classmethod def from_func(cls, fn: AIFunc, depth: int = 0, parent_step_id: Optional[str] = None) -> "ExecFrame": return cls( - args=model_to_entity_meta(fn), + args=to_entity_meta(fn), parent_step=parent_step_id, depth=depth, ) @@ -153,15 +153,15 @@ def func_name(self) -> str: return self.args['type'] def get_args(self) -> AIFunc: - return model_from_entity_meta(self.args, AIFunc) + return get_entity(self.args, AIFunc) def set_result(self, result: AIFuncResult) -> None: - self.result = model_to_entity_meta(result) + self.result = to_entity_meta(result) def get_result(self) -> Optional[AIFuncResult]: if self.result is None: return None - return model_from_entity_meta(self.result, AIFuncResult) + return get_entity(self.result, AIFuncResult) def new_step(self) -> ExecStep: step = ExecStep( diff --git a/ghostos/core/llms/__init__.py b/ghostos/core/llms/__init__.py index 0f08622b..7e50417c 100644 --- a/ghostos/core/llms/__init__.py +++ b/ghostos/core/llms/__init__.py @@ -2,7 +2,6 @@ from ghostos.core.llms.configs import ModelConf, ServiceConf, LLMsConfig, OPENAI_DRIVER_NAME from ghostos.core.llms.llm import LLMs, LLMDriver, LLMApi from ghostos.core.llms.prompt import Prompt, PromptPipe, run_prompt_pipeline -from ghostos.core.llms.embedding import Embeddings, EmbedApi, Embedding from ghostos.core.llms.tools import LLMFunc, FunctionalToken __all__ = [ @@ -10,6 +9,5 @@ 'LLMs', 'LLMDriver', 'LLMApi', 'LLMFunc', 'FunctionalToken', 'ModelConf', 'ServiceConf', 'LLMsConfig', 'OPENAI_DRIVER_NAME', - 'Embedding', 'Embeddings', 'EmbedApi', # 'Quest', ] diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index 6bcb69f4..d614d3dd 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -12,8 +12,8 @@ from openai.types.chat.chat_completion_function_message_param import ChatCompletionFunctionMessageParam from openai.types.chat.chat_completion_tool_message_param import ChatCompletionToolMessageParam -from ghostos.core.messages.message import Message, MessageType, Role, Caller, PayloadItem -from ghostos.container import Provider, Container, INSTANCE +from ghostos.core.messages import Message, MessageType, Role, Caller, Payload +from ghostos.container import Provider, Container __all__ = [ "OpenAIMessageParser", "DefaultOpenAIMessageParser", "DefaultOpenAIParserProvider", @@ -61,7 +61,7 @@ def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) - pass -class CompletionUsagePayload(CompletionUsage, PayloadItem): +class CompletionUsagePayload(CompletionUsage, Payload): """ the strong-typed payload of OpenAI chat completion usage. """ diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index b7fd9df2..e60e2ac0 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -3,7 +3,8 @@ from abc import ABC, abstractmethod from enum import Enum from pydantic import BaseModel, Field -from ghostos.identifier import Identifier, Identical, EntityMeta +from ghostos.identifier import Identifier, Identical +from ghostos.entity import EntityMeta from ghostos.core.messages import Payload from contextlib import contextmanager diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index 985cb31b..6b474671 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -2,7 +2,6 @@ import time from abc import ABC, abstractmethod from pydantic import BaseModel, Field -from ghostos.identifier import EntityMeta from ghostos.core.messages import Message, copy_messages, Role from ghostos.core.moss.pycontext import PyContext from ghostos.core.llms import Prompt diff --git a/tests/python/test_pkg.py b/tests/python/test_pkg.py index d2481890..e7e24a89 100644 --- a/tests/python/test_pkg.py +++ b/tests/python/test_pkg.py @@ -2,6 +2,6 @@ def test_iter_modules(): - from ghostos.core import ghosts - values = pkgutil.iter_modules(ghosts.__path__, prefix=ghosts.__name__ + '.') + from ghostos.core import moss + values = pkgutil.iter_modules(moss.__path__, prefix=moss.__name__ + '.') assert len(list(values)) > 1 From 68807d1cc6aa8c4653a724bd2efaade23195faca Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 10 Nov 2024 12:35:48 +0800 Subject: [PATCH 064/148] dev: fix llms test case --- ghostos/framework/storage/memstorage.py | 2 ++ tests/framework/llms/test_llms.py | 36 ++++++++++++++++++---- tests/framework/tasks/test_storage_impl.py | 2 +- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/ghostos/framework/storage/memstorage.py b/ghostos/framework/storage/memstorage.py index e588d3f1..1bbb48da 100644 --- a/ghostos/framework/storage/memstorage.py +++ b/ghostos/framework/storage/memstorage.py @@ -20,6 +20,7 @@ def sub_storage(self, relative_path: str) -> "Storage": def get(self, file_path: str) -> bytes: key = join(self._namespace, file_path) + key = key.lstrip('/') if key not in self._saved: raise KeyError(key) return self._saved.get(key) @@ -30,6 +31,7 @@ def exists(self, file_path: str) -> bool: def put(self, file_path: str, content: bytes) -> None: key = join(self._namespace, file_path) + key = key.lstrip('/') self._saved[key] = content def dir(self, prefix_dir: str, recursive: bool, patten: Optional[str] = None) -> Iterable[str]: diff --git a/tests/framework/llms/test_llms.py b/tests/framework/llms/test_llms.py index 157be094..4ae489a6 100644 --- a/tests/framework/llms/test_llms.py +++ b/tests/framework/llms/test_llms.py @@ -1,19 +1,43 @@ import os + +import yaml + from ghostos.container import Container -from ghostos.core.llms import LLMsConfig, LLMs +from ghostos.core.llms import LLMsConfig, ServiceConf, ModelConf, LLMs from ghostos.contracts.configs import YamlConfig, Configs from ghostos.framework.configs import ConfigsByStorageProvider -from ghostos.framework.storage import FileStorageProvider +from ghostos.framework.storage import MemStorage, Storage from ghostos.framework.llms import ConfigBasedLLMsProvider def _prepare_container() -> Container: - dirname = os.path.dirname - demo_dir = dirname(__file__) + "/../../../ghostos/demo/" - demo_dir = os.path.abspath(demo_dir) container = Container() - container.register(FileStorageProvider(demo_dir)) + storage = MemStorage() + container.set(Storage, storage) container.register(ConfigsByStorageProvider('configs')) + + data = LLMsConfig( + services=[ + ServiceConf( + name='moonshot', + base_url="http://moonshot.com", + token="$MOONSHOT_TOKEN", + ) + ], + default="moonshot-v1-32k", + models={ + "moonshot-v1-32k": ModelConf( + model="moonshot-v1-32k", + service="moonshot" + ), + "gpt-4": dict( + model="moonshot-v1-32k", + service="moonshot" + ) + } + ) + + storage.put("configs/llms_conf.yml", yaml.safe_dump(data.model_dump()).encode()) return container diff --git a/tests/framework/tasks/test_storage_impl.py b/tests/framework/tasks/test_storage_impl.py index 056aed11..4f58ae4a 100644 --- a/tests/framework/tasks/test_storage_impl.py +++ b/tests/framework/tasks/test_storage_impl.py @@ -14,7 +14,7 @@ def test_storage_tasks_impl(): process_id="process_id", name="name", description="description", - meta=EntityMeta(type="type", data={}), + meta=EntityMeta(type="type", content=""), ) t = tasks.get_task(task.task_id, False) From 689c47ef80ca24e4a3f56c7946997741d91fda28 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 11 Nov 2024 20:19:38 +0800 Subject: [PATCH 065/148] dev: update moss agent in new pattern --- ghostos/core/abcd/__init__.py | 4 +- ghostos/core/abcd/concepts.py | 89 +++++--- ghostos/core/agents/functional_token.py | 72 ------ ghostos/core/agents/instructions.py | 83 +++++++ ghostos/core/agents/moss_agent.py | 218 ++++++++++++------ ghostos/core/agents/utils.py | 33 --- ghostos/core/aifunc/driver.py | 2 +- ghostos/core/messages/message.py | 75 +++++-- ghostos/core/moss/abcd.py | 32 +-- ghostos/core/moss/examples/baseline.py | 4 +- ghostos/core/moss/impl.py | 13 +- ghostos/core/moss/lifecycle.py | 4 +- ghostos/core/moss/test_suites.py | 2 +- ghostos/core/runtime/events.py | 6 +- ghostos/framework/actions/moss_action.py | 2 +- ghostos/framework/eventbuses/memimpl.py | 7 + ghostos/framework/logger/named.py | 3 +- ghostos/framework/messages/buffers.py | 2 +- ghostos/framework/streams/__init__.py | 4 - ghostos/framework/streams/array.py | 233 -------------------- ghostos/framework/streams/empty.py | 19 -- ghostos/framework/streams/queuestream.py | 51 ----- ghostos/helpers/__init__.py | 2 +- ghostos/helpers/tree_sitter.py | 56 ++++- ghostos/mocks/__init__.py | 0 ghostos/mocks/libraries/__init__.py | 0 ghostos/mocks/libraries/auto_text_memory.py | 48 ---- ghostos/prompter.py | 70 +++--- ghostos/prototypes/ghostfunc/driver.py | 2 +- tests/core/ghosts/test_thoughts.py | 18 -- tests/core/moss/examples/test_baseline.py | 4 +- tests/helpers/test_tree_sitter.py | 38 +++- tests/python/test_class.py | 39 +++- tests/python/test_func.py | 8 + tests/test_prompter.py | 15 +- 35 files changed, 587 insertions(+), 671 deletions(-) delete mode 100644 ghostos/core/agents/functional_token.py create mode 100644 ghostos/core/agents/instructions.py delete mode 100644 ghostos/core/agents/utils.py delete mode 100644 ghostos/framework/streams/__init__.py delete mode 100644 ghostos/framework/streams/array.py delete mode 100644 ghostos/framework/streams/empty.py delete mode 100644 ghostos/framework/streams/queuestream.py delete mode 100644 ghostos/mocks/__init__.py delete mode 100644 ghostos/mocks/libraries/__init__.py delete mode 100644 ghostos/mocks/libraries/auto_text_memory.py delete mode 100644 tests/core/ghosts/test_thoughts.py diff --git a/ghostos/core/abcd/__init__.py b/ghostos/core/abcd/__init__.py index c151b58f..a3755a86 100644 --- a/ghostos/core/abcd/__init__.py +++ b/ghostos/core/abcd/__init__.py @@ -1,2 +1,4 @@ -from .concepts import Ghost, GhostDriver, Operator, Session, GhostOS, StateValue, Action +from .concepts import ( + Ghost, GhostDriver, Operator, Session, GhostOS, StateValue, Action, +) from .ghosts import Agent diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index eba6ed68..c56285f7 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -49,7 +49,7 @@ 2. ghost shall be defined by code, which can be generated by meta-agents. """ -__all__ = ("Ghost", "Session", "GhostDriver", "GhostOS", "Operator", "StateValue") +__all__ = ("Ghost", "Session", "GhostDriver", "GhostOS", "Operator", "StateValue", "Action") class Ghost(Identifiable, EntityType, ABC): @@ -119,13 +119,16 @@ def self_prompt(self, container: Container) -> str: """ generate prompt from model values with libraries that container provides. :param container: IoC container provides library implementation. - :param depth: the depth of the context, usually decide the prompt title level :return: natural language prompt """ pass + @abstractmethod + def get_title(self) -> str: + pass + -class Operator(Protocol): +class Operator(ABC): """ Operator to operating the GhostOS through the Session encapsulation. @@ -358,7 +361,7 @@ def get_or_bind(self, session: Session) -> Self: return value -class Session(Protocol[G]): +class Session(Generic[G], ABC): """ Session 管理了一个有状态的会话. 所谓 "有状态的会话", 通常指的是: shell + ghost + 多轮对话/多轮思考 运行中的状态. @@ -373,9 +376,51 @@ class Scope(BaseModel): """ scope of the session. """ + root_id: str task_id: str parent_task_id: Optional[str] = None + class Flow(ABC): + """ + task flow + """ + + # --- 基本操作 --- # + @abstractmethod + def done(self, status: str = "", *replies: MessageKind) -> Operator: + """ + finish self task + :param status: describe status of the task + :param replies: replies to parent task or user + """ + pass + + @abstractmethod + def fail(self, status: str = "", *replies: MessageKind) -> Operator: + """ + self task failed. + :param status: describe status of the task + :param replies: replies to parent task or user + """ + pass + + @abstractmethod + def wait(self, status: str = "", *replies: MessageKind) -> Operator: + """ + wait for the parent task or user to provide more information or further instruction. + :param status: describe current status + :param replies: question, inform or + """ + pass + + @abstractmethod + def observe(self, *messages: MessageKind) -> Operator: + pass + + @abstractmethod + def on_error(self, *messages: MessageKind) -> Operator: + pass + scope: Scope """the running scope of the session""" @@ -406,7 +451,7 @@ def is_alive(self) -> bool: pass @abstractmethod - def ghost(self) -> G: + def get_ghost(self) -> G: """ current ghost instance :return: @@ -438,6 +483,10 @@ def refresh(self) -> Self: """ pass + @abstractmethod + def flow(self) -> Flow: + pass + @abstractmethod def messenger( self, *, @@ -461,36 +510,6 @@ def respond( """ pass - # --- 基本操作 --- # - @abstractmethod - def self_finish(self, status: str = "", *replies: MessageKind) -> Operator: - """ - finish self task - :param status: describe status of the task - :param replies: replies to parent task or user - """ - pass - - @abstractmethod - def self_fail(self, status: str = "", *replies: MessageKind) -> Operator: - """ - self task failed. - :param status: describe status of the task - :param replies: replies to parent task or user - """ - pass - - @abstractmethod - def self_wait(self, status: str = "", *replies: MessageKind) -> Operator: - """ - wait for the parent task or user to provide more information or further instruction. - :param status: describe current status - :param replies: question, inform or - """ - pass - - # --- subtask 相关 --- # - @abstractmethod def cancel_subtask(self, ghost: G, reason: str = "") -> None: """ diff --git a/ghostos/core/agents/functional_token.py b/ghostos/core/agents/functional_token.py deleted file mode 100644 index 02cec18b..00000000 --- a/ghostos/core/agents/functional_token.py +++ /dev/null @@ -1,72 +0,0 @@ -from ghostos.core.llms import FunctionalToken -from ghostos.core.moss.abcd import MossPrompter -from pydantic import BaseModel, Field - -__all__ = ['MOSSArgument', 'DEFAULT_MOSS_FUNCTIONAL_TOKEN', 'DEFAULT_MOSS_PROMPT_TEMPLATE', 'get_default_moss_prompt'] - - -class MOSSArgument(BaseModel): - code: str = Field(description="generated moss code that include `def main(os: MOSS) -> Operator`") - - -DEFAULT_MOSS_FUNCTIONAL_TOKEN = FunctionalToken( - token=">moss:", - name="moss", - description=""" -You can output the Python code that MOSS is supposed to run after this token. -The system will automatically execute them. -Notice: -- MOSS-related output is not visible to user. -- You are only able to generate MOSS code within this token. -- The content after this token shall be pure Python code only. -- You can send anything directly before this token, not after it. -- **Never** use ``` to embrace your code. -- Need not to mention the code you generated to user. -""".strip(), - deliver=False, - parameters=MOSSArgument.model_json_schema(), -) - -DEFAULT_MOSS_PROMPT_TEMPLATE = """ -# MOSS - -You are equipped with the MOSS (Model-oriented Operating System) that provides tools and thought directions in python interface. -With MOSS you shall generate a single block of Python code in which defines a function `def main(os: MOSS) -> Operator:`, -the MOSS will automatically execute them. - -**Directives for MOSS**: -- **Code Generation Only**: Produce a block of Python code for the `main` function. - The interface, class and abstract methods in context are ALREADY implemented in external system, - and passed into main as arguments, DON'T implement them or instantiate them again, - just invoke them directly on you need. -- **Format Requirement**: Your output must be a single block of Python code enclosed within triple backticks. - Do not include any additional text, comments, or explanations outside this code block. - Do not invoke main method by yourself. - -**External System Responsibilities**: -- **Execution and Data Fetching**: The external system will concatenate your code with the true context - (implemented all abstract methods and interface), execution the main method and wait to fetch the result. -- **Result Handling**: The external system will process the results and manage user interactions. - Std output will be buffed by MOSS, you can generate operator to observe them. - - -Here is the context provided to you in this turn: - -```python -{code} -``` - -**Notices** -0. You need not to use MOSS when you don't need it's api such as tell raw text or use other functional tokens. -1. MOSS is a private system only serve for you, **never mention it existence**. -2. MOSS code shall be generated within functional token, markdown python block will not do, and **don't repeat the code with markdown**. -3. MOSS will automatic execute the main function so you never execute it again. -4. **Return Operator**: You shall always use method that MOSS provide you to return an Operator from function main. -5. In the generated MOSS code, ** YOU SHALL NOT WRITE ANYTHING BUT CODE AND COMMENTS BECAUSE MOSS CODE NEVER SEND TO USER**. -6. Your generated code must include `def main(os: MOSS) -> Operator` method which will be executed following your intention. -""" - - -def get_default_moss_prompt(prompter: MossPrompter) -> str: - code_prompt = prompter.dump_context_prompt() - return DEFAULT_MOSS_PROMPT_TEMPLATE.format(code=code_prompt) diff --git a/ghostos/core/agents/instructions.py b/ghostos/core/agents/instructions.py new file mode 100644 index 00000000..5a2d5b71 --- /dev/null +++ b/ghostos/core/agents/instructions.py @@ -0,0 +1,83 @@ +from ghostos.core.moss import MossRuntime +from ghostos.prompter import Prompter, TextPrmt +from ghostos.identifier import Identifier + +AGENT_INTRODUCTION = """ +You are the mind of an AI Agent driven by `GhostOS` framework. +Here are some basic information you might expect: +""" + +GHOSTOS_INTRODUCTION = """ +`GhostOS` is an AI Agent framework written in Python, +providing llm connections, body shell, tools, memory etc and specially the `MOSS` for you. +""" + +MOSS_INTRODUCTION = """ +You are equipped with the MOSS (Model-oriented Operating System Simulator). +Which provides you a way to control your body / tools / thoughts through Python code. + +basic usage: +1. you will get the python code context that MOSS provide to you below. +2. you can generate python code to the tool named `moss`, the code will be automatically executed by the outer system. +3. if you print anything in your generated code, the output will be shown in further messages. + +the python code you generated, must include a main function, follow the pattern: +```python +def main(moss: Moss): + \""" + :param moss: instance of the class `Moss`, the properties on it will be injected with runtime implementations. + :return: Union[Operator, None], if None, the outer system will perform default action. + Otherwise, the outer system will execute the operator. + You shall only return operator by the libraries provided by `moss`. + \""" +``` + +* the outer system will execute the main function to realize your will. +* if the python code context can not fulfill your will, do not use the `moss` tool. +* you can reply as usual without calling the tool `moss`. use it only when you know what you're doing. +* the code you generated executed only once and do not add to the python context. + But the properties on moss instance, will keep existence. + You can bind variables of type int/float/bool/str/list/dict/BaseModel to moss instance if you need them for next turn. +""" + +MOSS_CONTEXT_TEMPLATE = """ +The python context that MOSS provides to you are below: +```python +{code_context} +``` +""" + +MOSS_FUNCTION_DESC = """ +useful to generate execution code of `MOSS`, notice the code must include a `main` function. +""" + + +def get_moss_context_prompter(title: str, runtime: MossRuntime) -> Prompter: + code_context = runtime.prompter().dump_code_context() + injections = runtime.moss_injections() + children = [] + container = runtime.container() + for name, injection in injections.items(): + if isinstance(injection, Prompter): + prompter = TextPrmt( + title=f"property moss.{name}", + content=injection.self_prompt(container), + ) + children.append(prompter) + return TextPrmt( + title=title, + content=code_context, + ).with_children(*children) + + +def get_agent_identity(title: str, id_: Identifier) -> Prompter: + return TextPrmt( + title=title, + content=f""" +`name`: +{id_.name} + +`description`: +{id_.description} +""" + ) diff --git a/ghostos/core/agents/moss_agent.py b/ghostos/core/agents/moss_agent.py index a9fc89e2..3eb6fb66 100644 --- a/ghostos/core/agents/moss_agent.py +++ b/ghostos/core/agents/moss_agent.py @@ -1,18 +1,21 @@ -from typing import Union, Optional, Protocol, Dict, Any, TypeVar, Generic, List +from typing import Union, Optional, Dict, Any, TypeVar, List, Self from types import ModuleType -from abc import ABC, abstractmethod from ghostos.identifier import Identifier from pydantic import BaseModel, Field -from ghostos.helpers import import_from_path, generate_import_path +from ghostos.helpers import import_from_path +from ghostos.prompter import TextPrmt, Prompter from ghostos.core.abcd import GhostDriver, Operator, Agent, Session, StateValue, Action -from ghostos.core.runtime import Event, Runtime, GoThreadInfo -from ghostos.core.moss import MossCompiler, PyContext -from ghostos.core.messages import Message, Caller -from ghostos.core.llms import LLMs, LLMApi, Prompt, PromptPipe -from ghostos.container import Container -from .utils import make_agent_task_id +from ghostos.core.runtime import Event, GoThreadInfo +from ghostos.core.moss import MossCompiler, PyContext, Moss, MossRuntime +from ghostos.core.messages import Message, Caller, Role +from ghostos.core.llms import LLMs, LLMApi, Prompt, PromptPipe, LLMFunc +from .instructions import ( + GHOSTOS_INTRODUCTION, MOSS_INTRODUCTION, AGENT_INTRODUCTION, MOSS_FUNCTION_DESC, + get_moss_context_prompter, get_agent_identity, +) +import json class MossAgent(BaseModel, Agent): @@ -44,23 +47,6 @@ def __identifier__(self) -> Identifier: A = TypeVar("A", bound=MossAgent) -class Moss(Generic[A], ABC): - """ - the model-oriented operating system defined in the moss module - useful for: - 1. inject dynamic implementations by IoC Container - 2. inject session state values. - - and the state values injected to the Moss instance, will bind to the session as default. - """ - - self: A - """self moss agent""" - - props: A.Props - """self props""" - - # --- lifecycle methods of moss agent --- # @@ -78,8 +64,6 @@ def __agent_contextual_prompt__(agent: A, moss: Moss) -> str: return "" -# - __agent__: Optional[MossAgent] = None """ magic attr that predefine an agent of the module with given persona and instruction.""" @@ -118,9 +102,6 @@ def get_goal(self, session: Session) -> Optional[MossAgent.GoalType]: return fn(self.ghost, moss) def on_event(self, session: Session, event: Event) -> Union[Operator, None]: - event = self.filter_event(session, event) - if event is None: - return None thread = self.update_with_event(session, event) @@ -128,14 +109,16 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: with compiler: rtm = compiler.compile(self.ghost.compile_module) with rtm: - moss = rtm.moss() # prepare instructions. - instructions = self.get_instructions(session, moss) + instructions = self.get_instructions(session, rtm) # prepare prompt prompt = thread.to_prompt(instructions) - prompt = self.prepare_prompt(session, moss, prompt) - actions = self.get_actions(session) + prompt = self.update_prompt_by_moss(rtm, prompt) + + # prepare actions + actions = self.get_actions(session, rtm) for action in actions: + # update prompt with action if isinstance(action, PromptPipe): prompt = action.update_prompt(prompt) @@ -152,26 +135,32 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: op = action.run(session, caller) if op is not None: return op - return session.self_wait() - - def filter_event(self, session: Session, event: Event) -> Union[Event, None]: - return event - - def get_instructions(self, session: Session, moss: object) -> List[Message]: - # instruction of moss agent is composed by: - # 1. meta prompt. - # 2. persona. - # 3. instruction. - # 4. props - # 5. injections. - # 6. contextual prompt. - pass - - def prepare_prompt(self, session: Session, moss: object, prompt: Prompt) -> Prompt: - pass + return session.flow().wait() + + def get_instructions(self, session: Session, moss_rtm: MossRuntime) -> List[Message]: + """ + generate moss agent's instruction + :param session: + :param moss_rtm: + :return: + """ + prompter = self.get_instruction_prompter(session, moss_rtm) + instruction = prompter.get_prompt(session.container, depth=0) + return [Role.SYSTEM.new(content=instruction)] + + def get_instruction_prompter(self, session: Session, runtime: MossRuntime) -> Prompter: + prompter = MossAgentPrompter.new( + self.ghost, + runtime, + ) + return prompter - def get_actions(self, session: Session) -> Dict[str, Action]: - pass + def get_actions(self, session: Session, runtime: MossRuntime) -> Dict[str, Action]: + """ + get moss agent's actions. default is moss action. + """ + moss_action = MossAction(runtime) + return {moss_action.name(): moss_action} def get_llmapi(self, session: Session) -> LLMApi: llms = session.container.force_fetch(LLMs) @@ -195,6 +184,7 @@ def get_compiler(self, session: Session) -> MossCompiler: # bind agent level injections. injection_fn = __agent_moss_injections__ module = self.get_module() + # if magic function __agent_moss_injections__ exists, use it to get some instance level injections to moss. if __agent_moss_injections__.__name__ in module.__dict__: injection_fn = getattr(module, __agent_moss_injections__.__name__) injections = injection_fn(self.ghost, session) @@ -203,29 +193,121 @@ def get_compiler(self, session: Session) -> MossCompiler: return compiler def get_pycontext(self, session: Session) -> PyContext: - pycontext_key = generate_import_path(PyContext) - data = session.state.get(pycontext_key, None) + """ + get moss pycontext. moss pycontext is bind to session.state as default. + :param session: + :return: + """ + pycontext = SessionPyContext( + module=self.ghost.moss_module, + ) + return pycontext.get_or_bind(session) + + +class MossAgentPrompter(TextPrmt): + + @classmethod + def new(cls, agent: MossAgent, runtime: MossRuntime) -> Self: + children = [ + # system meta prompt + TextPrmt(title="Meta Instruction", content=AGENT_INTRODUCTION).with_children( + TextPrmt(title="GhostOS", content=GHOSTOS_INTRODUCTION), + TextPrmt(title="MOSS", content=MOSS_INTRODUCTION), + # code context + get_moss_context_prompter("Code Context", runtime), + ), + # agent prompt + TextPrmt( + title="Agent Info", + content="The Agent info about who you are and what you are doing: ", + ).with_children( + get_agent_identity("Identity", agent.__identifier__()), + TextPrmt(title="Persona", content=agent.persona), + TextPrmt(title="Instructions", content=agent.instruction), + ), + ] + return cls().with_children(*children) + + +class SessionPyContext(PyContext, StateValue): + + def get(self, session: Session) -> Optional[Self]: + data = session.state.get(SessionPyContext.__name__, None) if data is not None: - pycontext = PyContext(**data) - else: - pycontext = PyContext( - module=self.ghost.moss_module, - ) - return pycontext + if isinstance(data, Dict): + return SessionPyContext(**data) + elif isinstance(data, SessionPyContext): + return data + return None - -META_PROMPT = """ -""" + def bind(self, session: Session) -> None: + session.state[SessionPyContext.__name__] = self class MossAction(Action, PromptPipe): + class Argument(BaseModel): + code: str = Field( + description="generated moss code", + ) + + def __init__(self, runtime: MossRuntime): + self.runtime: MossRuntime = runtime def name(self) -> str: return "moss" def update_prompt(self, prompt: Prompt) -> Prompt: - pass + parameters = self.Argument.model_json_schema() + llm_func = LLMFunc( + name=self.name(), + description=MOSS_FUNCTION_DESC, + parameters=parameters, + ) + prompt.functions.append(llm_func) + return prompt def run(self, session: Session, caller: Caller) -> Union[Operator, None]: - pass -# + # prepare arguments. + arguments = caller.arguments + data = json.loads(arguments) + args = self.Argument(**data) + code = args.code.strip() + + # if code is not exists, inform the llm + if not code: + return self.fire_error(session, caller, "the moss code is empty") + + error = self.runtime.lint_exec_code(code) + if error: + return self.fire_error(session, caller, f"the moss code has syntax errors:\n{error}") + + moss = self.runtime.moss() + try: + result = self.runtime.execute(target="main", args=[moss]) + op = result.returns + if op is not None and not isinstance(op, Operator): + return self.fire_error(session, caller, "result of moss code is not None or Operator") + pycontext = result.pycontext + # rebind pycontext to bind session + pycontext = SessionPyContext(**pycontext.model_dump(exclude_defaults=True)) + pycontext.bind(session) + + # handle std output + std_output = result.std_output + if std_output: + output = f"Moss output:\n{std_output}" + message = caller.new_output(output) + if op is None: + # if std output is not empty, and op is none, observe the output as default. + return session.flow().observe(message) + else: + session.respond([message], remember=True) + return op + + except Exception as e: + return self.fire_error(session, caller, f"error executing moss code: {e}") + + @staticmethod + def fire_error(session: Session, caller: Caller, error: str) -> Operator: + message = caller.new_output(error) + return session.flow().on_error(message) diff --git a/ghostos/core/agents/utils.py b/ghostos/core/agents/utils.py deleted file mode 100644 index 27b27dd0..00000000 --- a/ghostos/core/agents/utils.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import Optional, Iterable, List, Callable, Self, ClassVar, Union, Type, TypeVar, Generic -from abc import ABC, abstractmethod -from ghostos.identifier import Identical, Identifier, EntityMeta, to_entity_meta, get_identifier -from ghostos.core.runtime import ( - Event, Session, GoTaskStruct, Runtime, -) -from ghostos.core.messages import Message -from ghostos.core.llms import Prompt, LLMApi -from ghostos.container import Container, Contracts -from ghostos.core.abcd.ghostos import Ghost, GhostDriver, Operator -from ghostos.core.abcd.ghosts import Agent -from ghostos.core.abcd.utils import make_unique_ghost_id - - -def make_agent_task_id(runtime: Runtime, agent: Agent, parent_task_id: Optional[str] = None) -> str: - """ - agent is singleton to its parent. - """ - parent_task_id = parent_task_id if parent_task_id else "" - id_ = get_identifier(agent) - task_id = make_unique_ghost_id( - runtime.shell_id, - process_id=runtime.process_id, - parent_task_id=parent_task_id, - agent_name=id_.name, - ) - return task_id - - -def update_session_with_event(session: Session, event: Event): - thread = session.thread() - thread.new_turn(event) - session.update_thread(thread) diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index 27439766..3ef8554d 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -124,7 +124,7 @@ def generate_system_messages(self, runtime: MossRuntime) -> List[Message]: aifunc_class = aifunc_cls.__name__ aifunc_result_type = get_aifunc_result_type(aifunc_cls) aifunc_result_class = aifunc_result_type.__name__ - moss_code = runtime.prompter().dump_context_prompt() + moss_code = runtime.prompter().dump_code_context() prompt = default_aifunc_prompt( aifunc_class=aifunc_class, aifunc_result_class=aifunc_result_class, diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 1678bbe3..527d4010 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -1,3 +1,4 @@ +from __future__ import annotations import enum import time from typing import Optional, Dict, Set, Iterable, Union, List, Any, ClassVar @@ -123,19 +124,6 @@ def is_protocol_type(cls, value: str) -> bool: return value in {cls.ERROR, cls.FINAL} -class Caller(BaseModel): - """ - 消息协议中用来描述一个工具或者function 的调用请求. - """ - id: Optional[str] = Field(default=None, description="caller 的 id, 用来 match openai 的 tool call 协议. ") - name: str = Field(description="方法的名字.") - arguments: str = Field(description="方法的参数. ") - functional_token: bool = Field(default=False, description="caller 是否是基于协议生成的?") - - def add(self, message: "Message") -> None: - message.callers.append(self) - - # the Message class is a container for every kind of message and it's chunks. # I need this container because: # 1. I hate weak-type container of message, countless type checking and adapting @@ -461,6 +449,67 @@ def to_openai_param(self) -> Dict: pass +class Caller(BaseModel): + """ + 消息协议中用来描述一个工具或者function 的调用请求. + """ + id: Optional[str] = Field(default=None, description="caller 的 id, 用来 match openai 的 tool call 协议. ") + name: str = Field(description="方法的名字.") + arguments: str = Field(description="方法的参数. ") + functional_token: bool = Field(default=False, description="caller 是否是基于协议生成的?") + + def add(self, message: "Message") -> None: + message.callers.append(self) + + def new_output(self, output: str) -> CallerOutput: + return CallerOutput( + call_id=self.id, + name=self.name, + content=output, + ) + + +class CallerOutput(BaseModel, MessageClass): + call_id: Optional[str] = Field(None, description="caller id") + name: str = Field(description="caller name") + content: Optional[str] = Field(description="caller output") + + def to_message(self) -> Message: + return Message( + ref_id=self.call_id, + type=MessageType.FUNCTION_OUTPUT.value, + name=self.name, + role="", + content=self.content, + ) + + @classmethod + def from_message(cls, container: Message) -> Optional[Self]: + if container.type != MessageType.FUNCTION_OUTPUT.value: + return None + return cls( + call_id=container.ref_id, + name=container.name, + output=container.content, + ) + + def to_openai_param(self) -> Dict: + from openai.types.chat.chat_completion_tool_message_param import ChatCompletionToolMessageParam + from openai.types.chat.chat_completion_function_message_param import ChatCompletionFunctionMessageParam + if self.call_id: + return ChatCompletionToolMessageParam( + content=self.content, + role="tool", + tool_call_id=self.call_id, + ) + else: + return ChatCompletionFunctionMessageParam( + content=self.content, + name=self.name, + role="function", + ) + + class MessageClasses: def __init__( self, diff --git a/ghostos/core/moss/abcd.py b/ghostos/core/moss/abcd.py index 79e17ea5..f1338cc7 100644 --- a/ghostos/core/moss/abcd.py +++ b/ghostos/core/moss/abcd.py @@ -7,7 +7,6 @@ from ghostos.core.moss.prompts import ( AttrPrompts, reflect_module_locals, compile_attr_prompts ) -from ghostos.prompter import Prompter """ MOSS 是 Model-oriented Operating System Simulation 的简写. @@ -319,14 +318,7 @@ def imported_attrs_prompt(self, auto_generation: bool = True) -> str: prompts = [(name, done[name]) for name in names] return compile_attr_prompts(prompts) - @abstractmethod - def moss_injected_prompters(self) -> Dict[str, Prompter]: - """ - prompt for moss injections. - """ - pass - - def dump_context_prompt(self) -> str: + def dump_code_context(self) -> str: """ 获取 MOSS 运行时的完整 Python context 的 Prompt. 这个 Prompt 包含以下几个部分: @@ -334,13 +326,13 @@ def dump_context_prompt(self) -> str: 2. pycontext_code_prompt: 对 predefined code 里各种引用类库的描述 prompt. 会包裹在 `\"""` 中展示. 3. moss_prompt: moss 会注入到当前上下文里, 因此会生成 MOSS Prompt. """ - from ghostos.core.moss.lifecycle import __moss_code_prompt__ + from ghostos.core.moss.lifecycle import __moss_code_context__ compiled = self.module() # 基于 moss prompter 来生成. - if hasattr(compiled, __moss_code_prompt__.__name__): - fn = getattr(compiled, __moss_code_prompt__.__name__) + if hasattr(compiled, __moss_code_context__.__name__): + fn = getattr(compiled, __moss_code_context__.__name__) return fn(self) - return __moss_code_prompt__(self) + return __moss_code_context__(self) class MossRuntime(ABC): @@ -351,6 +343,13 @@ def container(self) -> Container: """ pass + @abstractmethod + def lint_exec_code(self, code: str) -> Optional[str]: + """ + lint execution code and return error info if error occurs + """ + pass + @abstractmethod def prompter(self) -> MossPrompter: """ @@ -373,6 +372,13 @@ def locals(self) -> Dict[str, Any]: """ pass + @abstractmethod + def moss_injections(self) -> Dict[str, Any]: + """ + get injections from moss + """ + pass + @abstractmethod def moss(self) -> Moss: """ diff --git a/ghostos/core/moss/examples/baseline.py b/ghostos/core/moss/examples/baseline.py index eeb0703c..85d16964 100644 --- a/ghostos/core/moss/examples/baseline.py +++ b/ghostos/core/moss/examples/baseline.py @@ -90,8 +90,8 @@ def __moss_attr_prompts__() -> "AttrPrompts": def __moss_prompt__(prompter: "MossPrompter") -> str: # 测试生命周期生效. - from ghostos.core.moss.lifecycle import __moss_code_prompt__ - return __moss_code_prompt__(prompter) + from ghostos.core.moss.lifecycle import __moss_code_context__ + return __moss_code_context__(prompter) def __moss_exec__(*args, **kwargs) -> "Execution": diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index e0c8066a..197abd1e 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -15,7 +15,7 @@ from ghostos.core.moss.utils import add_comment_mark from ghostos.core.moss.pycontext import PyContext from ghostos.prompter import Prompter -from ghostos.helpers import generate_module_spec +from ghostos.helpers import generate_module_spec, code_syntax_check from contextlib import contextmanager, redirect_stdout IMPORT_FUTURE = "from __future__ import annotations" @@ -190,6 +190,7 @@ def _compile_moss(self) -> Moss: for name, prop in pycontext.iter_props(self._compiled): # 直接用 property 作为值. setattr(moss, name, prop) + self._injected.add(name) # 反向注入 @@ -222,6 +223,11 @@ def container(self) -> Container: def prompter(self) -> MossPrompter: return self + def lint_exec_code(self, code: str) -> Optional[str]: + source_code = self._source_code + new_code = source_code + "\n\n" + code.strip() + return code_syntax_check(new_code) + def module(self) -> ModuleType: return self._compiled @@ -251,13 +257,12 @@ def pycontext_code( code = self._source_code return self._parse_pycontext_code(code, exclude_hide_code) - def moss_injected_prompters(self) -> Dict[str, Prompter]: + def moss_injections(self) -> Dict[str, Any]: moss = self.moss() prompters = {} for name in self._injected: injection = getattr(moss, name) - if isinstance(injection, Prompter): - prompters[name] = injection + prompters[name] = injection return prompters @staticmethod diff --git a/ghostos/core/moss/lifecycle.py b/ghostos/core/moss/lifecycle.py index 08bf0bbb..8ea24ef5 100644 --- a/ghostos/core/moss/lifecycle.py +++ b/ghostos/core/moss/lifecycle.py @@ -14,7 +14,7 @@ __all__ = [ '__moss_compile__', '__moss_attr_prompts__', - '__moss_code_prompt__', + '__moss_code_context__', '__moss_exec__', ] @@ -61,7 +61,7 @@ def __moss_attr_prompts__() -> "AttrPrompts": return [] -def __moss_code_prompt__(prompter: "MossPrompter") -> str: +def __moss_code_context__(prompter: "MossPrompter") -> str: """ 使用 MOSS Runtime 生成 prompt 的方法. 可选的魔术方法. 定义的话, runtime.moss_context_prompt 实际上会使用这个方法. diff --git a/ghostos/core/moss/test_suites.py b/ghostos/core/moss/test_suites.py index defe41af..f4cad5f6 100644 --- a/ghostos/core/moss/test_suites.py +++ b/ghostos/core/moss/test_suites.py @@ -24,7 +24,7 @@ def dump_prompt( compiler = self._container.force_fetch(MossCompiler) compiler.join_context(PyContext(module=modulename)) runtime = compiler.compile(test_modulename) - return runtime.prompter().dump_context_prompt() + return runtime.prompter().dump_code_context() def run_module_tests( self, *, diff --git a/ghostos/core/runtime/events.py b/ghostos/core/runtime/events.py index 86130bef..7dc85234 100644 --- a/ghostos/core/runtime/events.py +++ b/ghostos/core/runtime/events.py @@ -129,6 +129,8 @@ class EventTypes(str, Enum): ROTATE = "rotate" + ERROR = "error" + # --- callback events --- # FINISH_CALLBACK = "finish_callback" @@ -206,7 +208,7 @@ def send_event(self, e: Event, notify: bool) -> None: pass @abstractmethod - def pop_task_event(self, task_id: str, block: bool = False, timeout: float = 0.0) -> Optional[Event]: + def pop_task_event(self, task_id: str) -> Optional[Event]: """ pop a task event by task_id. the canceled event has higher priority to others. @@ -214,7 +216,7 @@ def pop_task_event(self, task_id: str, block: bool = False, timeout: float = 0.0 pass @abstractmethod - def pop_task_notification(self, block: bool = False, timeout: float = 0.0) -> Optional[str]: + def pop_task_notification(self) -> Optional[str]: """ pop a task notification from the main queue. :return: task id or None if not found. diff --git a/ghostos/framework/actions/moss_action.py b/ghostos/framework/actions/moss_action.py index ffcfd2da..5ad8a65c 100644 --- a/ghostos/framework/actions/moss_action.py +++ b/ghostos/framework/actions/moss_action.py @@ -122,7 +122,7 @@ def update_prompt(self, chat: Prompt) -> Prompt: chat.functional_tokens.append(function_token) # update code prompt as system message - code_prompt = self._moss_runtime.prompter().dump_context_prompt() + code_prompt = self._moss_runtime.prompter().dump_code_context() moss_instruction = self.template.format(code=code_prompt) moss_prompt = MessageType.DEFAULT.new_system( content=moss_instruction, diff --git a/ghostos/framework/eventbuses/memimpl.py b/ghostos/framework/eventbuses/memimpl.py index 3bbd4a97..81551d83 100644 --- a/ghostos/framework/eventbuses/memimpl.py +++ b/ghostos/framework/eventbuses/memimpl.py @@ -1,4 +1,5 @@ from typing import Optional, Dict, Type +from typing_extensions import Self from ghostos.core.runtime import Event from ghostos.core.runtime.events import EventBus @@ -14,6 +15,12 @@ def __init__(self): self._task_notification_queue = Queue() self._task_queues: Dict[str, Queue] = {} + def with_process_id(self, process_id: str) -> Self: + return self + + def clear_all(self): + pass + def send_event(self, e: Event, notify: bool) -> None: self._send_task_event(e) if notify: diff --git a/ghostos/framework/logger/named.py b/ghostos/framework/logger/named.py index c02e1b3a..c9789b17 100644 --- a/ghostos/framework/logger/named.py +++ b/ghostos/framework/logger/named.py @@ -1,8 +1,7 @@ from typing import Optional, Type from ghostos.container import Provider, Container -from ghostos.contracts.logger import LoggerItf, LoggerWrapper -from os.path import join +from ghostos.contracts.logger import LoggerItf import logging __all__ = ['NamedLoggerProvider'] diff --git a/ghostos/framework/messages/buffers.py b/ghostos/framework/messages/buffers.py index 662c4d4b..14632940 100644 --- a/ghostos/framework/messages/buffers.py +++ b/ghostos/framework/messages/buffers.py @@ -1,7 +1,7 @@ import time from typing import Iterable, Optional, List, Dict, Set -from ghostos.core.messages import Message, Caller, MessageType, Role, Payload, Attachment, Buffer, Flushed +from ghostos.core.messages import Message, Caller, MessageType, Role, Payload, Buffer, Flushed from ghostos.core.llms import FunctionalToken from ghostos.helpers import uuid diff --git a/ghostos/framework/streams/__init__.py b/ghostos/framework/streams/__init__.py deleted file mode 100644 index 0597d665..00000000 --- a/ghostos/framework/streams/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from ghostos.core.messages import Stream, Receiver, Received -from ghostos.framework.streams.array import new_connection -from ghostos.framework.streams.queuestream import QueueStream -from ghostos.framework.streams.empty import EmptyStream diff --git a/ghostos/framework/streams/array.py b/ghostos/framework/streams/array.py deleted file mode 100644 index e7b46b14..00000000 --- a/ghostos/framework/streams/array.py +++ /dev/null @@ -1,233 +0,0 @@ -from typing import Tuple, Optional, Dict, List, Iterable, Callable -from ghostos.core.messages import ( - Message, Stream, Receiver, Received, - MessageType, -) -import time - -__all__ = ['new_connection'] - -from ghostos.helpers import Timeleft - - -def new_connection(timeout: float, accept_chunks: bool, idle: float = 0.2) -> Tuple[Stream, Receiver]: - """ - create a stream and a receiver, which are run at different threads. - when receiver is stopped, stream stop immediately. - :param timeout: - :param accept_chunks: - :param idle: - :return: - """ - receiver = _ArrayReceiver(idle=idle) - stream = _ArrayStream(receiver, timeout=timeout, accept_chunks=accept_chunks) - return stream, receiver - - -class _ArrayReceiver(Receiver): - def __init__(self, idle: float): - self._idle = idle - self._stopped = False - self._received: Dict[str, _ArrayReceived] = {} - self._msg_ids: List[str] = [] - self._final: Optional[Message] = None - self._buffering: Optional[Message] = None - self._destroyed: bool = False - self._iterating: bool = False - - def add_item(self, item: Message) -> bool: - if self._stopped: - return False - if MessageType.is_protocol_message(item): - self.stop(item) - return True - - if self._buffering is None: - self._new_received(item) - return True - - patched = self._buffering.patch(item) - if patched: - self._append_item(item) - return True - else: - tail = self._buffering.as_tail() - self._append_item(tail) - self._new_received(item) - return True - - def _new_received(self, item: Message) -> None: - msg_id = item.msg_id - if not item.is_complete(): - self._buffering = item.as_head() - msg_id = self._buffering.msg_id - received = _ArrayReceived(item, idle=self.idle) - self._received[msg_id] = received - self._msg_ids.append(msg_id) - - def _append_item(self, item: Message) -> None: - msg_id = self._buffering.msg_id - received = self._received[msg_id] - received.add_item(item) - - def stopped(self) -> bool: - return self._stopped - - def idle(self) -> bool: - time.sleep(self._idle) - return not self._stopped - - def __exit__(self, exc_type, exc_val, exc_tb): - if self._stopped: - return - self.destroy() - - def __enter__(self) -> Iterable[Received]: - if self._iterating: - raise RuntimeError("Cannot iterating Retriever at the same time") - self._iterating = True - idx = 0 - while not self._stopped: - if idx < len(self._msg_ids): - msg_id = self._msg_ids[idx] - idx += 1 - yield self._received[msg_id] - else: - time.sleep(self._idle) - while idx < len(self._msg_ids): - yield self._received[self._msg_ids[idx]] - idx += 1 - if self._final and MessageType.ERROR.match(self._final): - yield _ArrayReceived(self._final, idle=self.idle) - self._iterating = False - - def stop(self, item: Optional[Message]) -> None: - if self._stopped: - return - self._stopped = True - if self._buffering: - tail = self._buffering.as_tail() - self._append_item(tail) - self._buffering = None - self._final = item - - def destroy(self): - if self._destroyed: - return - self._destroyed = True - self._stopped = True - for item in self._received.values(): - item.destroy() - del self._buffering - del self._final - del self._msg_ids - del self._received - - -class _ArrayStream(Stream): - - def __init__(self, receiver: _ArrayReceiver, timeout: float, accept_chunks: bool = True): - self._receiver = receiver - self._stopped = receiver.stopped() - self._accept_chunks = accept_chunks - self._timeleft = Timeleft(timeout) - - def deliver(self, pack: "Message") -> bool: - if self._stopped: - return False - if not self._timeleft.alive(): - e = TimeoutError(f"Timeout after {self._timeleft.passed()}") - self._receiver.stop(MessageType.ERROR.new(content=str(e))) - raise e - if pack.chunk and not self._accept_chunks: - return True - success = self._receiver.add_item(pack) - if success: - return True - if self._receiver.stopped(): - self.stop() - return False - - def __exit__(self, exc_type, exc_val, exc_tb): - item = None - if exc_val: - item = MessageType.ERROR.new(content=str(exc_val)) - if not self._stopped: - self._receiver.stop(item) - self.stop() - - def accept_chunks(self) -> bool: - return self._accept_chunks - - def stopped(self) -> bool: - if self._stopped: - return self._stopped - self._stopped = self._receiver.stopped() - return self._stopped - - def stop(self): - if self._stopped: - return - self._stopped = True - del self._receiver - - -class _ArrayReceived(Received): - - def __init__(self, head: Message, idle: Callable) -> None: - self._idle = idle - self._items: List[Dict] = [head.model_dump(exclude_defaults=True)] - self._stopped = False - self._tail: Optional[Message] = None - if head.is_complete() or MessageType.is_protocol_message(head): - self._tail = head.as_tail() - self._destroyed = False - - def add_item(self, item: Message) -> None: - if item.is_complete() or MessageType.is_protocol_message(item): - self._tail = item.as_tail() - else: - self._items.append(item.model_dump(exclude_defaults=True)) - - def head(self) -> Message: - return Message(**self._items[0]) - - def chunks(self) -> Iterable[Message]: - if self._tail: - for item in self._items: - yield Message(**item) - return - idx = 0 - while self._tail is None and not self._stopped: - if idx < len(self._items): - yield Message(**self._items[idx]) - idx += 1 - else: - self._stopped = self._idle() - while idx < len(self._items): - yield Message(**self._items[idx]) - idx += 1 - - def destroy(self) -> None: - if self._destroyed: - return - self._destroyed = True - self._stopped = True - del self._items - del self._tail - del self._idle - - def done(self) -> Message: - if self._tail: - return self._tail - failed = 0 - while not self._stopped: - if failed > 3: - break - if self._tail: - return self._tail - if not self._idle(): - failed += 1 - if self._tail: - return self._tail - raise RuntimeError(f"empty tail message") diff --git a/ghostos/framework/streams/empty.py b/ghostos/framework/streams/empty.py deleted file mode 100644 index 7ac179e6..00000000 --- a/ghostos/framework/streams/empty.py +++ /dev/null @@ -1,19 +0,0 @@ -from ghostos.core.messages import Stream, Message - - -class EmptyStream(Stream): - """ - for mock or test - """ - - def deliver(self, pack: "Message") -> bool: - return True - - def accept_chunks(self) -> bool: - return False - - def stopped(self) -> bool: - return False - - def __exit__(self, exc_type, exc_val, exc_tb): - pass diff --git a/ghostos/framework/streams/queuestream.py b/ghostos/framework/streams/queuestream.py deleted file mode 100644 index 18e5d2c3..00000000 --- a/ghostos/framework/streams/queuestream.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import Iterable, Optional - -from ghostos.core.messages import Stream, Message, MessageType -from queue import Queue - -__all__ = ["QueueStream"] - - -class QueueStream(Stream): - """ - expect to develop a thread-safe stream by python queue. - but I'm not familiar to python thread safe queue... - """ - - def __init__(self, queue: Queue, accept_chunks: bool = True): - self._queue = queue - self._accept_chunks = accept_chunks - self._stopped = False - - def deliver(self, pack: "Message") -> bool: - if self._stopped: - return False - if MessageType.is_protocol_message(pack): - return True - elif self._accept_chunks and not pack.is_complete(): - # 不发送间包, 只发送尾包. - return True - else: - self._queue.put(pack, block=True) - return True - - def accept_chunks(self) -> bool: - return not self._accept_chunks - - def stopped(self) -> bool: - return self._stopped - - def stop(self, error: Optional[Exception]) -> None: - if self._stopped: - return - self._stopped = True - if error: - final = MessageType.ERROR.new(content=str(error)) - else: - final = MessageType.final() - self._queue.put(final) - self._queue.task_done() - del self._queue - - def __exit__(self, exc_type, exc_val, exc_tb): - self.stop(exc_val) diff --git a/ghostos/helpers/__init__.py b/ghostos/helpers/__init__.py index f0ae137c..eefd79b9 100644 --- a/ghostos/helpers/__init__.py +++ b/ghostos/helpers/__init__.py @@ -23,7 +23,7 @@ from ghostos.helpers.coding import reflect_module_code, unwrap from ghostos.helpers.openai import get_openai_key -from ghostos.helpers.tree_sitter import tree_sitter_parse +from ghostos.helpers.tree_sitter import tree_sitter_parse, code_syntax_check if TYPE_CHECKING: from typing import Callable diff --git a/ghostos/helpers/tree_sitter.py b/ghostos/helpers/tree_sitter.py index 910ebe9a..0147b263 100644 --- a/ghostos/helpers/tree_sitter.py +++ b/ghostos/helpers/tree_sitter.py @@ -1,4 +1,4 @@ -from typing import Optional, Iterable, List, Set, Dict, Type, ClassVar +from typing import Optional, Iterable, List, Set, Dict, Type, ClassVar, Generator from abc import ABC, abstractmethod from tree_sitter_languages import get_parser from tree_sitter import ( @@ -8,11 +8,61 @@ _PythonParser = get_parser('python') -__all__ = ['tree_sitter_parse'] +__all__ = ['tree_sitter_parse', 'code_syntax_check'] def tree_sitter_parse(code: str) -> Tree: - return _PythonParser.parse(code) + return _PythonParser.parse(code.encode()) + + +def code_syntax_check(code: str) -> Optional[str]: + try: + tree = tree_sitter_parse(code) + except Exception as e: + return f"parse code failed: {e}" + + errors = [] + travel_node_error(code, tree.root_node, errors) + if errors: + return "- " + "\n- ".join(errors) + return None + + +def traverse_tree(tree: Tree) -> Generator[TreeSitterNode, None, None]: + cursor = tree.walk() + + visited_children = False + while True: + if not visited_children: + yield cursor.node + if not cursor.goto_first_child(): + visited_children = True + elif cursor.goto_next_sibling(): + visited_children = False + elif not cursor.goto_parent(): + break + + +def travel_node_error(code: str, node: TreeSitterNode, errors: List[str]) -> None: + error = get_node_error(code, node) + if error is not None: + errors.append(error) + return + for child in node.children: + travel_node_error(code, child, errors) + + +def get_node_error(code: str, node: TreeSitterNode) -> Optional[str]: + """ + get all the errors when traversing a node + """ + if node.is_error: + start_point_row, col = node.start_point + line_number = start_point_row + 1 + line_content = code.splitlines()[line_number - 1] + # 这里假设错误分析就是节点的类型和文本内容 + return f"Syntax Error at line {line_number}: `{line_content}`" + return None def get_error_nodes(node: TreeSitterNode) -> Iterable[TreeSitterNode]: diff --git a/ghostos/mocks/__init__.py b/ghostos/mocks/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/mocks/libraries/__init__.py b/ghostos/mocks/libraries/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/mocks/libraries/auto_text_memory.py b/ghostos/mocks/libraries/auto_text_memory.py deleted file mode 100644 index 305f4598..00000000 --- a/ghostos/mocks/libraries/auto_text_memory.py +++ /dev/null @@ -1,48 +0,0 @@ -from typing import Dict - -from mem0 import Memory -from mem0.configs.base import MemoryConfig, LlmConfig, VectorStoreConfig -from ghostos.framework.libraries.auto_memory import TextMemory, ProxyConfig, VectorDBConfig, DBConfig - - -class Mem0TextMemory(TextMemory): - - def __init__(self, proxy_config: ProxyConfig = None, llm_config: Dict = None, vector_config: VectorDBConfig = None, db_config: DBConfig = None): - super().__init__(proxy_config) - - conf = MemoryConfig() - if llm_config: - conf.llm = LlmConfig(provider=llm_config["llm_provider"]) - if vector_config: - conf.vector_store = VectorStoreConfig(provider=vector_config.provider) - if db_config: - conf.history_db_path = db_config.path - self.memory = Memory(conf) - - def add(self, data, agent_id=None, run_id=None, metadata=None, filters=None, prompt=None): - # Implement the add method - self.memory.add(data, agent_id=agent_id, run_id=run_id, metadata=metadata, filters=filters, prompt=prompt) - - def search(self, query, agent_id=None, run_id=None, limit=100, filters=None): - return self.memory.search(query, agent_id=agent_id, run_id=run_id, limit=limit, filters=filters) - - def get(self, memory_id): - return self.memory.get(memory_id) - - def get_all(self, agent_id=None, run_id=None, limit=100): - return self.memory.get_all(agent_id=agent_id, run_id=run_id, limit=limit) - - def update(self, memory_id, data): - return self.memory.update(memory_id, data) - - def delete(self, memory_id): - return self.memory.delete(memory_id) - - def delete_all(self, agent_id=None, run_id=None): - return self.memory.delete_all(agent_id=agent_id, run_id=run_id) - - def history(self, memory_id): - return self.memory.history(memory_id) - - def clear(self): - return self.memory.reset() diff --git a/ghostos/prompter.py b/ghostos/prompter.py index ff28cbd6..e7d06048 100644 --- a/ghostos/prompter.py +++ b/ghostos/prompter.py @@ -17,7 +17,7 @@ 'get_defined_prompt', 'set_prompter', 'set_class_prompter', 'Prompter', - 'GroupPrmt', 'ParagraphPrmt', + 'TextPrmt', 'PromptAbleObj', 'PromptAbleClass', ] @@ -76,20 +76,19 @@ class Prompter(BaseModel, EntityClass, ABC): is strong-typed model for runtime alternative properties of a ghost. """ - prompt_priority: float = Field(0.0, description='Priority of the prompter.') + priority: int = Field(default=0, description='Priority of this prompter.') - __children__: List[Prompter] + __children__: Optional[List[Prompter]] = None """ children is fractal sub context nodes""" - __self_prompt__: Optional[str] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.__children__ = [] - self.__self_prompt__ = None + __self_prompt__: Optional[str] = None def with_children(self, *children: Prompter) -> Prompter: - self.__children__.extend(children) + if self.__children__ is None: + self.__children__ = [] + children = list(children) + if len(children) > 0: + self.__children__.extend(children) return self @abstractmethod @@ -103,8 +102,14 @@ def self_prompt(self, container: Container) -> str: @abstractmethod def get_title(self) -> str: + """ + the title of the prompt + """ pass + def get_priority(self) -> int: + return 0 + def get_prompt(self, container: Container, depth: int = 0) -> str: """ get prompt with container which provides libraries to generate prompt @@ -112,26 +117,37 @@ def get_prompt(self, container: Container, depth: int = 0) -> str: :param depth: :return: """ + if self.__self_prompt__ is not None: + return self.__self_prompt__ + title = self.get_title() if title: title = '#' * (depth + 1) + ' ' + title - if self.__self_prompt__ is not None: - self_prompt = self.__self_prompt__ - else: - self_prompt = self.self_prompt(container) - self.__self_prompt__ = self_prompt - - prompts = [title, self_prompt] - for child in self.__children__: - child_prompt = child.get_prompt(container, depth=depth + 1) - prompts.append(child_prompt) + self_prompt = self.self_prompt(container) + prompts = [] + if self_prompt: + prompts.append(self_prompt) + + if self.__children__ is not None: + for child in self.__children__: + child_prompt = child.get_prompt(container, depth=depth + 1) + if child_prompt: + prompts.append(child_prompt) + # empty prompts + if not prompts: + return "" + + # generate output prompt + prompts.insert(0, title) output = "" for paragraph in prompts: paragraph = paragraph.strip() if paragraph: output += "\n\n" + paragraph - return output.strip() + self.__self_prompt__ = output.strip() + self.__children__ = None + return self.__self_prompt__ def __to_entity_meta__(self) -> EntityMeta: type_ = generate_import_path(self.__class__) @@ -167,17 +183,7 @@ def flatten(self, index: str = "") -> Dict[str, Self]: return result -class GroupPrmt(Prompter): - title: str = "" - - def self_prompt(self, container: Container) -> str: - return "" - - def get_title(self) -> str: - return self.title - - -class ParagraphPrmt(Prompter): +class TextPrmt(Prompter): title: str = "" content: str = "" diff --git a/ghostos/prototypes/ghostfunc/driver.py b/ghostos/prototypes/ghostfunc/driver.py index a0ee24fd..1fc788cb 100644 --- a/ghostos/prototypes/ghostfunc/driver.py +++ b/ghostos/prototypes/ghostfunc/driver.py @@ -165,7 +165,7 @@ def _init_prompt(self, context_code: str) -> str: def _init_thread(self) -> GoThreadInfo: pycontext = self._init_pycontext() moss_runtime = self._moss_runtime(pycontext) - context_code = moss_runtime.prompter().dump_context_prompt() + context_code = moss_runtime.prompter().dump_code_context() instruction = self._init_prompt(context_code) system = Role.SYSTEM.new(content=instruction) e = EventTypes.ROTATE.new(task_id="", messages=[system], from_task_id="") diff --git a/tests/core/ghosts/test_thoughts.py b/tests/core/ghosts/test_thoughts.py deleted file mode 100644 index cb938118..00000000 --- a/tests/core/ghosts/test_thoughts.py +++ /dev/null @@ -1,18 +0,0 @@ -from ghostos.core.moss.prompts import get_prompt -from ghostos.core.ghosts.thoughts import Thought, ModelThought - - -def test_get_prompt_from_thought(): - prompt = get_prompt(Thought) - assert prompt == Thought.__class_prompt__() - - -def test_get_prompt_from_thought_with_no_thought(): - prompt = get_prompt(ModelThought) - assert prompt == ModelThought.__class_prompt__() - - class TestThought(ModelThought): - foo: int = 123 - - prompt = get_prompt(TestThought) - assert "class TestThought" in prompt diff --git a/tests/core/moss/examples/test_baseline.py b/tests/core/moss/examples/test_baseline.py index aa1184f1..31b9626d 100644 --- a/tests/core/moss/examples/test_baseline.py +++ b/tests/core/moss/examples/test_baseline.py @@ -45,9 +45,9 @@ def test_baseline_exec(): prompter = runtime.prompter() assert prompter is not None - prompt = prompter.dump_context_prompt() + prompt = prompter.dump_code_context() - prompters = prompter.moss_injected_prompters() + prompters = prompter.moss_injected() assert "tester" in prompters # plus 方法存在. diff --git a/tests/helpers/test_tree_sitter.py b/tests/helpers/test_tree_sitter.py index 6b60394c..7a5321f8 100644 --- a/tests/helpers/test_tree_sitter.py +++ b/tests/helpers/test_tree_sitter.py @@ -1,3 +1,39 @@ -from ghostos.helpers.tree_sitter import tree_sitter_parse +from ghostos.helpers.tree_sitter import code_syntax_check +def test_lint_code_success(): + code = """ +import inspect + +def main(): + print("hello world") + +source = inspect.getsource(main) +print(source) +""" + error = code_syntax_check(code.strip()) + assert error is None + + +def test_lint_code_without_quote(): + code = """ +def main(): + print("hello world) + +source = inspect.getsource(main) +print(source) +""" + error = code_syntax_check(code.strip()) + assert error is not None + + +def test_lint_code_many_errors(): + code = """ +def main(): + print("hello world) + +source = inspect.getsource(main +print(source) +""" + error = code_syntax_check(code.strip()) + assert error and "hello world)" in error diff --git a/tests/python/test_class.py b/tests/python/test_class.py index 3ddc55b7..422f605f 100644 --- a/tests/python/test_class.py +++ b/tests/python/test_class.py @@ -1,4 +1,4 @@ -from typing import Dict, SupportsAbs as _SupportsAbs +from typing import Dict, SupportsAbs as _SupportsAbs, List import inspect @@ -237,5 +237,42 @@ class _Baz(_Bar): def test_attr_of_class(): class Foo: foo = 1 + bar: int + baz: int = 3 assert Foo.foo == 1 + assert Foo().foo == 1 + assert not hasattr(Foo, "bar") + assert not hasattr(Foo(), "bar") + from typing import get_type_hints + props = get_type_hints(Foo) + assert "bar" in props + assert "baz" in props + assert hasattr(Foo, "baz") + assert hasattr(Foo(), "baz") + + +def test_class_var_list(): + class Foo: + foo: List[str] = [] + + def __init__(self, val: List[str]): + self.foo = val + + f = Foo(["a", "b"]) + assert f.foo == ["a", "b"] + assert Foo.foo == [] + f2 = Foo([]) + assert f.foo == ["a", "b"] + assert f2.foo == [] + + class Bar: + bar: List[str] = [] + + def __init__(self, val: List[str]): + self.bar.extend(val) + + b = Bar(["a", "b"]) + assert b.bar == ["a", "b"] + # be updated + assert Bar.bar == ["a", "b"] diff --git a/tests/python/test_func.py b/tests/python/test_func.py index bb74ef16..4b47fb4e 100644 --- a/tests/python/test_func.py +++ b/tests/python/test_func.py @@ -9,3 +9,11 @@ def foo(bar: int, baz: str, *args, **kwargs) -> bool: assert foo.__annotations__['bar'] is int assert foo.__annotations__['return'] is bool + + +def test_func_iterable_args(): + def foo(*args: int) -> int: + return len(list(args)) + + value = foo(1, 2, 3, *[4, 5, 6], 7, 8, *[9]) + assert value == 9 diff --git a/tests/test_prompter.py b/tests/test_prompter.py index 6effff15..40d08fa9 100644 --- a/tests/test_prompter.py +++ b/tests/test_prompter.py @@ -1,15 +1,15 @@ -from ghostos.prompter import Prompter, GroupPrmt, ParagraphPrmt +from ghostos.prompter import TextPrmt from ghostos.container import Container def test_group_prompters(): - prompter = GroupPrmt( + prompter = TextPrmt( title="1" ).with_children( - GroupPrmt(title="1.1"), - GroupPrmt(title="1.2").with_children( - GroupPrmt(title="1.2.1"), - ParagraphPrmt(title="1.2.2", content="hello world"), + TextPrmt(title="1.1"), + TextPrmt(title="1.2").with_children( + TextPrmt(title="1.2.1"), + TextPrmt(title="1.2.2", content="hello world"), ) ) @@ -17,3 +17,6 @@ def test_group_prompters(): p = prompter.get_prompt(container=c) assert "# 1\n" in p assert "\n### 1.2.2\n" in p + # test buffer is ok + assert p == prompter.get_prompt(c) + assert prompter.__children__ is None From 2aa5d0ccdb7790832d12bfe9adaf0442b38dec64 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 11 Nov 2024 23:00:30 +0800 Subject: [PATCH 066/148] dev: update messages catch up to changes --- ghostos/core/messages/__init__.py | 3 +- ghostos/core/messages/buffers.py | 2 +- ghostos/core/messages/buffers2.py | 66 ------------- ghostos/core/messages/message.py | 5 +- ghostos/core/messages/message_classes.py | 4 +- ghostos/core/messages/openai.py | 86 +++++++++++----- ghostos/core/messages/stream.py | 121 ----------------------- ghostos/core/messages/transport.py | 2 +- ghostos/framework/messages/buffers.py | 2 +- ghostos/framework/messengers/defaults.py | 2 +- tests/core/messages/test_messages.py | 2 +- tests/framework/messages/test_buffer.py | 38 +++---- 12 files changed, 90 insertions(+), 243 deletions(-) delete mode 100644 ghostos/core/messages/buffers2.py delete mode 100644 ghostos/core/messages/stream.py diff --git a/ghostos/core/messages/__init__.py b/ghostos/core/messages/__init__.py index c4c39e91..8271879b 100644 --- a/ghostos/core/messages/__init__.py +++ b/ghostos/core/messages/__init__.py @@ -1,6 +1,6 @@ from ghostos.core.messages.message import ( Message, Role, MessageType, - Caller, + Caller, CallerOutput, MessageClass, MessageKind, MessageKindParser, ) from ghostos.core.messages.payload import Payload @@ -10,5 +10,4 @@ ) from ghostos.core.messages.buffers import Buffer, Flushed from ghostos.core.messages.helpers import copy_messages -# from ghostos.core.messages.stream import Stream, Receiver, Received from ghostos.core.messages.transport import Stream, Receiver, new_arr_connection diff --git a/ghostos/core/messages/buffers.py b/ghostos/core/messages/buffers.py index 8337a672..1e47fc0b 100644 --- a/ghostos/core/messages/buffers.py +++ b/ghostos/core/messages/buffers.py @@ -28,7 +28,7 @@ class Buffer(ABC): """ @abstractmethod - def buff(self, pack: "Message") -> List[Message]: + def add(self, pack: "Message") -> Iterable[Message]: """ try to buff a message pack :return: the sending messages after the buffing. may be: diff --git a/ghostos/core/messages/buffers2.py b/ghostos/core/messages/buffers2.py deleted file mode 100644 index e63b197a..00000000 --- a/ghostos/core/messages/buffers2.py +++ /dev/null @@ -1,66 +0,0 @@ -from typing import List, Iterable, Optional, Tuple -from typing_extensions import Self -from abc import ABC, abstractmethod -from ghostos.core.messages.message import Message - - -class Buffer(ABC): - - @abstractmethod - def new(self) -> Self: - pass - - @abstractmethod - def match(self, message: Message) -> bool: - pass - - @abstractmethod - def buffer(self, message: Message) -> Iterable[Message]: - pass - - @abstractmethod - def flush(self) -> Tuple[List[Message], List[Message]]: - pass - - -class GroupBuffer(Buffer): - def __init__(self, default: Buffer, buffers: Iterable[Buffer]): - self._default = default - self._buffers = list(buffers) - self._current: Optional[Buffer] = None - self._completes: List[Message] = [] - - def new(self) -> Self: - return GroupBuffer(buffers=self._buffers) - - def match(self, message: Message) -> bool: - return True - - def _find_buffer(self, message: Message) -> Buffer: - for buffer in self._buffers: - if buffer.match(message): - return buffer.new() - return self._default.new() - - def buffer(self, message: Message) -> Iterable[Message]: - if self._current is None: - self._current = self._find_buffer(message) - yield from self._current.buffer(message) - elif self._current.match(message): - yield from self._current.buffer(message) - else: - unsent, completes = self._current.flush() - self._completes.extend(completes) - yield from unsent - self._current = self._find_buffer(message) - yield from self._current.buffer(message) - - def flush(self) -> Tuple[List[Message], List[Message]]: - unsent = [] - if self._current is not None: - unsent, completes = self._current.flush() - self._completes.extend(completes) - self._current = None - completes = self._completes - self._completes = [] - return unsent, completes diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 527d4010..44d0a19e 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -12,7 +12,7 @@ "Message", "Role", "MessageType", "MessageClass", "MessageKind", "MessageKindParser", - "Caller", + "Caller", "CallerOutput", ] SeqType = Literal["head", "chunk", "complete"] @@ -325,7 +325,7 @@ def patch(self, chunk: "Message") -> Optional["Message"]: return None # if not a chunk, just return the tail message. # tail message may be changed by outside method such as moderation. - if not chunk.chunk: + if chunk.is_complete(): return chunk.model_copy() # otherwise, update current one. self.update(chunk) @@ -351,7 +351,6 @@ def get_copy(self) -> Self: def as_tail(self, copy: bool = True) -> Self: item = self.as_head(copy) - item.chunk = False item.seq = "complete" return item diff --git a/ghostos/core/messages/message_classes.py b/ghostos/core/messages/message_classes.py index 0d5abd1a..6e875a2c 100644 --- a/ghostos/core/messages/message_classes.py +++ b/ghostos/core/messages/message_classes.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, Field -class DefaultMC(MessageClass): +class DefaultMsgCls(MessageClass): message_type = MessageType.DEFAULT message: Message @@ -25,7 +25,7 @@ def to_openai_param(self) -> Dict: raise NotImplementedError("todo") -class VariableMC(MessageClass, BaseModel): +class VariableMsgCls(MessageClass, BaseModel): """ 变量类型消息. """ diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index d614d3dd..80b3ccf1 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -1,5 +1,5 @@ import time -from typing import Iterable, Optional, Type, ClassVar +from typing import Iterable, Optional, Type, ClassVar, List from abc import ABC, abstractmethod from openai.types.chat.chat_completion_chunk import ChoiceDelta, ChatCompletionChunk from openai.types.completion_usage import CompletionUsage @@ -12,8 +12,9 @@ from openai.types.chat.chat_completion_function_message_param import ChatCompletionFunctionMessageParam from openai.types.chat.chat_completion_tool_message_param import ChatCompletionToolMessageParam -from ghostos.core.messages import Message, MessageType, Role, Caller, Payload +from ghostos.core.messages import Message, MessageType, Role, Caller, CallerOutput, Payload, MessageClass from ghostos.container import Provider, Container +from ghostos.helpers import import_class_from_path __all__ = [ "OpenAIMessageParser", "DefaultOpenAIMessageParser", "DefaultOpenAIParserProvider", @@ -88,11 +89,19 @@ class DefaultOpenAIMessageParser(OpenAIMessageParser): default implementation of OpenAIMessageParser """ + def __init__(self, message_classes: List[Type[MessageClass]]): + self.message_classes = message_classes + def parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: - if message.type == MessageType.CHAT_COMPLETION: - return self._parse_assistant_chat_completion(message) - else: - return self._parse_message(message) + if not message.is_complete(): + return [] + + # message class first. + for message_class in self.message_classes: + wrapped = message_class.from_message(message) + if wrapped is not None: + return [wrapped.to_openai_param()] + return self._parse_message(message) def _parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: if message.role == Role.ASSISTANT: @@ -105,11 +114,11 @@ def _parse_message(self, message: Message) -> Iterable[ChatCompletionMessagePara return [ ChatCompletionUserMessageParam(content=message.get_content(), name=message.name, role="user") ] - elif message.role == Role.FUNCTION: + elif message.type == MessageType.FUNCTION_OUTPUT: return [ ChatCompletionFunctionMessageParam(content=message.get_content(), name=message.name, role="function") ] - elif message.role == Role.TOOL: + elif message.role == MessageType.FUNCTION_CALL: return [ ChatCompletionToolMessageParam( tool_call_id=message.ref_id, @@ -158,12 +167,13 @@ def _parse_assistant_chat_completion(message: Message) -> Iterable[ChatCompletio return [ChatCompletionAssistantMessageParam( content=content, role="assistant", + name=message.name if message.name else "", function_call=function_call, tool_calls=tool_calls, )] def from_chat_completion(self, message: ChatCompletionMessage) -> Message: - pack = Message.new_tail(type_=MessageType.CHAT_COMPLETION, role=message.role, content=message.content) + pack = Message.new_tail(type_=MessageType.DEFAULT, role=message.role, content=message.content) if message.function_call: caller = Caller( name=message.function_call.name, @@ -184,29 +194,43 @@ def from_chat_completion(self, message: ChatCompletionMessage) -> Message: def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) -> Iterable[Message]: # 创建首包, 并发送. - first = True + buffer = None for item in messages: if len(item.choices) == 0: # 接受到了 openai 协议尾包. 但在这个协议里不作为尾包发送. usage = CompletionUsagePayload.from_chunk(item) - pack = Message.new_chunk(role=Role.ASSISTANT.value, typ_=MessageType.CHAT_COMPLETION) - usage.set(pack) - yield pack - else: + chunk = Message.new_chunk(role=Role.ASSISTANT.value, typ_=MessageType.DEFAULT) + if usage: + usage.set(chunk) + elif len(item.choices) > 0: choice = item.choices[0] delta = choice.delta - pack = self._new_pack_from_delta(delta, first) - yield pack - first = False + chunk = self._new_pack_from_delta(delta) + else: + continue + + if buffer is None: + buffer = chunk.as_head(copy=True) + yield buffer.get_copy() + else: + patched = buffer.patch(chunk) + if not patched: + yield buffer.as_tail() + buffer = chunk.as_head(copy=True) + else: + buffer = patched + yield chunk + + if buffer: + yield buffer.as_tail(copy=False) @staticmethod - def _new_pack_from_delta(delta: ChoiceDelta, first: bool) -> Message: - if first: - pack = Message.new_head(role=Role.ASSISTANT.value, content=delta.content, - typ_=MessageType.CHAT_COMPLETION) - else: - pack = Message.new_chunk(role=Role.ASSISTANT.value, content=delta.content, - typ_=MessageType.CHAT_COMPLETION) + def _new_pack_from_delta(delta: ChoiceDelta) -> Message: + pack = Message.new_chunk( + role=Role.ASSISTANT.value, + content=delta.content, + typ_=MessageType.DEFAULT, + ) # function call if delta.function_call: function_call = Caller(**delta.function_call.model_dump()) @@ -225,8 +249,20 @@ class DefaultOpenAIParserProvider(Provider[OpenAIMessageParser]): 默认的 provider. """ + def __init__(self, message_classes: Optional[List[str]] = None): + if message_classes is None: + classes = [ + CallerOutput, + ] + else: + classes = [] + for import_path in message_classes: + cls = import_class_from_path(import_path, MessageClass) + classes.append(cls) + self._message_classes = classes + def singleton(self) -> bool: return True def factory(self, con: Container) -> Optional[OpenAIMessageParser]: - return DefaultOpenAIMessageParser() + return DefaultOpenAIMessageParser(self._message_classes) diff --git a/ghostos/core/messages/stream.py b/ghostos/core/messages/stream.py deleted file mode 100644 index a59c2dca..00000000 --- a/ghostos/core/messages/stream.py +++ /dev/null @@ -1,121 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Iterable, Tuple, Optional, Callable -from ghostos.core.messages.message import Message - -__all__ = [ - "Stream", - "Receiver", "Received", -] - - -# todo: remove - -class Stream(ABC): - """ - streaming output messages. - with stream: - stream.send(message_item) - ... - # when the stream exits, it will send the protocol final item. - - 1. when stream context is exited, the stream send final message to the receiver. - 2. when a protocol item send to stream, it will stop. - """ - - @abstractmethod - def deliver(self, pack: "Message") -> bool: - """ - deliver a message. - a message shall be a head, chunk or a tail. - - head: first chunk message with msg id - - chunk: part of the complete message, if msg id exists, should be the same as head. - - tail: complete message that join all the chunks, has msg_id - - when msg type is Protocol type, means the stream shall stop. - - stream can deliver multiple batch of message chunks. like: - [tail], [head, chunk, chunk, tail], [head, tail], [tail, tail, tail] - - tail only: one complete message at a time. - - head => chunks => tail: normal sequences of chunks. - - head => tail: no chunks needed - - tail => tail: the new tail shall replace the current tail. - - if an error message or a final message is delivering, the stream usually stop immediately. - :return: if the message was delivered. if the stream is stopped, return False. - """ - pass - - @abstractmethod - def accept_chunks(self) -> bool: - """ - weather the stream is sending chunks. - if False, the stream will ignore all the chunks - """ - pass - - def send(self, messages: Iterable[Message]) -> bool: - """ - syntax sugar for delivering - """ - for item in messages: - ok = self.deliver(item) - if not ok: - # break sending - return False - return True - - @abstractmethod - def stopped(self) -> bool: - """ - if the stream is stopped. - """ - pass - - def __enter__(self): - return self - - @abstractmethod - def __exit__(self, exc_type, exc_val, exc_tb): - pass - - -class Received(ABC): - """ - api for a batch of message chunks - """ - - @abstractmethod - def head(self) -> Message: - """ - :return: head chunk of the message chunks. - may be the head chunk is the tail. - """ - pass - - @abstractmethod - def chunks(self) -> Iterable[Message]: - """ - iterate over the message chunks. - from head (if head is not the tail) to the last chunk - """ - pass - - @abstractmethod - def done(self) -> Message: - """ - retail the complete message of the chunks. - """ - pass - - -class Receiver(ABC): - @abstractmethod - def __enter__(self) -> Iterable[Received]: - pass - - @abstractmethod - def __exit__(self, exc_type, exc_val, exc_tb): - pass - - -new_connection = Callable[[], Tuple[Stream, Receiver]] diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index 42a9ecf4..5c78e01d 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -1,4 +1,4 @@ -from typing import Iterable, Optional, Callable, List, Tuple +from typing import Iterable, Optional, Callable, Tuple from typing_extensions import Protocol from collections import deque diff --git a/ghostos/framework/messages/buffers.py b/ghostos/framework/messages/buffers.py index 14632940..e0272075 100644 --- a/ghostos/framework/messages/buffers.py +++ b/ghostos/framework/messages/buffers.py @@ -87,7 +87,7 @@ def match(self, message: Message) -> bool: # 默认可以匹配任何一种 message 消息体. return True - def buff(self, pack: "Message") -> List[Message]: + def add(self, pack: "Message") -> List[Message]: # 获取buff 后需要发送的包. items = self._buff(pack) result = [] diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index 09e448ef..229e8f54 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -176,7 +176,7 @@ def deliver(self, pack: "Message") -> bool: # return self._reduce_streaming_items() def _buff_then_deliver(self, pack: "Message") -> bool: - delivery = self._buffer.buff(pack) + delivery = self._buffer.add(pack) return self._deliver_to_upstream(delivery) def _deliver_to_upstream(self, delivery: Iterable[Message]) -> bool: diff --git a/tests/core/messages/test_messages.py b/tests/core/messages/test_messages.py index 403162ef..43f46fa8 100644 --- a/tests/core/messages/test_messages.py +++ b/tests/core/messages/test_messages.py @@ -34,7 +34,7 @@ def test_message_with_full_type(): msg = msg.patch(msg.new_chunk(content=c)) last = msg.model_copy(update=dict(content="good")) - last.chunk = False + last.seq = "complete" buffed = msg.patch(last) assert buffed is not None and buffed.content == "good" diff --git a/tests/framework/messages/test_buffer.py b/tests/framework/messages/test_buffer.py index d4a02a6d..234ff449 100644 --- a/tests/framework/messages/test_buffer.py +++ b/tests/framework/messages/test_buffer.py @@ -13,19 +13,19 @@ def test_default_buffer_baseline(): content2 = "world" msg1 = Message.new_head() - sent = buffer.buff(msg1) + sent = buffer.add(msg1) i = 0 for item in sent: - buffer2.buff(item) + buffer2.add(item) i += 1 # 空首包也发送, 对齐 moonshot 协议. assert i == 1 for c in content1: pack = Message.new_chunk(content=c) - sent = buffer.buff(pack) + sent = buffer.add(pack) for item in sent: - buffer2.buff(item) + buffer2.add(item) buffed = buffer.flush() assert len(buffed.messages) == 1 @@ -33,11 +33,11 @@ def test_default_buffer_baseline(): assert buffed.messages[0].memory is None new_head = Message.new_head() - buffer2.buff(new_head) + buffer2.add(new_head) for c in content2: pack = Message.new_chunk(content=c) - buffer2.buff(pack) + buffer2.add(pack) buffed = buffer2.flush() print(buffed) @@ -59,7 +59,7 @@ def test_functional_token_baseline(): for c in content: msg = Message.new_chunk(content=c) - buffer.buff(msg) + buffer.add(msg) flushed = buffer.flush() assert len(flushed.messages) == 1 @@ -77,7 +77,7 @@ def test_buffer_sent(): for c in content: msg = Message.new_chunk(content=c) - sent = buffer.buff(msg) + sent = buffer.add(msg) for i in sent: assert not i.is_empty() if i.msg_id: @@ -94,7 +94,7 @@ def test_buffer_sent_one_tail(): tails = 0 for c in content: msg = Message.new_chunk(content=c) - sent = buffer.buff(msg) + sent = buffer.add(msg) for i in sent: if not i.chunk: tails += 1 @@ -124,7 +124,7 @@ def test_buffer_with_moss_token(): content = "好的,我会帮你播放这首歌。\n\n>moss:\ndef main(os: MOSS) -> Operator:\n # Search for the song \"七里香\" by 周杰伦\n song_list = os.player.search(\"\", \"周杰伦\", \"七里香\")\n \n # Check if the song is found\n if \"七里香\" in song_list:\n # Play the song\n playing = os.player.play(\"七里香\")\n \n # Check if the song is playing\n if playing:\n return\n os.mindflow.finish(\"正在播放周杰伦的《七里香》。\")\n else:\n return os.mindflow.fail(\"无法播放周杰伦的《七里香》。\")\n else:\n return os.mindflow.fail(\"未找到周杰伦的《七里香》。\")" for c in content: p = Message.new_chunk(content=c) - buffer.buff(p) + buffer.add(p) buffed = buffer.flush() assert len(buffed.messages) == 1 assert len(buffed.callers) == 1 @@ -144,7 +144,7 @@ def test_buffer_with_sep_content(): content = "".join(contents) for c in contents: msg = Message.new_chunk(content=c) - buffer.buff(msg) + buffer.add(msg) flushed = buffer.flush() assert len(flushed.messages) == 1 assert len(list(flushed.callers)) > 0 @@ -165,13 +165,13 @@ def test_buffer_with_sep_content(): def test_buffer_with_tail_item(): buffer = DefaultBuffer() header = Message.new_head(content="") - buffer.buff(header) + buffer.add(header) content = "hello" for c in content: msg = Message.new_chunk(content=c) - buffer.buff(msg) + buffer.add(msg) tail = Message.new_tail(content="hello world", msg_id=header.msg_id) - buffer.buff(tail) + buffer.add(tail) flushed = buffer.flush() assert len(flushed.messages) == 1 assert flushed.messages[0].content == "hello world" @@ -181,12 +181,12 @@ def test_buffer_header_with_payload(): buffer = DefaultBuffer() header = Message.new_head(content="") header.payloads["foo"] = {} - buffer.buff(header) + buffer.add(header) content = "hello" - buffer.buff(Message.new_chunk(content="")) + buffer.add(Message.new_chunk(content="")) for c in content: msg = Message.new_chunk(content=c) - buffer.buff(msg) + buffer.add(msg) flushed = buffer.flush() assert len(flushed.messages) == 1 assert flushed.messages[0].content == "hello" @@ -206,7 +206,7 @@ def test_buffer_with_xml_functional_token(): content = "".join(contents) for c in contents: msg = Message.new_chunk(content=c) - buffer.buff(msg) + buffer.add(msg) flushed = buffer.flush() assert len(flushed.messages) == 1 assert len(list(flushed.callers)) > 0 @@ -233,7 +233,7 @@ def test_buffer_with_visible_functional_token(): content = "".join(contents) for c in contents: msg = Message.new_chunk(content=c) - buffer.buff(msg) + buffer.add(msg) flushed = buffer.flush() assert len(flushed.messages) == 1 assert len(list(flushed.callers)) > 0 From 0e1809a6327a24691aea14afcf76b9bf9b70d278 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 11 Nov 2024 23:49:10 +0800 Subject: [PATCH 067/148] dev: implements llm changes --- ghostos/__init__.py | 0 ghostos/bootstrap.py | 7 +- ghostos/container.py | 4 +- ghostos/core/agents/moss_agent.py | 14 +- ghostos/core/llms/__init__.py | 19 ++- ghostos/core/llms/configs.py | 13 +- ghostos/core/llms/llm.py | 7 +- ghostos/core/llms/prompt.py | 17 ++- ghostos/core/llms/prompt_pipes.py | 29 ++++ ghostos/framework/chatpreparers/__init__.py | 1 - .../chatpreparers/assistant_preparer.py | 41 ------ ghostos/framework/eventbuses/memimpl.py | 4 +- ghostos/framework/llms/__init__.py | 5 +- ghostos/framework/llms/llms.py | 2 +- ghostos/framework/llms/openai_driver.py | 126 ++++++++++-------- .../prompt_storage_impl.py} | 15 --- ghostos/framework/llms/providers.py | 34 ++++- ghostos/framework/prompts/__init__.py | 0 .../{test_llms.py => test_llms_config.py} | 0 tests/framework/llms/test_prompt_storage.py | 16 +++ 20 files changed, 197 insertions(+), 157 deletions(-) delete mode 100644 ghostos/__init__.py create mode 100644 ghostos/core/llms/prompt_pipes.py delete mode 100644 ghostos/framework/chatpreparers/__init__.py delete mode 100644 ghostos/framework/chatpreparers/assistant_preparer.py rename ghostos/framework/{prompts/storage_impl.py => llms/prompt_storage_impl.py} (63%) delete mode 100644 ghostos/framework/prompts/__init__.py rename tests/framework/llms/{test_llms.py => test_llms_config.py} (100%) create mode 100644 tests/framework/llms/test_prompt_storage.py diff --git a/ghostos/__init__.py b/ghostos/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 4ea66215..50d96145 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -99,7 +99,7 @@ def default_application_contracts() -> Contracts: from ghostos.framework.threads import GoThreads from ghostos.framework.tasks import GoTasks from ghostos.framework.eventbuses import EventBus - from ghostos.framework.llms import LLMs + from ghostos.framework.llms import LLMs, PromptStorage from ghostos.framework.logger import LoggerItf from ghostos.framework.documents import DocumentRegistry from ghostos.core.aifunc import AIFuncExecutor, AIFuncRepository @@ -113,6 +113,8 @@ def default_application_contracts() -> Contracts: Pool, # multi-thread or process pool to submit async tasks Shutdown, # graceful shutdown register LLMs, # LLMs interface + PromptStorage, + LoggerItf, # the logger instance of application Modules, # the import_module proxy EntityFactory, # wrap and un-wrap Entity class @@ -158,7 +160,7 @@ def default_application_providers( from ghostos.framework.threads import MsgThreadsRepoByWorkSpaceProvider from ghostos.framework.tasks import WorkspaceTasksProvider from ghostos.framework.eventbuses import MemEventBusImplProvider - from ghostos.framework.llms import ConfigBasedLLMsProvider + from ghostos.framework.llms import ConfigBasedLLMsProvider, PromptStorageProvider from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.entities import EntityFactoryProvider from ghostos.core.aifunc import DefaultAIFuncExecutorProvider, AIFuncRepoByConfigsProvider @@ -189,6 +191,7 @@ def default_application_providers( # --- llm --- # ConfigBasedLLMsProvider(llms_conf_path), + PromptStorageProvider(), # --- basic library --- # EntityFactoryProvider(), diff --git a/ghostos/container.py b/ghostos/container.py index 8f186efa..db591abc 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -7,7 +7,7 @@ __all__ = [ "Container", "IoCContainer", - "Provider", "Factory", "Bootstrapper", + "Provider", "Factory", "Bootstrapper", "BootstrapProvider", "INSTANCE", "ABSTRACT", "ProviderAdapter", 'provide', 'Contracts', @@ -426,7 +426,7 @@ def bootstrap(self, container: Container) -> None: pass -class BootstrappingProvider(Generic[INSTANCE], Provider[INSTANCE], Bootstrapper, metaclass=ABCMeta): +class BootstrapProvider(Generic[INSTANCE], Provider[INSTANCE], Bootstrapper, metaclass=ABCMeta): """ 将 bootstrapper 和 Provider 可以融合在一起. """ diff --git a/ghostos/core/agents/moss_agent.py b/ghostos/core/agents/moss_agent.py index 3eb6fb66..01877e97 100644 --- a/ghostos/core/agents/moss_agent.py +++ b/ghostos/core/agents/moss_agent.py @@ -1,4 +1,4 @@ -from typing import Union, Optional, Dict, Any, TypeVar, List, Self +from typing import Union, Optional, Dict, Any, TypeVar, List, Self, Iterable from types import ModuleType from ghostos.identifier import Identifier @@ -10,7 +10,11 @@ from ghostos.core.runtime import Event, GoThreadInfo from ghostos.core.moss import MossCompiler, PyContext, Moss, MossRuntime from ghostos.core.messages import Message, Caller, Role -from ghostos.core.llms import LLMs, LLMApi, Prompt, PromptPipe, LLMFunc +from ghostos.core.llms import ( + LLMs, LLMApi, + Prompt, PromptPipe, AssistantNamePipe, run_prompt_pipeline, + LLMFunc, +) from .instructions import ( GHOSTOS_INTRODUCTION, MOSS_INTRODUCTION, AGENT_INTRODUCTION, MOSS_FUNCTION_DESC, get_moss_context_prompter, get_agent_identity, @@ -113,7 +117,8 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: instructions = self.get_instructions(session, rtm) # prepare prompt prompt = thread.to_prompt(instructions) - prompt = self.update_prompt_by_moss(rtm, prompt) + pipes = self.get_prompt_pipes(session.rtm) + prompt = run_prompt_pipeline(prompt, pipes) # prepare actions actions = self.get_actions(session, rtm) @@ -162,6 +167,9 @@ def get_actions(self, session: Session, runtime: MossRuntime) -> Dict[str, Actio moss_action = MossAction(runtime) return {moss_action.name(): moss_action} + def get_prompt_pipes(self, session: Session, runtime: MossRuntime) -> Iterable[PromptPipe]: + yield AssistantNamePipe(self.ghost.name) + def get_llmapi(self, session: Session) -> LLMApi: llms = session.container.force_fetch(LLMs) llmapi_name = self.ghost.llmapi_name diff --git a/ghostos/core/llms/__init__.py b/ghostos/core/llms/__init__.py index 7e50417c..537737ac 100644 --- a/ghostos/core/llms/__init__.py +++ b/ghostos/core/llms/__init__.py @@ -1,13 +1,10 @@ -from __future__ import annotations -from ghostos.core.llms.configs import ModelConf, ServiceConf, LLMsConfig, OPENAI_DRIVER_NAME +from ghostos.core.llms.configs import ( + ModelConf, ServiceConf, LLMsConfig, + OPENAI_DRIVER_NAME, LITELLM_DRIVER_NAME, +) from ghostos.core.llms.llm import LLMs, LLMDriver, LLMApi -from ghostos.core.llms.prompt import Prompt, PromptPipe, run_prompt_pipeline +from ghostos.core.llms.prompt import ( + Prompt, PromptPipe, run_prompt_pipeline, PromptStorage, PromptPayload, +) from ghostos.core.llms.tools import LLMFunc, FunctionalToken - -__all__ = [ - 'Prompt', 'PromptPipe', 'run_prompt_pipeline', - 'LLMs', 'LLMDriver', 'LLMApi', 'LLMFunc', 'FunctionalToken', - 'ModelConf', 'ServiceConf', 'LLMsConfig', - 'OPENAI_DRIVER_NAME', - # 'Quest', -] +from ghostos.core.llms.prompt_pipes import AssistantNamePipe diff --git a/ghostos/core/llms/configs.py b/ghostos/core/llms/configs.py index cf3a4492..d9833452 100644 --- a/ghostos/core/llms/configs.py +++ b/ghostos/core/llms/configs.py @@ -8,12 +8,15 @@ from ghostos.core.messages import Payload __all__ = [ - 'ModelConf', 'ServiceConf', 'LLMsConfig', 'OPENAI_DRIVER_NAME', + 'ModelConf', 'ServiceConf', 'LLMsConfig', + 'OPENAI_DRIVER_NAME', 'LITELLM_DRIVER_NAME', ] -OPENAI_DRIVER_NAME = "ghostos.llms.openai_driver" +OPENAI_DRIVER_NAME = "openai_driver" """default llm driver name for OpenAI llm message protocol """ +LITELLM_DRIVER_NAME = "lite_llm_Driver" + class ModelConf(Payload): """ @@ -37,7 +40,11 @@ class ServiceConf(BaseModel): The service configuration of a llm. """ name: str = Field(description="Service name") - driver: str = Field(default=OPENAI_DRIVER_NAME, description="the adapter driver name of this service. ") + driver: str = Field( + default=OPENAI_DRIVER_NAME, + description="the adapter driver name of this service. ", + ) + base_url: str = Field(description="llm service provider") token: str = Field(default="", description="token") proxy: Optional[str] = Field(default=None, description="proxy") diff --git a/ghostos/core/llms/llm.py b/ghostos/core/llms/llm.py index ae440b93..c7b59651 100644 --- a/ghostos/core/llms/llm.py +++ b/ghostos/core/llms/llm.py @@ -65,12 +65,7 @@ def deliver_chat_completion(self, chat: Prompt, deliver: Stream) -> None: deliver.deliver(message) return items = self.chat_completion_chunks(chat) - # todo: payload 要计算 tokens - for item in items: - if item.is_complete(): - # add model conf as message payload - self.get_model().set(item) - deliver.deliver(item) + deliver.send(items) class LLMDriver(ABC): diff --git a/ghostos/core/llms/prompt.py b/ghostos/core/llms/prompt.py index ebb2b106..20a9c231 100644 --- a/ghostos/core/llms/prompt.py +++ b/ghostos/core/llms/prompt.py @@ -1,8 +1,9 @@ from __future__ import annotations +import time from abc import ABC, abstractmethod -from typing import List, Iterable, Optional, Union, Callable, Tuple +from typing import List, Iterable, Optional, Union, Callable, Self from openai.types.chat.completion_create_params import Function, FunctionCall from openai import NotGiven, NOT_GIVEN from openai.types.chat.chat_completion_function_call_option_param import ChatCompletionFunctionCallOptionParam @@ -17,6 +18,7 @@ 'Prompt', 'PromptPipe', 'run_prompt_pipeline', 'PromptStorage', + 'PromptPayload', ] @@ -27,7 +29,7 @@ class Prompt(BaseModel): 模拟对话的上下文. """ id: str = Field(default_factory=helpers.uuid, description="trace id") - description: str = Field(default="") + description: str = Field(default="description of this prompt") system: List[Message] = Field(default_factory=list, description="system messages") history: List[Message] = Field(default_factory=list) @@ -40,6 +42,11 @@ class Prompt(BaseModel): # deprecated functional_tokens: List[FunctionalToken] = Field(default_factory=list) + # system info + output: List[Message] = Field(default_factory=list) + error: Optional[str] = Field(default=None, description="error message") + created: float = Field(default_factory=lambda: round(time.time(), 4)) + def system_prompt(self) -> str: contents = [] if self.system: @@ -160,9 +167,13 @@ def fork( class PromptPayload(Payload): key = "prompt_info" - prompt_id: str = Field(description="created from prompt") + pid: str = Field(description="created from prompt") desc: str = Field(default="description of the prompt") + @classmethod + def from_prompt(cls, prompt: Prompt) -> Self: + return cls(pid=prompt.id, desc=prompt.description) + class PromptPipe(ABC): """ diff --git a/ghostos/core/llms/prompt_pipes.py b/ghostos/core/llms/prompt_pipes.py new file mode 100644 index 00000000..f45dfc79 --- /dev/null +++ b/ghostos/core/llms/prompt_pipes.py @@ -0,0 +1,29 @@ +from typing import Optional +from ghostos.core.messages import Message, Role +from ghostos.core.llms import PromptPipe, Prompt + +__all__ = ['AssistantNamePipe'] + + +class AssistantNamePipe(PromptPipe): + """ + 调整 assistant name, 如果一条 assistant 消息的 name 与当前 name 相同则去掉. + 这样就会认为是自己的消息. + """ + + def __init__(self, assistant_name: str): + self._assistant_name = assistant_name + + def update_prompt(self, prompt: Prompt) -> Prompt: + def filter_fn(message: Message) -> Optional[Message]: + if message.role != Role.ASSISTANT.value: + return message + + copy = None + if message.name != self._assistant_name: + copy = message.get_copy() + copy.name = "" + return copy + + prompt.filter_messages(filter_fn) + return prompt diff --git a/ghostos/framework/chatpreparers/__init__.py b/ghostos/framework/chatpreparers/__init__.py deleted file mode 100644 index 35177407..00000000 --- a/ghostos/framework/chatpreparers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ghostos.framework.chatpreparers.assistant_preparer import OtherAgentOrTaskPipe diff --git a/ghostos/framework/chatpreparers/assistant_preparer.py b/ghostos/framework/chatpreparers/assistant_preparer.py deleted file mode 100644 index 03280010..00000000 --- a/ghostos/framework/chatpreparers/assistant_preparer.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import Optional -from ghostos.core.messages import Message, Role -from ghostos.core.llms import PromptPipe, Prompt -from ghostos.core.runtime import TaskPayload - - -class OtherAgentOrTaskPipe(PromptPipe): - """ - 调整 assistant name, 如果一条 assistant 消息的 name 与当前 name 相同则去掉. - 这样就会认为是自己的消息. - """ - - def __init__(self, *, assistant_name: str, task_id: str = "", with_task_name: bool = False): - self._assistant_name = assistant_name - self._task_id = task_id - self._with_task_name = with_task_name - - def update_prompt(self, chat: Prompt) -> Prompt: - def filter_fn(message: Message) -> Optional[Message]: - if message.role != Role.ASSISTANT.value: - return message - - copy = None - if message.name != self._assistant_name: - copy = message.get_copy() - copy.name = "" - - task_payload = TaskPayload.read(message) - # 判断是否要做任务信息的改造. - if task_payload is None or message.memory is None or task_payload.task_id == self._task_id: - return copy if copy else message - - copy = copy if copy else message.get_copy() - # 对齐用户所见的消息体. - copy.memory = None - if self._with_task_name: - copy.name = "task." + task_payload.name - return copy - - chat.filter_messages(filter_fn) - return chat diff --git a/ghostos/framework/eventbuses/memimpl.py b/ghostos/framework/eventbuses/memimpl.py index 81551d83..46c28944 100644 --- a/ghostos/framework/eventbuses/memimpl.py +++ b/ghostos/framework/eventbuses/memimpl.py @@ -4,7 +4,7 @@ from ghostos.core.runtime import Event from ghostos.core.runtime.events import EventBus from queue import Queue, Empty -from ghostos.container import Provider, Container, BootstrappingProvider +from ghostos.container import Provider, Container, BootstrapProvider from ghostos.contracts.shutdown import Shutdown @@ -73,7 +73,7 @@ def shutdown(self) -> None: del self._task_queues -class MemEventBusImplProvider(BootstrappingProvider[EventBus]): +class MemEventBusImplProvider(BootstrapProvider[EventBus]): """ mem event bus provider """ diff --git a/ghostos/framework/llms/__init__.py b/ghostos/framework/llms/__init__.py index 4e2f8bf7..6c256bf1 100644 --- a/ghostos/framework/llms/__init__.py +++ b/ghostos/framework/llms/__init__.py @@ -1,4 +1,5 @@ -from ghostos.core.llms import LLMs +from ghostos.core.llms import LLMs, Prompt, PromptStorage from ghostos.framework.llms.llms import LLMsImpl from ghostos.framework.llms.openai_driver import OpenAIDriver, OpenAIAdapter, LitellmAdapter -from ghostos.framework.llms.providers import ConfigBasedLLMsProvider +from ghostos.framework.llms.providers import ConfigBasedLLMsProvider, PromptStorageProvider +from ghostos.framework.llms.prompt_storage_impl import PromptStorageImpl diff --git a/ghostos/framework/llms/llms.py b/ghostos/framework/llms/llms.py index e2fcbe8c..05b26962 100644 --- a/ghostos/framework/llms/llms.py +++ b/ghostos/framework/llms/llms.py @@ -23,7 +23,7 @@ def __init__( self._llm_models: Dict[str, ModelConf] = {} self._default_driver = default_driver self._apis: Dict[str, LLMApi] = {} - self._default_llm_model: ModelConf = conf.default + self._default_llm_model: ModelConf = conf.models[conf.default] if self._default_llm_model is None: raise AttributeError("llms conf must contains default model conf") diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index 16fd0d9a..a3134616 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -1,4 +1,3 @@ -import os from typing import List, Iterable, Union, Optional from openai import OpenAI @@ -14,15 +13,18 @@ CompletionUsagePayload, ) from ghostos.core.llms import ( - LLMs, LLMDriver, LLMApi, ModelConf, ServiceConf, OPENAI_DRIVER_NAME, - Prompt, + LLMs, LLMDriver, LLMApi, ModelConf, ServiceConf, OPENAI_DRIVER_NAME, LITELLM_DRIVER_NAME, + Prompt, PromptPayload, PromptStorage, FunctionalToken, ) from ghostos.container import Bootstrapper, Container import litellm -__all__ = ['OpenAIDriver', 'OpenAIAdapter', 'OpenAIDriverBootstrapper', 'LitellmAdapter'] +__all__ = [ + 'OpenAIDriver', 'OpenAIAdapter', 'OpenAIDriverBootstrapper', + 'LitellmAdapter', 'LiteLLMDriver', +] class FunctionalTokenPrompt(str): @@ -73,10 +75,13 @@ def __init__( service_conf: ServiceConf, model_conf: ModelConf, parser: OpenAIMessageParser, + storage: PromptStorage, + # deprecated: functional_token_prompt: Optional[str] = None, ): self._service = service_conf self._model = model_conf + self._storage: PromptStorage = storage http_client = None if service_conf.proxy: transport = SyncProxyTransport.from_url(service_conf.proxy) @@ -102,29 +107,7 @@ def get_model(self) -> ModelConf: def text_completion(self, prompt: str) -> str: raise NotImplemented("text_completion is deprecated, implement it later") - # def get_embeddings(self, texts: List[str]) -> Embeddings: - # try: - # model = self._model.model - # resp = self._client.embeddings.create( - # input=texts, - # model=model, - # # todo: 未来再做更多细节. - # ) - # result = [] - # for i, text in enumerate(texts): - # embedding = resp.embeddings[i] - # result.append(Embedding( - # text=text, - # embedding=embedding - # )) - # return Embeddings(result=result) - # - # except Exception as e: - # # todo: log - # raise GhostOSIOError("failed to get text embedding", e) - def _chat_completion(self, chat: Prompt, stream: bool) -> Union[ChatCompletion, Iterable[ChatCompletionChunk]]: - # todo: try catch chat = self.parse_chat(chat) include_usage = ChatCompletionStreamOptionsParam(include_usage=True) if stream else NOT_GIVEN messages = chat.get_messages() @@ -147,35 +130,50 @@ def _chat_completion(self, chat: Prompt, stream: bool) -> Union[ChatCompletion, ) def chat_completion(self, chat: Prompt) -> Message: - message: ChatCompletion = self._chat_completion(chat, stream=False) - pack = self._parser.from_chat_completion(message.choices[0].message) - # add completion usage - self._model.set(pack) - if message.usage: - usage = CompletionUsagePayload.from_usage(message.usage) - usage.set(pack) - - if not pack.is_complete(): - pack.chunk = False - return pack + try: + message: ChatCompletion = self._chat_completion(chat, stream=False) + chat.output = [message] + pack = self._parser.from_chat_completion(message.choices[0].message) + # add completion usage + self._model.set(pack) + if message.usage: + usage = CompletionUsagePayload.from_usage(message.usage) + usage.set(pack) + + if not pack.is_complete(): + pack.chunk = False + return pack + except Exception as e: + chat.error = str(e) + raise + finally: + self._storage.save(chat) def chat_completion_chunks(self, chat: Prompt) -> Iterable[Message]: - chunks: Iterable[ChatCompletionChunk] = self._chat_completion(chat, stream=True) - messages = self._parser.from_chat_completion_chunks(chunks) - first = True - for chunk in messages: - if first: - self._model.set(chunk) - first = False - yield chunk + try: + chunks: Iterable[ChatCompletionChunk] = self._chat_completion(chat, stream=True) + messages = self._parser.from_chat_completion_chunks(chunks) + prompt_payload = PromptPayload.from_prompt(chat) + output = [] + for chunk in messages: + yield chunk + if chunk.is_complete(): + self._model.set(chunk) + prompt_payload.set(chunk) + output.append(chunk) + chat.output = output + except Exception as e: + chat.error = str(e) + finally: + self._storage.save(chat) def parse_chat(self, chat: Prompt) -> Prompt: - if not chat.functional_tokens: - return chat - prompt = FunctionalTokenPrompt(self._functional_token_prompt) - content = prompt.format_tokens(chat.functional_tokens) - message = MessageType.DEFAULT.new_system(content=content) - chat.system.append(message) + # if not chat.functional_tokens: + # return chat + # prompt = FunctionalTokenPrompt(self._functional_token_prompt) + # content = prompt.format_tokens(chat.functional_tokens) + # message = MessageType.DEFAULT.new_system(content=content) + # chat.system.append(message) return chat @@ -184,20 +182,17 @@ class OpenAIDriver(LLMDriver): adapter """ - def __init__(self, parser: Optional[OpenAIMessageParser] = None): + def __init__(self, storage: PromptStorage, parser: Optional[OpenAIMessageParser] = None): if parser is None: - parser = DefaultOpenAIMessageParser() + parser = DefaultOpenAIMessageParser([]) self._parser = parser + self._storage = storage def driver_name(self) -> str: return OPENAI_DRIVER_NAME def new(self, service: ServiceConf, model: ModelConf) -> LLMApi: - # todo: 不能这么 hack. - if service.name in ("anthropic", "deepseek"): - return LitellmAdapter(service, model, self._parser) - - return OpenAIAdapter(service, model, self._parser) + return OpenAIAdapter(service, model, self._parser, self._storage) class LitellmAdapter(OpenAIAdapter): @@ -221,8 +216,21 @@ def _chat_completion(self, chat: Prompt, stream: bool) -> ChatCompletion: return response.choices[0].message +class LiteLLMDriver(OpenAIDriver): + + def driver_name(self) -> str: + return LITELLM_DRIVER_NAME + + def new(self, service: ServiceConf, model: ModelConf) -> LLMApi: + return LitellmAdapter(service, model, self._parser, self._storage) + + class OpenAIDriverBootstrapper(Bootstrapper): def bootstrap(self, container: Container) -> None: llms = container.force_fetch(LLMs) - llms.register_driver(OpenAIDriver()) + storage = container.force_fetch(PromptStorage) + openai_driver = OpenAIDriver(storage) + lite_llm_driver = LiteLLMDriver(storage) + llms.register_driver(openai_driver) + llms.register_driver(lite_llm_driver) diff --git a/ghostos/framework/prompts/storage_impl.py b/ghostos/framework/llms/prompt_storage_impl.py similarity index 63% rename from ghostos/framework/prompts/storage_impl.py rename to ghostos/framework/llms/prompt_storage_impl.py index 2cbd6bd9..7f4ba02e 100644 --- a/ghostos/framework/prompts/storage_impl.py +++ b/ghostos/framework/llms/prompt_storage_impl.py @@ -1,10 +1,8 @@ from typing import Optional from ghostos.contracts.storage import Storage -from ghostos.contracts.workspace import Workspace from ghostos.core.llms import Prompt from ghostos.core.llms.prompt import PromptStorage -from ghostos.container import Provider, Container, INSTANCE import json @@ -30,16 +28,3 @@ def get(self, prompt_id: str) -> Optional[Prompt]: data = json.loads(content) return Prompt(**data) return None - - -class PromptStorageProvider(Provider[PromptStorage]): - def __init__(self, relative_path: str = "prompts"): - self._relative_path = relative_path - - def singleton(self) -> bool: - return True - - def factory(self, con: Container) -> Optional[INSTANCE]: - ws = con.force_fetch(Workspace) - storage = ws.runtime().sub_storage(self._relative_path) - return PromptStorageImpl(storage) diff --git a/ghostos/framework/llms/providers.py b/ghostos/framework/llms/providers.py index 1c33535e..d3eda70f 100644 --- a/ghostos/framework/llms/providers.py +++ b/ghostos/framework/llms/providers.py @@ -1,15 +1,17 @@ from typing import Type, Optional from ghostos.contracts.configs import YamlConfig, Configs -from ghostos.container import Provider, Container -from ghostos.core.llms import LLMs, LLMsConfig +from ghostos.container import BootstrapProvider, Provider, Container +from ghostos.core.llms import LLMs, LLMsConfig, PromptStorage from ghostos.framework.llms.llms import LLMsImpl -from ghostos.framework.llms.openai_driver import OpenAIDriver +from ghostos.framework.llms.openai_driver import OpenAIDriver, LiteLLMDriver +from ghostos.framework.llms.prompt_storage_impl import PromptStorageImpl +from ghostos.contracts.workspace import Workspace -__all__ = ['ConfigBasedLLMsProvider'] +__all__ = ['ConfigBasedLLMsProvider', 'PromptStorageProvider'] -class ConfigBasedLLMsProvider(Provider[LLMs]): +class ConfigBasedLLMsProvider(BootstrapProvider[LLMs]): """ 基于 Config 来读取 """ @@ -32,8 +34,28 @@ class LLMsYamlConfig(YamlConfig, LLMsConfig): relative_path = self.llm_conf_path configs = con.force_fetch(Configs) + storage = con.force_fetch(PromptStorage) + conf = configs.get(LLMsYamlConfig) - openai_driver = OpenAIDriver() + openai_driver = OpenAIDriver(storage) + lite_llm_driver = LiteLLMDriver(storage) + + # register default drivers. llms = LLMsImpl(conf=conf, default_driver=openai_driver) llms.register_driver(openai_driver) + llms.register_driver(lite_llm_driver) + return llms + + +class PromptStorageProvider(Provider[PromptStorage]): + def __init__(self, relative_path: str = "prompts"): + self._relative_path = relative_path + + def singleton(self) -> bool: + return True + + def factory(self, con: Container) -> Optional[PromptStorage]: + ws = con.force_fetch(Workspace) + storage = ws.runtime().sub_storage(self._relative_path) + return PromptStorageImpl(storage) diff --git a/ghostos/framework/prompts/__init__.py b/ghostos/framework/prompts/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/framework/llms/test_llms.py b/tests/framework/llms/test_llms_config.py similarity index 100% rename from tests/framework/llms/test_llms.py rename to tests/framework/llms/test_llms_config.py diff --git a/tests/framework/llms/test_prompt_storage.py b/tests/framework/llms/test_prompt_storage.py new file mode 100644 index 00000000..167bfa95 --- /dev/null +++ b/tests/framework/llms/test_prompt_storage.py @@ -0,0 +1,16 @@ +from ghostos.framework.llms import PromptStorageImpl, Prompt +from ghostos.framework.storage import MemStorage +from ghostos.core.messages import Message + + +def test_prompt_storage_baseline(): + storage = MemStorage() + prompts = PromptStorageImpl(storage) + + prompt = Prompt() + prompt.inputs.append(Message.new_tail(content="hello world")) + id_ = prompt.id + + prompts.save(prompt) + got = prompts.get(id_) + assert got == prompt From 857250d025653f33ad1858a10a9cf138614f1e4e Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 12 Nov 2024 00:32:02 +0800 Subject: [PATCH 068/148] dev: update messenger and llm to recent refact --- ghostos/core/abcd/concepts.py | 7 +- ghostos/core/agents/moss_agent.py | 7 +- ghostos/core/llms/llm.py | 11 +- ghostos/core/messages/pipeline.py | 1 + ghostos/core/messages/transport.py | 2 + ghostos/framework/messages/buffers.py | 10 +- ghostos/framework/messengers/__init__.py | 2 +- ghostos/framework/messengers/defaults.py | 317 ++++-------------- tests/framework/messenger/test_messenger.py | 139 +------- .../framework/streams/test_arr_connection.py | 192 ----------- tests/test_abc.py | 7 - tests/test_prompter.py | 8 +- 12 files changed, 95 insertions(+), 608 deletions(-) delete mode 100644 tests/framework/streams/test_arr_connection.py delete mode 100644 tests/test_abc.py diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index c56285f7..bfcf79b2 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -421,6 +421,8 @@ def observe(self, *messages: MessageKind) -> Operator: def on_error(self, *messages: MessageKind) -> Operator: pass + stream: Stream + scope: Scope """the running scope of the session""" @@ -488,10 +490,7 @@ def flow(self) -> Flow: pass @abstractmethod - def messenger( - self, *, - remember: bool = True, - ) -> "Messenger": + def messenger(self) -> "Messenger": """ Task 当前运行状态下, 向上游发送消息的 Messenger. 每次会实例化一个 Messenger, 理论上不允许并行发送消息. 但也可能做一个技术方案去支持它. diff --git a/ghostos/core/agents/moss_agent.py b/ghostos/core/agents/moss_agent.py index 01877e97..87df7427 100644 --- a/ghostos/core/agents/moss_agent.py +++ b/ghostos/core/agents/moss_agent.py @@ -117,7 +117,7 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: instructions = self.get_instructions(session, rtm) # prepare prompt prompt = thread.to_prompt(instructions) - pipes = self.get_prompt_pipes(session.rtm) + pipes = self.get_prompt_pipes(session, rtm) prompt = run_prompt_pipeline(prompt, pipes) # prepare actions @@ -129,11 +129,10 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: # call llm llm = self.get_llmapi(session) - messenger = session.messenger() - llm.deliver_chat_completion(prompt, messenger) + messages = llm.deliver_chat_completion(prompt, not session.stream.completes_only()) + messages, callers = session.respond(messages, remember=True) # handle actions - messages, callers = messenger.flush() for caller in callers: if caller.name in actions: action = actions[caller.name] diff --git a/ghostos/core/llms/llm.py b/ghostos/core/llms/llm.py index c7b59651..07c80376 100644 --- a/ghostos/core/llms/llm.py +++ b/ghostos/core/llms/llm.py @@ -56,16 +56,15 @@ def chat_completion_chunks(self, chat: Prompt) -> Iterable[Message]: """ pass - def deliver_chat_completion(self, chat: Prompt, deliver: Stream) -> None: + def deliver_chat_completion(self, chat: Prompt, stream: bool) -> Iterable[Message]: """ 逐个发送消息的包. """ - if deliver.completes_only(): + if not stream: message = self.chat_completion(chat) - deliver.deliver(message) - return - items = self.chat_completion_chunks(chat) - deliver.send(items) + return [message] + + yield from self.chat_completion_chunks(chat) class LLMDriver(ABC): diff --git a/ghostos/core/messages/pipeline.py b/ghostos/core/messages/pipeline.py index 0c4ae7b6..ad624946 100644 --- a/ghostos/core/messages/pipeline.py +++ b/ghostos/core/messages/pipeline.py @@ -43,6 +43,7 @@ def across(self, messages: Iterable[Message]) -> Iterable[Message]: final: Optional[Message] = None for item in messages: if MessageType.is_protocol_message(item): + final = item break if head is None: if item.is_complete(): diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index 5c78e01d..7ba1f38e 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -24,6 +24,8 @@ def send(self, messages: Iterable[Message]) -> bool: pass def deliver(self, message: Message) -> bool: + if not message.is_complete(): + message = message.as_tail() return self.send([message]) @abstractmethod diff --git a/ghostos/framework/messages/buffers.py b/ghostos/framework/messages/buffers.py index e0272075..ee27a232 100644 --- a/ghostos/framework/messages/buffers.py +++ b/ghostos/framework/messages/buffers.py @@ -8,6 +8,7 @@ __all__ = ['DefaultBuffer'] +# deprecated class DefaultBuffer(Buffer): """ 基于 Message 标准协议的默认 buffer. @@ -19,7 +20,6 @@ def __init__( name: Optional[str] = None, role: str = Role.ASSISTANT.value, payloads: Optional[Iterable[Payload]] = None, - attachments: Optional[Iterable[Attachment]] = None, functional_tokens: Optional[Iterable[FunctionalToken]] = None, ): self._default_name = name @@ -28,8 +28,6 @@ def __init__( """默认的角色""" self._payloads = list(payloads) if payloads else None """默认的 payloads""" - self._attachments = list(attachments) if attachments else None - """默认的 attachments""" self._buffering_message: Optional[Message] = None """正在 buff 的消息体. """ @@ -276,10 +274,6 @@ def _wrap_first_pack(self, pack: Message) -> Message: if not payload.exists(pack): payload.set(pack) - # 添加默认的 attachments. - if self._attachments: - for attachment in self._attachments: - attachment.add(pack) return pack def _receive_head_pack(self, pack: "Message") -> Iterable[Message]: @@ -295,7 +289,7 @@ def _clear_tail_pack(self) -> Optional[Message]: return None buffering = self._buffering_message - buffering.chunk = False + buffering = buffering.as_tail() if self._functional_token_starts: if self._buffering_token: diff --git a/ghostos/framework/messengers/__init__.py b/ghostos/framework/messengers/__init__.py index 3a73ac6a..da1d8e87 100644 --- a/ghostos/framework/messengers/__init__.py +++ b/ghostos/framework/messengers/__init__.py @@ -1,2 +1,2 @@ from ghostos.core.runtime import Messenger -from ghostos.framework.messengers.defaults import DefaultMessenger, TestMessengerProvider +from ghostos.framework.messengers.defaults import DefaultMessenger diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index 229e8f54..99af2b70 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -1,276 +1,85 @@ -from typing import Optional, Iterable, TYPE_CHECKING, Type, Dict, List -from ghostos.container import Container, Provider -from ghostos.core.runtime.messenger import Messenger, Buffed +from typing import Optional, Iterable, List, Tuple +from ghostos.core.runtime.messenger import Messenger from ghostos.core.messages import ( - Message, Payload, Attachment, Role, MessageType, - Buffer, Stream, + Message, Payload, Role, + Stream, Caller, ) -from ghostos.core.runtime.threads import GoThreadInfo -from ghostos.core.llms import FunctionalToken -from ghostos.framework.messages.buffers import DefaultBuffer -from ghostos.helpers import uuid -from threading import Lock - -if TYPE_CHECKING: - from ghostos.contracts.logger import LoggerItf +from ghostos.core.messages.pipeline import SequencePipe __all__ = [ - 'DefaultMessenger', 'TestMessengerProvider' + 'DefaultMessenger' ] -class DefaultMessenger(Messenger, Stream): - """ - 默认的 Deliver, 支持消息的各种工具. - """ +class DefaultMessenger(Messenger): def __init__( - self, *, - depth: int = 0, - upstream: Optional[Stream] = None, - thread: Optional["GoThreadInfo"] = None, + self, + upstream: Optional[Stream], + *, name: Optional[str] = None, role: Optional[str] = None, - buffer: Optional[Buffer] = None, payloads: Optional[Iterable[Payload]] = None, - attachments: Optional[Iterable[Attachment]] = None, - functional_tokens: Optional[Iterable[FunctionalToken]] = None, - saving: bool = True, - logger: Optional["LoggerItf"] = None, ): - """ - 初始化一个 Messenger. - :param upstream: 如果为 None 的话, 不会对上游发送消息. - :param thread: 如果不为 None, 会把发送的尾包记录到 thread 里. - :param name: 消息体的名字. - :param role: 消息体的角色, 默认设定为 Assistant - :param buffer: 是否传入自定义的 buffer. - :param payloads: 每条消息都必须添加的 payload. - :param attachments: 每条消息都必须添加的 attachments. - :param functional_tokens: 是否有需要处理的 functional tokens - :param logger: - """ - self._depth = depth - self._thread: Optional[GoThreadInfo] = thread - # self._streaming_id: str = uuid() - self._name = name - self._logger = logger + self._upstream = upstream + self._assistant_name = name self._role = role if role else Role.ASSISTANT.value - self._upstream: Optional[upstream] = upstream - self._stopped: bool = False - self._saving: bool = saving - self._payloads: Optional[Iterable[Payload]] = payloads - """默认的 payloads""" - self._attachments: Optional[Iterable[Attachment]] = attachments - """消息体默认的附件. """ - self._functional_tokens = functional_tokens - if buffer is None: - buffer = DefaultBuffer( - name=self._name, - role=self._role, - payloads=self._payloads, - attachments=self._attachments, - functional_tokens=self._functional_tokens, - ) - self._buffer: Buffer = buffer - self._accept_chunks = upstream.accept_chunks() if upstream else False - # self._sending_stream_id: Optional[str] = None - # self._sending_stream_buffer: Dict[str, List[Message]] = {} - self._destroyed: bool = False - self._locker = Lock() - - # def new( - # self, *, - # sending: bool = True, - # thread: Optional[MsgThread] = None, - # name: Optional[str] = None, - # buffer: Optional[Buffer] = None, - # payloads: Optional[Iterable[Payload]] = None, - # attachments: Optional[Iterable[Attachment]] = None, - # functional_tokens: Optional[Iterable[FunctionalToken]] = None, - # ) -> "Messenger": - # # payloads 完成初始化. - # # copy - # messenger = DefaultMessenger( - # depth=self._depth + 1, - # upstream=self._upstream, - # thread=thread, - # name=self._name, - # role=self._role, - # # buffer is None to sub manager - # buffer=buffer, - # payloads=payloads, - # attachments=attachments, - # functional_tokens=functional_tokens, - # ) - # return messenger - - def accept_chunks(self) -> bool: - return self._accept_chunks - - def deliver(self, pack: "Message") -> bool: - if self.stopped(): - return False - - elif MessageType.is_final(pack): - # 下游发送的 final 包, 上游会装作已经发送成功. - return True + self._payloads = payloads + self._sent_messages = [] + self._sent_callers = [] - with self._locker: - # 下游返回 error, 会导致全链路的 messenger 因为 error 而停止. - # 所以 error 类型的消息, 链路里只能有一个. - if MessageType.ERROR.match(pack): - # receive error pack will stop the current streaming. - self._stop(pack) - return True - # return self._map_or_deliver_by_streaming_id(pack) - return self._buff_then_deliver(pack) - - # def _map_or_deliver_by_streaming_id(self, pack: "Message") -> bool: - # """ - # use streaming id to buff or reduce messages. - # """ - # if self._depth > 0: - # return self._buff_then_deliver(pack) - # if self._sending_stream_id is None: - # self._sending_stream_id = pack.streaming_id - # - # if pack.streaming_id not in self._sending_stream_buffer: - # self._sending_stream_buffer[pack.streaming_id] = [] - # buffer = self._sending_stream_buffer[pack.streaming_id] - # buffer.append(pack) - # if self._sending_stream_id != pack.streaming_id: - # return True - # else: - # # reduce deliver - # return self._reduce_streaming_items() - - # def _reduce_streaming_items(self) -> bool: - # if self._sending_stream_id is not None: - # items = self._sending_stream_buffer[self._sending_stream_id] - # self._sending_stream_buffer[self._sending_stream_id] = [] - # last = None - # for item in items: - # success = self._buff_then_deliver(item) - # if not success: - # return False - # last = item - # if last and (last.is_complete() or DefaultMessageTypes.is_protocol_type(last)): - # print("\n+++`" + last.content + "`+++\n") - # del self._sending_stream_buffer[self._sending_stream_id] - # self._sending_stream_id = None - # # keep going - # return self._reduce_streaming_items() - # else: - # # still buffering - # return True - # elif len(self._sending_stream_buffer) == 0: - # # all items are sent - # self._sending_stream_id = None - # self._sending_stream_buffer = {} - # return True - # else: - # for key in self._sending_stream_buffer: - # self._sending_stream_id = key - # break - # return self._reduce_streaming_items() - - def _buff_then_deliver(self, pack: "Message") -> bool: - delivery = self._buffer.add(pack) - return self._deliver_to_upstream(delivery) - - def _deliver_to_upstream(self, delivery: Iterable[Message]) -> bool: - if self._stopped: - return False - for item in delivery: - if not MessageType.is_protocol_message(item) and item.chunk and not self._accept_chunks: - continue - # 如果发送不成功, 直接中断. - # if self._depth == 0: - # item.streaming_id = None - # if ( - # self._saving - # and self._thread is not None # thread exists. - # and not DefaultMessageTypes.is_protocol_type(item) # not a protocol type message. - # and not item.chunk - # ): # is tail package. - # # append tail message to thread. - # self._thread.append(item) - - if self._upstream is not None: - success = self._upstream.deliver(item) - if not success: - # in case check upstream is stopped over and over again. - self._stopped = self._upstream.stopped() - return False + def flush(self) -> Tuple[List[Message], List[Caller]]: + messages = self._sent_messages + callers = self._sent_callers + del self._upstream + del self._sent_messages + del self._sent_callers + return messages, callers + + def send(self, messages: Iterable[Message]) -> bool: + messages = self.buffer(messages) + if self._upstream is not None: + return self._upstream.send(messages) + list(messages) return True - def flush(self) -> Buffed: - if self._stopped or self._destroyed: - return Buffed(messages=[], callers=[]) - buffed = self._buffer.flush() - if buffed.unsent: - self._deliver_to_upstream(buffed.unsent) - if self._thread: - self._thread.append(*buffed.messages) - self._stop(None) - return Buffed(messages=buffed.messages, callers=buffed.callers) - - def _stop(self, final: Optional[Message]) -> None: - """ - 停止并且发送指定的 final 包. 如果没有指定, 则发送 DefaultTypes.final() - """ - self._stopped = True - if self._destroyed: - return - if final is None or not MessageType.is_protocol_message(final): - final = MessageType.final() - self._deliver_to_upstream([final]) - self.destroy() - - def stopped(self) -> bool: - if self._stopped: - return True - if self._upstream is None: - return False - if self._upstream.stopped(): - self._stopped = True - return self._stopped - - def __exit__(self, exc_type, exc_val, exc_tb): - if self._stopped: - return - self.flush() - if exc_val: - self._stop(MessageType.ERROR.new(content=str(exc_val))) - self.destroy() - - def destroy(self) -> None: - """ - I kind of don't trust python gc, let me help some - :return: - """ - if self._destroyed: - return - self._destroyed = True - del self._upstream - self._buffer = None - del self._payloads - del self._attachments - self._thread = None - del self._functional_tokens + def buffer(self, messages: Iterable[Message]) -> Iterable[Message]: + messages = SequencePipe().across(messages) + for item in messages: + if item.is_complete() or item.is_head(): + item.name = self._assistant_name + if not item.role: + item.role = self._role + + if item.is_complete() and self._payloads: + for payload in self._payloads: + payload.set(item) + + if item.is_complete(): + self._sent_messages.append(item) + if len(item.callers) > 0: + self._sent_callers.extend(item.callers) + + # skip chunk + if self._upstream and self._upstream.completes_only() and not item.is_complete(): + continue + yield item + def completes_only(self) -> bool: + return self._upstream is not None and self._upstream.completes_only() -class TestMessengerProvider(Provider[Messenger]): - """ - for test only - """ + def alive(self) -> bool: + return self._upstream is None or self._upstream.alive() - def singleton(self) -> bool: - return True + def close(self): + return - def contract(self) -> Type[Messenger]: - return Messenger + def fail(self, error: str) -> bool: + if self._upstream is not None: + return self._upstream.fail(error) + return False - def factory(self, con: Container) -> Messenger: - return DefaultMessenger() + def error(self) -> Optional[Message]: + if self._upstream is not None: + return self._upstream.error() + return None diff --git a/tests/framework/messenger/test_messenger.py b/tests/framework/messenger/test_messenger.py index 989800ef..7f817a6b 100644 --- a/tests/framework/messenger/test_messenger.py +++ b/tests/framework/messenger/test_messenger.py @@ -1,138 +1,15 @@ -from ghostos.framework.messengers import Messenger, DefaultMessenger -from ghostos.framework.streams import EmptyStream -from ghostos.core.runtime.threads import GoThreadInfo +from ghostos.framework.messengers import DefaultMessenger from ghostos.core.messages import Message -from ghostos.core.llms import FunctionalToken def test_default_messenger_baseline(): - thread = GoThreadInfo() - messenger = DefaultMessenger(thread=thread) + messenger = DefaultMessenger(None) content = "hello world" + items = [] for c in content: msg = Message.new_chunk(content=c) - success = messenger.deliver(msg) - assert success - messenger.flush() - assert len(thread.current.added) == 1 - assert thread.current.added[0].content == content - - -def test_messenger_with_random_token(): - functional_tokens = [FunctionalToken( - token=">moss:", - name="moss", - description="desc", - visible=False, - )] - - thread = GoThreadInfo() - messenger = DefaultMessenger(thread=thread, functional_tokens=functional_tokens) - - contents = ["he", "llo >mo", "ss: w", "orld"] - content = "".join(contents) - for c in contents: - msg = Message.new_chunk(content=c) - messenger.deliver(msg) - flushed = messenger.flush() - assert len(list(flushed.callers)) > 0 - message = flushed.messages[0] - assert message.content != content - assert message.memory == content - caller = flushed.callers[0] - assert caller.name == "moss" - assert caller.arguments == " world" - - assert len(thread.last_turn().added) == 1 - assert len(thread.last_turn().added[0].callers) == 1 - - -def test_messenger_with_single_message(): - functional_tokens = [FunctionalToken( - token="", - end_token="", - name="moss", - description="desc", - visible=False, - )] - - thread = GoThreadInfo() - messenger = DefaultMessenger(thread=thread, functional_tokens=functional_tokens) - - content = "def main():\n pass" - messenger.say(content) - flushed = messenger.flush() - assert flushed.messages[0].content == "" - assert flushed.messages[0].memory == content - assert len(flushed.callers) == 1 - - -def test_messenger_with_func_token_visible(): - functional_tokens = [FunctionalToken( - token="", - end_token="", - name="moss", - description="desc", - visible=True, - )] - - thread = GoThreadInfo() - messenger = DefaultMessenger( - thread=thread, - functional_tokens=functional_tokens, - upstream=EmptyStream(), - ) - - content = "hello worldhello" - messenger.say(content) - flushed = messenger.flush() - assert flushed.messages[0].content == content - assert flushed.messages[0].memory is None - assert len(flushed.callers) == 1 - assert flushed.callers[0].name == "moss" - -# def test_async_sub_messengers(): -# from threading import Thread -# functional_tokens = [FunctionalToken( -# token="", -# end_token="", -# name="moss", -# description="desc", -# visible=False, -# )] -# -# def make(m: Messenger, idx: int): -# def fn(): -# content = f"{idx}def main():\n pass" -# mod = idx % 3 + 1 -# contents = [] -# c = 0 -# while c < len(content): -# contents.append(content[c:c + mod]) -# c = c + mod -# for line in contents: -# m.deliver(Message.new_chunk(content=line)) -# time.sleep(0.1) -# messages, callers = m.flush() -# print(f"\ncontent {idx}: {len(messages)} + `{messages[0].content}`") -# -# -# return fn -# -# thread = MsgThread() -# messenger = DefaultMessenger(thread=thread, functional_tokens=functional_tokens) -# running = [] -# for i in range(10): -# sub = messenger.new() -# f = make(sub, i) -# t = Thread(target=f) -# running.append(t) -# t.start() -# for t in running: -# t.join() -# flushed = messenger.flush() -# for msg in flushed.messages: -# # print(msg.streaming_id) -# print("\n++\n" + msg.get_content()) -# for caller in flushed.callers: -# print(caller.arguments) + items.append(msg) + messenger.send(items) + messages, callers = messenger.flush() + assert len(messages) == 1 + assert len(callers) == 0 diff --git a/tests/framework/streams/test_arr_connection.py b/tests/framework/streams/test_arr_connection.py deleted file mode 100644 index 25e07706..00000000 --- a/tests/framework/streams/test_arr_connection.py +++ /dev/null @@ -1,192 +0,0 @@ -import time - -from ghostos.core.messages import Message -from ghostos.framework.streams import new_connection, Stream -from ghostos.framework.messengers import DefaultMessenger -from ghostos.core.runtime import GoThreadInfo -from ghostos.core.llms import FunctionalToken -from threading import Thread - - -def test_new_connection_baseline(): - stream, retriever = new_connection(timeout=5, accept_chunks=True) - content = "hello world, ha ha ha ha" - - def send_data(s: Stream): - with s: - for c in content: - s.deliver(Message.new_chunk(content=c)) - time.sleep(0.1) - - t = Thread(target=send_data, args=(stream,)) - t.start() - count = 0 - with retriever as items: - for item in items: - head = item.head() - assert head.msg_id - assert head.chunk - assert head.content == "h" - chunks = 0 - for ck in item.chunks(): - assert ck.content == content[chunks] - chunks += 1 - done = item.done() - assert done is not None, f"current {count}: {item}" - assert done.content == content - count += 1 - assert count == 1 - - -def test_new_connection_timeout(): - stream, retriever = new_connection(timeout=0.2, accept_chunks=True) - content = "hello world" - - def send_data(s: Stream): - err = None - try: - with s: - for c in content: - s.deliver(Message.new_chunk(content=c)) - time.sleep(0.5) - except Exception as e: - err = e - assert err is not None - - t = Thread(target=send_data, args=(stream,)) - t.start() - messages = [] - with retriever as items: - for item in items: - done = item.done() - assert done is not None - messages.append(done) - assert len(messages) == 2 - - -def test_new_connection_not_chunks(): - stream, retriever = new_connection(timeout=-1, accept_chunks=True) - content = "hello world" - - def send_data(s: Stream): - with s: - for i in range(5): - s.deliver(Message.new_tail(content=f"{i}{content}")) - time.sleep(0.05) - - t = Thread(target=send_data, args=(stream,)) - t.start() - messages = [] - with retriever as items: - for item in items: - head = item.head() - assert head is not None - assert head.content.endswith(content) - assert len(list(item.chunks())) == 1 - done = item.done() - assert done is not None - messages.append(done) - assert len(messages) == 5 - for i in range(len(messages)): - assert messages[i].content.startswith(str(i)) - - -def test_new_connection_sync(): - stream, retriever = new_connection(timeout=5, accept_chunks=True) - content = "hello world, ha ha ha ha" - - with stream: - for c in content: - stream.deliver(Message.new_chunk(content=c)) - - messages = [] - with retriever as items: - for item in items: - done = item.done() - messages.append(done) - assert len(messages) == 1 - - -def test_new_connection_with_messenger_sync(): - stream, retriever = new_connection(timeout=5, accept_chunks=True) - content = "hello world, ha ha ha ha" - - with stream: - messenger = DefaultMessenger(upstream=stream, thread=GoThreadInfo()) - with messenger: - for c in content: - messenger.deliver(Message.new_chunk(content=c)) - - messages = [] - with retriever as items: - for item in items: - done = item.done() - messages.append(done) - assert len(messages) == 1 - - -def test_new_connection_with_messenger_async(): - stream, retriever = new_connection(timeout=5, accept_chunks=True) - content = "hello world, ha ha ha ha" - - def send_data(s: Stream): - with s: - messenger = DefaultMessenger(upstream=s, thread=GoThreadInfo()) - with messenger: - for c in content: - messenger.deliver(Message.new_chunk(content=c)) - flushed = messenger.flush() - assert len(flushed.messages) == 1 - - t = Thread(target=send_data, args=(stream,)) - t.start() - - messages = [] - with retriever as items: - for item in items: - done = item.done() - messages.append(done) - assert len(messages) == 1 - t.join() - - -def test_new_connection_with_functional_tokens(): - stream, retriever = new_connection(timeout=5, accept_chunks=True) - content = "hello worldhello" - - msg_thread = GoThreadInfo() - - def send_data(s: Stream): - with s: - messenger = DefaultMessenger( - upstream=s, - thread=msg_thread, - functional_tokens=[ - FunctionalToken( - name="moss", - token="", - end_token="", - visible=True, - ) - ] - ) - for c in content: - messenger.deliver(Message.new_chunk(content=c)) - flushed = messenger.flush() - assert len(flushed.messages) == 1 - assert len(flushed.callers) == 1 - assert flushed.messages[0].memory is None - - t = Thread(target=send_data, args=(stream,)) - t.start() - - messages = [] - with retriever as items: - for item in items: - done = item.done() - messages.append(done) - assert len(messages) == 1 - assert len(messages[0].callers) == 1 - assert messages[0].callers[0].arguments == "hello" - assert len(msg_thread.last_turn().added[0].callers) == 1 - t.join() diff --git a/tests/test_abc.py b/tests/test_abc.py deleted file mode 100644 index 83dafdb0..00000000 --- a/tests/test_abc.py +++ /dev/null @@ -1,7 +0,0 @@ -from ghostos.identifier import PromptAbleObj, PromptAbleClass -import inspect - - -def test_is_abstract(): - assert inspect.isabstract(PromptAbleObj) - assert inspect.isabstract(PromptAbleClass) diff --git a/tests/test_prompter.py b/tests/test_prompter.py index 40d08fa9..c53c8a82 100644 --- a/tests/test_prompter.py +++ b/tests/test_prompter.py @@ -1,5 +1,11 @@ -from ghostos.prompter import TextPrmt +from ghostos.prompter import TextPrmt, PromptAbleClass, PromptAbleObj from ghostos.container import Container +import inspect + + +def test_is_abstract(): + assert inspect.isabstract(PromptAbleObj) + assert inspect.isabstract(PromptAbleClass) def test_group_prompters(): From d850ea7332c3475beaab2c3d3cabd9e7dfdc1a39 Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 12 Nov 2024 00:44:43 +0800 Subject: [PATCH 069/148] dev: update threads related code --- ghostos/core/runtime/threads.py | 2 -- ghostos/framework/storage/memstorage.py | 1 + ghostos/framework/threads/__init__.py | 2 +- ghostos/framework/threads/storage_threads.py | 19 ++-------- .../framework/threads/test_storage_threads.py | 35 +++++++++++++++++++ 5 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 tests/framework/threads/test_storage_threads.py diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index 6b474671..bdaf53bd 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -32,7 +32,6 @@ class Turn(BaseModel): description="The new messages that generated by ghost during this turn of chat or thinking." "Shall append to messages after updating.", ) - # todo: remove pycontext: PyContext = Field( default_factory=PyContext, description="The PyContext instance", @@ -130,7 +129,6 @@ class GoThreadInfo(BaseModel): description="the current turn", ) - @classmethod def new( cls, diff --git a/ghostos/framework/storage/memstorage.py b/ghostos/framework/storage/memstorage.py index 1bbb48da..e95383ab 100644 --- a/ghostos/framework/storage/memstorage.py +++ b/ghostos/framework/storage/memstorage.py @@ -27,6 +27,7 @@ def get(self, file_path: str) -> bytes: def exists(self, file_path: str) -> bool: key = join(self._namespace, file_path) + key = key.lstrip('/') return key in self._saved def put(self, file_path: str, content: bytes) -> None: diff --git a/ghostos/framework/threads/__init__.py b/ghostos/framework/threads/__init__.py index f31950b1..75553330 100644 --- a/ghostos/framework/threads/__init__.py +++ b/ghostos/framework/threads/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.runtime import GoThreads +from ghostos.core.runtime import GoThreads, GoThreadInfo from ghostos.framework.threads.storage_threads import MsgThreadRepoByStorageProvider, MsgThreadsRepoByWorkSpaceProvider diff --git a/ghostos/framework/threads/storage_threads.py b/ghostos/framework/threads/storage_threads.py index bb8e5d3e..9a8d295b 100644 --- a/ghostos/framework/threads/storage_threads.py +++ b/ghostos/framework/threads/storage_threads.py @@ -38,28 +38,15 @@ def save_thread(self, thread: GoThreadInfo) -> None: path = self._get_thread_filename(thread.id) saving = data_content.encode('utf-8') self._storage.put(path, saving) - # saving to special file - if thread.save_file and self._allow_saving_file: - simple = SimpleMsgThread.from_thread(thread) - simple_data = simple.model_dump(exclude_defaults=True) - content = yaml_pretty_dump(simple_data) - if thread.save_file.startswith('/'): - # saving to absolute path - saving_dir = os.path.dirname(thread.save_file) - if not os.path.exists(saving_dir): - os.makedirs(saving_dir) - with open(thread.save_file, 'wb') as f: - f.write(content.encode('UTF-8')) - else: - # saving to relative path - self._storage.put(thread.save_file, content.encode('UTF-8')) @staticmethod def _get_thread_filename(thread_id: str) -> str: return thread_id + ".thread.yml" def fork_thread(self, thread: GoThreadInfo) -> GoThreadInfo: - return thread.fork() + fork = thread.fork() + self.save_thread(fork) + return fork class MsgThreadRepoByStorageProvider(Provider[GoThreads]): diff --git a/tests/framework/threads/test_storage_threads.py b/tests/framework/threads/test_storage_threads.py new file mode 100644 index 00000000..b812d544 --- /dev/null +++ b/tests/framework/threads/test_storage_threads.py @@ -0,0 +1,35 @@ +from ghostos.framework.threads import MsgThreadRepoByStorageProvider, GoThreads, GoThreadInfo +from ghostos.framework.storage import MemStorage, Storage +from ghostos.framework.logger import FakeLogger, LoggerItf +from ghostos.core.messages import Message +from ghostos.core.moss import PyContext +from ghostos.container import Container + + +def _prepare_container() -> Container: + container = Container() + container.set(Storage, MemStorage()) + container.set(LoggerItf, FakeLogger()) + container.register(MsgThreadRepoByStorageProvider()) + return container + + +def test_threads_baseline(): + thread = GoThreadInfo() + pycontext = PyContext(module=PyContext.__module__) + thread.new_turn(None, pycontext=pycontext) + thread.append(Message.new_tail(content="hello world")) + + tid = thread.id + container = _prepare_container() + threads = container.force_fetch(GoThreads) + threads.save_thread(thread) + + got = threads.get_thread(tid, create=False) + assert got is not None + assert got == thread + + fork = threads.fork_thread(got) + assert fork.id != got.id + assert fork.root_id == got.id + assert fork.parent_id == got.id From bf2e0e1287c0cefb6787fd3ab31202fb54f59954 Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 12 Nov 2024 01:17:26 +0800 Subject: [PATCH 070/148] dev: update tasks and test baseline --- ghostos/contracts/storage.py | 4 + ghostos/core/runtime/tasks.py | 63 ++++++------- ghostos/framework/storage/filestorage.py | 4 + ghostos/framework/storage/memstorage.py | 5 ++ ghostos/framework/tasks/storage_tasks.py | 100 +++++++++++++-------- tests/framework/tasks/test_storage_impl.py | 30 ++++--- 6 files changed, 122 insertions(+), 84 deletions(-) diff --git a/ghostos/contracts/storage.py b/ghostos/contracts/storage.py index cdc29cfb..541a89fd 100644 --- a/ghostos/contracts/storage.py +++ b/ghostos/contracts/storage.py @@ -23,6 +23,10 @@ def get(self, file_path: str) -> bytes: """ pass + @abstractmethod + def remove(self, file_path: str) -> None: + pass + @abstractmethod def exists(self, file_path: str) -> bool: """ diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index e60e2ac0..1a29bdd7 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -1,5 +1,5 @@ import time -from typing import Optional, List, Set, Iterable, ClassVar, Dict, Self +from typing import Optional, List, ClassVar, Dict, Self from abc import ABC, abstractmethod from enum import Enum from pydantic import BaseModel, Field @@ -11,6 +11,7 @@ __all__ = [ 'GoTaskStruct', 'TaskPayload', 'TaskBrief', 'TaskState', + 'TaskLocker', 'GoTasks', ] @@ -41,28 +42,7 @@ class TaskState(str, Enum): @classmethod def is_dead(cls, state: str) -> bool: - return state in {cls.FINISHED, cls.FAILED, cls.CANCELLED, cls.KILLED} - - -# -# class WaitGroup(BaseModel): -# """ -# await group of children tasks that will wake up the task. -# """ -# tasks: Dict[str, bool] = Field(description="children task ids to wait") -# -# def is_done(self) -> bool: -# for _, ok in self.tasks.items(): -# if not ok: -# return False -# return True - - -class AssistantInfo(Identifier, BaseModel): - id: str = Field(description="id of the assistant") - name: str = Field(description="name of the assistant") - description: str = Field(description="description of the assistant") - meta_prompt: str = Field(description="meta prompt of the assistant") + return state in {cls.FINISHED, cls.FAILED, cls.CANCELLED} class GoTaskStruct(BaseModel): @@ -99,6 +79,10 @@ class GoTaskStruct(BaseModel): meta: EntityMeta = Field( description="the entity meta of the task handler", ) + context: Optional[EntityMeta] = Field( + default=None, + description="the context entity", + ) state: str = Field( default=TaskState.NEW.value, @@ -124,11 +108,12 @@ class GoTaskStruct(BaseModel): # --- brief --- # name: str = Field( - description="The name of the task. not very important" + description="The name of the task" ) - purpose: str = Field( - description="The description of the task purpose" + description: str = Field( + description="The description of the task" ) + priority: float = Field( default=0.0, description="The priority of the task", @@ -154,9 +139,6 @@ class GoTaskStruct(BaseModel): ) # --- system --- # - lock: Optional[str] = Field( - default=None, - ) turns: int = Field( default=0, @@ -167,22 +149,24 @@ class GoTaskStruct(BaseModel): def new( cls, *, task_id: str, - shell_id: str, process_id: str, name: str, description: str, meta: EntityMeta, + context: Optional[EntityMeta] = None, parent_task_id: Optional[str] = None, + priority: float = 0.0, ) -> "GoTaskStruct": return GoTaskStruct( task_id=task_id, - shell_id=shell_id, process_id=process_id, thread_id=task_id, parent=parent_task_id, meta=meta, + context=context, name=name, description=description, + priority=priority, ) def add_child( @@ -191,15 +175,16 @@ def add_child( name: str, description: str, meta: EntityMeta, + context: Optional[EntityMeta] = None, ) -> "GoTaskStruct": self.children.append(task_id) child = self.new( task_id=task_id, - shell_id=self.shell_id, process_id=self.process_id, name=name, description=description, meta=meta, + context=context, parent_task_id=self.task_id, ) child.depth = self.depth + 1 @@ -238,7 +223,6 @@ def new_turn(self) -> Self: update={ "updated": round(time.time(), 4), "turns": self.turns + 1, - }, deep=True, ) @@ -247,7 +231,7 @@ def new_turn(self) -> Self: class TaskBrief(BaseModel, Identical): task_id: str = Field(description="the id of the task") name: str = Field(description="the name of the task") - purpose: str = Field(description="the purpose of the task") + description: str = Field(description="the purpose of the task") state: str = Field(description="the state of the task") status_desc: str = Field(description="the description of the task status") @@ -293,6 +277,10 @@ class TaskLocker(ABC): def acquire(self) -> bool: pass + @abstractmethod + def acquired(self) -> bool: + pass + @abstractmethod def refresh(self) -> bool: pass @@ -301,6 +289,13 @@ def refresh(self) -> bool: def release(self) -> bool: pass + def __enter__(self) -> bool: + return self.acquire() + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.acquired(): + self.release() + class GoTasks(ABC): """ diff --git a/ghostos/framework/storage/filestorage.py b/ghostos/framework/storage/filestorage.py index 597a162e..dc99acf4 100644 --- a/ghostos/framework/storage/filestorage.py +++ b/ghostos/framework/storage/filestorage.py @@ -24,6 +24,10 @@ def get(self, file_path: str) -> bytes: with open(file_path, 'rb') as f: return f.read() + def remove(self, file_path: str) -> None: + file_path = self._join_file_path(file_path) + os.remove(file_path) + def exists(self, file_path: str) -> bool: file_path = self._join_file_path(file_path) return os.path.exists(file_path) diff --git a/ghostos/framework/storage/memstorage.py b/ghostos/framework/storage/memstorage.py index e95383ab..c4a20e45 100644 --- a/ghostos/framework/storage/memstorage.py +++ b/ghostos/framework/storage/memstorage.py @@ -30,6 +30,11 @@ def exists(self, file_path: str) -> bool: key = key.lstrip('/') return key in self._saved + def remove(self, file_path: str) -> None: + key = join(self._namespace, file_path) + key = key.lstrip('/') + del self._saved[key] + def put(self, file_path: str, content: bytes) -> None: key = join(self._namespace, file_path) key = key.lstrip('/') diff --git a/ghostos/framework/tasks/storage_tasks.py b/ghostos/framework/tasks/storage_tasks.py index c63f89ac..56ed6a2c 100644 --- a/ghostos/framework/tasks/storage_tasks.py +++ b/ghostos/framework/tasks/storage_tasks.py @@ -1,27 +1,82 @@ -from typing import Optional, List, Iterable, Dict, Type +import time +from typing import Optional, List, Iterable, Dict, Type, TypedDict import yaml from ghostos.core.runtime import TaskState, TaskBrief, GoTaskStruct, GoTasks from ghostos.contracts.workspace import Workspace from ghostos.contracts.logger import LoggerItf from ghostos.contracts.storage import Storage from ghostos.container import Provider, Container +from ghostos.core.runtime.tasks import TaskLocker from ghostos.helpers import uuid __all__ = ['StorageGoTasksImpl', 'StorageTasksImplProvider', 'WorkspaceTasksProvider'] +class SimpleStorageLocker(TaskLocker): + class LockData(TypedDict): + lock_id: str + created: float + + def __init__(self, storage: Storage, task_id: str): + self.task_id = task_id + self.storage = storage + self.lock_id = uuid() + self._acquired = False + + def acquire(self) -> bool: + filename = self.locker_file_name() + if self.storage.exists(filename): + content = self.storage.get(filename) + data = yaml.safe_load(content) + lock = self.LockData(**data) + now = time.time() + if lock['lock_id'] == self.lock_id or now - float(lock["created"]) > 100: + self.create_lock() + return True + return False + + self.create_lock() + return True + + def acquired(self) -> bool: + return self._acquired + + def create_lock(self) -> None: + filename = self.locker_file_name() + lock = self.LockData(lock_id=self.lock_id, created=time.time()) + content = yaml.safe_dump(lock) + self.storage.put(filename, content.encode()) + self._acquired = True + + def locker_file_name(self) -> str: + return f'{self.task_id}.lock' + + def refresh(self) -> bool: + if not self._acquired: + return False + return self.acquire() + + def release(self) -> bool: + if not self._acquired: + return False + filename = self.locker_file_name() + if self.refresh(): + self.storage.remove(filename) + return True + return False + + class StorageGoTasksImpl(GoTasks): def __init__(self, storage: Storage, logger: LoggerItf): self._storage = storage self._logger = logger - self._locks: Dict[str, str] = {} def save_task(self, *tasks: GoTaskStruct) -> None: for task in tasks: filename = self._get_task_filename(task.task_id) - content = yaml.safe_dump(task.model_dump(exclude_defaults=True)) - # todo: 正确的做法要先 check lock. + data = task.model_dump(exclude_defaults=True) + content = yaml.safe_dump(data) self._storage.put(filename, content.encode('utf-8')) @staticmethod @@ -41,24 +96,13 @@ def exists(self, task_id: str) -> bool: filename = self._get_task_filename(task_id) return self._storage.exists(filename) - def get_task(self, task_id: str, lock: bool) -> Optional[GoTaskStruct]: - task = self._get_task(task_id) - if task is None: - return None - if lock: - if task.lock: - return None - task.lock = uuid() - self.save_task(task) - return task - else: - task.lock = None - return task + def get_task(self, task_id: str) -> Optional[GoTaskStruct]: + return self._get_task(task_id) def get_tasks(self, task_ids: List[str], states: Optional[List[TaskState]] = None) -> Iterable[GoTaskStruct]: states = set(states) if states else None for task_id in task_ids: - task = self.get_task(task_id, lock=False) + task = self.get_task(task_id) if states and task.state not in states: continue yield task @@ -67,24 +111,8 @@ def get_task_briefs(self, task_ids: List[str], states: Optional[List[TaskState]] for task in self.get_tasks(task_ids, states): yield TaskBrief.from_task(task) - def unlock_task(self, task_id: str, lock: str) -> None: - task = self._get_task(task_id) - if task is None: - return - if task.lock == lock: - task.lock = None - self.save_task(task) - - def refresh_task_lock(self, task_id: str, lock: str) -> Optional[str]: - task = self._get_task(task_id) - if task is None: - return uuid() - if task.lock or task.lock == lock: - lock = uuid() - task.lock = lock - self.save_task(task) - return lock - return None + def lock_task(self, task_id: str) -> TaskLocker: + return SimpleStorageLocker(self._storage, task_id) class StorageTasksImplProvider(Provider[GoTasks]): diff --git a/tests/framework/tasks/test_storage_impl.py b/tests/framework/tasks/test_storage_impl.py index 4f58ae4a..edb6d16e 100644 --- a/tests/framework/tasks/test_storage_impl.py +++ b/tests/framework/tasks/test_storage_impl.py @@ -1,7 +1,7 @@ from ghostos.framework.storage import MemStorage from ghostos.framework.tasks.storage_tasks import StorageGoTasksImpl from ghostos.framework.logger import FakeLogger -from ghostos.core.runtime import GoTaskStruct +from ghostos.core.runtime import GoTaskStruct, TaskBrief from ghostos.entity import EntityMeta @@ -10,28 +10,30 @@ def test_storage_tasks_impl(): tasks = StorageGoTasksImpl(storage, FakeLogger()) task = GoTaskStruct.new( task_id="task_id", - shell_id="session_id", process_id="process_id", name="name", description="description", meta=EntityMeta(type="type", content=""), ) - t = tasks.get_task(task.task_id, False) + t = tasks.get_task(task.task_id) assert t is None tasks.save_task(task) - t = tasks.get_task(task.task_id, False) + t = tasks.get_task(task.task_id) assert t is not None - assert t.lock is None - locked = tasks.get_task(task.task_id, True) - assert locked.lock is not None - locked2 = tasks.get_task(task.task_id, True) - assert locked2 is None - tasks.unlock_task(locked.task_id, locked.lock) + with tasks.lock_task(task.task_id): + locker = tasks.lock_task(task.task_id) + new_turn = task.new_turn() + tasks.save_task(new_turn) + assert locker.acquire() is False - locked2 = tasks.get_task(task.task_id, True) - assert locked2.lock is not None + locker = tasks.lock_task(task.task_id) + assert locker.acquire() is True + locker.release() - new_lock = tasks.refresh_task_lock(locked2.task_id, locked2.lock) - assert new_lock is not locked2.lock + new_got = tasks.get_task(task.task_id) + assert new_got != task + assert new_got == new_turn + + assert TaskBrief.from_task(task) == TaskBrief.from_task(new_got) From 8d79eef5ad1cf43dc702c622680fa856a0ea2f2a Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 12 Nov 2024 01:34:58 +0800 Subject: [PATCH 071/148] fix: fix all the test cases --- ghostos/bootstrap.py | 4 +- ghostos/core/moss/abcd.py | 4 + ghostos/core/moss/impl.py | 28 +- ghostos/core/runtime/__init__.py | 2 +- .../{simple_thread.py => thread_history.py} | 4 +- ghostos/framework/llms/__init__.py | 2 +- ghostos/framework/llms/providers.py | 8 +- ghostos/framework/threads/storage_threads.py | 2 +- ghostos/prompter.py | 6 +- tests/core/moss/examples/test_baseline.py | 4 +- tests/framework/llms/test_llms_config.py | 3 +- tests/framework/messages/test_buffer.py | 491 +++++++++--------- tests/test_container.py | 4 - tests/test_prompter.py | 1 - 14 files changed, 288 insertions(+), 275 deletions(-) rename ghostos/core/runtime/{simple_thread.py => thread_history.py} (95%) diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 50d96145..4fb4e3f3 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -160,7 +160,7 @@ def default_application_providers( from ghostos.framework.threads import MsgThreadsRepoByWorkSpaceProvider from ghostos.framework.tasks import WorkspaceTasksProvider from ghostos.framework.eventbuses import MemEventBusImplProvider - from ghostos.framework.llms import ConfigBasedLLMsProvider, PromptStorageProvider + from ghostos.framework.llms import ConfigBasedLLMsProvider, PromptStorageInWorkspaceProvider from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.entities import EntityFactoryProvider from ghostos.core.aifunc import DefaultAIFuncExecutorProvider, AIFuncRepoByConfigsProvider @@ -191,7 +191,7 @@ def default_application_providers( # --- llm --- # ConfigBasedLLMsProvider(llms_conf_path), - PromptStorageProvider(), + PromptStorageInWorkspaceProvider(), # --- basic library --- # EntityFactoryProvider(), diff --git a/ghostos/core/moss/abcd.py b/ghostos/core/moss/abcd.py index f1338cc7..6127185b 100644 --- a/ghostos/core/moss/abcd.py +++ b/ghostos/core/moss/abcd.py @@ -334,6 +334,10 @@ def dump_code_context(self) -> str: return fn(self) return __moss_code_context__(self) + @abstractmethod + def moss_injections_prompt(self) -> str: + pass + class MossRuntime(ABC): @abstractmethod diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index 197abd1e..b87808eb 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -1,7 +1,6 @@ import inspect from types import ModuleType -from abc import abstractmethod -from typing import Optional, Any, Dict, get_type_hints, Type, List, Protocol +from typing import Optional, Any, Dict, get_type_hints, Type, List import io from ghostos.container import Container, Provider @@ -11,10 +10,8 @@ MossCompiler, MossRuntime, MossPrompter, MOSS_VALUE_NAME, MOSS_TYPE_NAME, MOSS_HIDDEN_MARK, MOSS_HIDDEN_UNMARK, ) -from ghostos.core.moss.prompts import get_defined_prompt -from ghostos.core.moss.utils import add_comment_mark from ghostos.core.moss.pycontext import PyContext -from ghostos.prompter import Prompter +from ghostos.prompter import Prompter, TextPrmt from ghostos.helpers import generate_module_spec, code_syntax_check from contextlib import contextmanager, redirect_stdout @@ -259,11 +256,26 @@ def pycontext_code( def moss_injections(self) -> Dict[str, Any]: moss = self.moss() - prompters = {} + injections = {} for name in self._injected: injection = getattr(moss, name) - prompters[name] = injection - return prompters + injections[name] = injection + return injections + + def moss_injections_prompt(self) -> str: + injections = self.moss_injections() + children = [] + container = self.container() + for name, injection in injections.items(): + if isinstance(injection, Prompter): + children.append(TextPrmt( + title=f"moss.{name}", + content=injection.self_prompt(container), + )) + prompter = TextPrmt( + title="Moss Injections", + ).with_children(*children) + return prompter.get_prompt(container) @staticmethod def _parse_pycontext_code(code: str, exclude_hide_code: bool = True) -> str: diff --git a/ghostos/core/runtime/__init__.py b/ghostos/core/runtime/__init__.py index 163dc28c..2a54db03 100644 --- a/ghostos/core/runtime/__init__.py +++ b/ghostos/core/runtime/__init__.py @@ -6,5 +6,5 @@ from ghostos.core.runtime.processes import GoProcess, GoProcesses from ghostos.core.runtime.messenger import Messenger, Buffed from ghostos.core.runtime.events import Event, EventBus, EventTypes -from ghostos.core.runtime.simple_thread import SimpleMsgThread +from ghostos.core.runtime.thread_history import ThreadHistory from ghostos.core.runtime.runtime import Runtime diff --git a/ghostos/core/runtime/simple_thread.py b/ghostos/core/runtime/thread_history.py similarity index 95% rename from ghostos/core/runtime/simple_thread.py rename to ghostos/core/runtime/thread_history.py index b87ba438..03991872 100644 --- a/ghostos/core/runtime/simple_thread.py +++ b/ghostos/core/runtime/thread_history.py @@ -46,14 +46,14 @@ def from_turn(cls, turn: Turn, idx: int = 0) -> Optional["SimpleTurn"]: ) -class SimpleMsgThread(BaseModel): +class ThreadHistory(BaseModel): thread_id: str = Field(description="thread id that useful to save & read thread") extra: Dict[str, Any] = Field(default_factory=dict) last_turn_system_prompt: str = Field(defualt="", description="system prompt") turns: List[SimpleTurn] = Field(default_factory=list) @classmethod - def from_thread(cls, thread: GoThreadInfo) -> "SimpleMsgThread": + def from_thread(cls, thread: GoThreadInfo) -> "ThreadHistory": turns = [] idx = 0 for turn in thread.turns(): diff --git a/ghostos/framework/llms/__init__.py b/ghostos/framework/llms/__init__.py index 6c256bf1..2602ead4 100644 --- a/ghostos/framework/llms/__init__.py +++ b/ghostos/framework/llms/__init__.py @@ -1,5 +1,5 @@ from ghostos.core.llms import LLMs, Prompt, PromptStorage from ghostos.framework.llms.llms import LLMsImpl from ghostos.framework.llms.openai_driver import OpenAIDriver, OpenAIAdapter, LitellmAdapter -from ghostos.framework.llms.providers import ConfigBasedLLMsProvider, PromptStorageProvider +from ghostos.framework.llms.providers import ConfigBasedLLMsProvider, PromptStorageInWorkspaceProvider from ghostos.framework.llms.prompt_storage_impl import PromptStorageImpl diff --git a/ghostos/framework/llms/providers.py b/ghostos/framework/llms/providers.py index d3eda70f..4abf90b6 100644 --- a/ghostos/framework/llms/providers.py +++ b/ghostos/framework/llms/providers.py @@ -1,17 +1,17 @@ from typing import Type, Optional from ghostos.contracts.configs import YamlConfig, Configs -from ghostos.container import BootstrapProvider, Provider, Container +from ghostos.container import Provider, Container from ghostos.core.llms import LLMs, LLMsConfig, PromptStorage from ghostos.framework.llms.llms import LLMsImpl from ghostos.framework.llms.openai_driver import OpenAIDriver, LiteLLMDriver from ghostos.framework.llms.prompt_storage_impl import PromptStorageImpl from ghostos.contracts.workspace import Workspace -__all__ = ['ConfigBasedLLMsProvider', 'PromptStorageProvider'] +__all__ = ['ConfigBasedLLMsProvider', 'PromptStorageInWorkspaceProvider'] -class ConfigBasedLLMsProvider(BootstrapProvider[LLMs]): +class ConfigBasedLLMsProvider(Provider[LLMs]): """ 基于 Config 来读取 """ @@ -48,7 +48,7 @@ class LLMsYamlConfig(YamlConfig, LLMsConfig): return llms -class PromptStorageProvider(Provider[PromptStorage]): +class PromptStorageInWorkspaceProvider(Provider[PromptStorage]): def __init__(self, relative_path: str = "prompts"): self._relative_path = relative_path diff --git a/ghostos/framework/threads/storage_threads.py b/ghostos/framework/threads/storage_threads.py index 9a8d295b..508ff817 100644 --- a/ghostos/framework/threads/storage_threads.py +++ b/ghostos/framework/threads/storage_threads.py @@ -1,5 +1,5 @@ from typing import Optional, Type -from ghostos.core.runtime import GoThreadInfo, GoThreads, SimpleMsgThread +from ghostos.core.runtime import GoThreadInfo, GoThreads, ThreadHistory from ghostos.contracts.workspace import Workspace from ghostos.contracts.storage import Storage from ghostos.contracts.logger import LoggerItf diff --git a/ghostos/prompter.py b/ghostos/prompter.py index e7d06048..3dc18a80 100644 --- a/ghostos/prompter.py +++ b/ghostos/prompter.py @@ -146,15 +146,15 @@ def get_prompt(self, container: Container, depth: int = 0) -> str: if paragraph: output += "\n\n" + paragraph self.__self_prompt__ = output.strip() - self.__children__ = None return self.__self_prompt__ def __to_entity_meta__(self) -> EntityMeta: type_ = generate_import_path(self.__class__) ctx_data = self.model_dump(exclude_defaults=True) children_data = [] - for child in self.__children__: - children_data.append(to_entity_meta(child)) + if self.__children__ is not None: + for child in self.__children__: + children_data.append(to_entity_meta(child)) data = {"ctx": ctx_data, "children": children_data} content = json.dumps(data) return EntityMeta(type=type_, content=content) diff --git a/tests/core/moss/examples/test_baseline.py b/tests/core/moss/examples/test_baseline.py index 31b9626d..f3b4fcc2 100644 --- a/tests/core/moss/examples/test_baseline.py +++ b/tests/core/moss/examples/test_baseline.py @@ -47,8 +47,8 @@ def test_baseline_exec(): assert prompter is not None prompt = prompter.dump_code_context() - prompters = prompter.moss_injected() - assert "tester" in prompters + injection_prompt = prompter.moss_injections_prompt() + assert "tester" in injection_prompt # plus 方法存在. assert 'def plus' in prompt diff --git a/tests/framework/llms/test_llms_config.py b/tests/framework/llms/test_llms_config.py index 4ae489a6..ecf7b729 100644 --- a/tests/framework/llms/test_llms_config.py +++ b/tests/framework/llms/test_llms_config.py @@ -7,7 +7,7 @@ from ghostos.contracts.configs import YamlConfig, Configs from ghostos.framework.configs import ConfigsByStorageProvider from ghostos.framework.storage import MemStorage, Storage -from ghostos.framework.llms import ConfigBasedLLMsProvider +from ghostos.framework.llms import ConfigBasedLLMsProvider, PromptStorage, PromptStorageImpl def _prepare_container() -> Container: @@ -15,6 +15,7 @@ def _prepare_container() -> Container: storage = MemStorage() container.set(Storage, storage) container.register(ConfigsByStorageProvider('configs')) + container.set(PromptStorage, PromptStorageImpl(storage.sub_storage("prompts"))) data = LLMsConfig( services=[ diff --git a/tests/framework/messages/test_buffer.py b/tests/framework/messages/test_buffer.py index 234ff449..b9918a4a 100644 --- a/tests/framework/messages/test_buffer.py +++ b/tests/framework/messages/test_buffer.py @@ -1,245 +1,246 @@ -from ghostos.core.messages import ( - Message -) -from ghostos.core.llms import FunctionalToken -from ghostos.framework.messages import DefaultBuffer - - -def test_default_buffer_baseline(): - buffer = DefaultBuffer() - buffer2 = DefaultBuffer() - - content1 = "hello" - content2 = "world" - - msg1 = Message.new_head() - sent = buffer.add(msg1) - i = 0 - for item in sent: - buffer2.add(item) - i += 1 - # 空首包也发送, 对齐 moonshot 协议. - assert i == 1 - - for c in content1: - pack = Message.new_chunk(content=c) - sent = buffer.add(pack) - for item in sent: - buffer2.add(item) - - buffed = buffer.flush() - assert len(buffed.messages) == 1 - assert buffed.messages[0].content == content1 - assert buffed.messages[0].memory is None - - new_head = Message.new_head() - buffer2.add(new_head) - - for c in content2: - pack = Message.new_chunk(content=c) - buffer2.add(pack) - - buffed = buffer2.flush() - print(buffed) - assert len(buffed.messages) == 2 - - -def test_functional_token_baseline(): - buffer = DefaultBuffer( - functional_tokens=[ - FunctionalToken(token=":moss>", name="moss", description="desc", deliver=False) - ] - ) - - content = """ -hello -:moss> -world -""" - - for c in content: - msg = Message.new_chunk(content=c) - buffer.add(msg) - - flushed = buffer.flush() - assert len(flushed.messages) == 1 - assert len(flushed.callers) == 1 - assert flushed.callers[0].name == "moss" - assert flushed.callers[0].arguments == "\nworld\n" - assert flushed.messages[0].content == "\nhello\n" - - -def test_buffer_sent(): - buffer = DefaultBuffer() - content = "hello world" - count = 0 - count_has_message_id = 0 - - for c in content: - msg = Message.new_chunk(content=c) - sent = buffer.add(msg) - for i in sent: - assert not i.is_empty() - if i.msg_id: - count_has_message_id += 1 - count += 1 - assert count == len(content) - assert count_has_message_id == count - assert len(buffer.flush().messages) == 1 - - -def test_buffer_sent_one_tail(): - buffer = DefaultBuffer() - content = "hello world" - tails = 0 - for c in content: - msg = Message.new_chunk(content=c) - sent = buffer.add(msg) - for i in sent: - if not i.chunk: - tails += 1 - buffed = buffer.flush() - for i in buffed.unsent: - if not i.chunk: - tails += 1 - assert tails == 1 - - -def test_buffer_with_moss_token(): - data = '''{ -"msg_id": "e28c37c8-4292-4c5e-8c22-25b85fd65af3", -"created": 1722267720.0, -"pack": false, -"content": "" -}''' - import json - j = json.loads(data) - message = Message(**j) - assert message.content is not None - - buffer = DefaultBuffer( - functional_tokens=[FunctionalToken(token=">moss:", name="moss", description="desc", deliver=False)] - ) - - content = "好的,我会帮你播放这首歌。\n\n>moss:\ndef main(os: MOSS) -> Operator:\n # Search for the song \"七里香\" by 周杰伦\n song_list = os.player.search(\"\", \"周杰伦\", \"七里香\")\n \n # Check if the song is found\n if \"七里香\" in song_list:\n # Play the song\n playing = os.player.play(\"七里香\")\n \n # Check if the song is playing\n if playing:\n return\n os.mindflow.finish(\"正在播放周杰伦的《七里香》。\")\n else:\n return os.mindflow.fail(\"无法播放周杰伦的《七里香》。\")\n else:\n return os.mindflow.fail(\"未找到周杰伦的《七里香》。\")" - for c in content: - p = Message.new_chunk(content=c) - buffer.add(p) - buffed = buffer.flush() - assert len(buffed.messages) == 1 - assert len(buffed.callers) == 1 - - -def test_buffer_with_sep_content(): - functional_tokens = [FunctionalToken( - token=">moss:", - name="moss", - description="desc", - deliver=False, - )] - - buffer = DefaultBuffer(functional_tokens=functional_tokens) - - contents = ["he", "llo >mo", "ss: w", "orld"] - content = "".join(contents) - for c in contents: - msg = Message.new_chunk(content=c) - buffer.add(msg) - flushed = buffer.flush() - assert len(flushed.messages) == 1 - assert len(list(flushed.callers)) > 0 - message = flushed.messages[0] - assert message.content == "hello " - assert message.memory == content - caller = flushed.callers[0] - assert caller.name == "moss" - assert caller.arguments == " world" - - unsent = list(flushed.unsent) - assert len(unsent) == 1 - assert unsent[0].content == "hello " - assert unsent[0].memory == content - assert len(unsent[0].callers) == 1 - - -def test_buffer_with_tail_item(): - buffer = DefaultBuffer() - header = Message.new_head(content="") - buffer.add(header) - content = "hello" - for c in content: - msg = Message.new_chunk(content=c) - buffer.add(msg) - tail = Message.new_tail(content="hello world", msg_id=header.msg_id) - buffer.add(tail) - flushed = buffer.flush() - assert len(flushed.messages) == 1 - assert flushed.messages[0].content == "hello world" - - -def test_buffer_header_with_payload(): - buffer = DefaultBuffer() - header = Message.new_head(content="") - header.payloads["foo"] = {} - buffer.add(header) - content = "hello" - buffer.add(Message.new_chunk(content="")) - for c in content: - msg = Message.new_chunk(content=c) - buffer.add(msg) - flushed = buffer.flush() - assert len(flushed.messages) == 1 - assert flushed.messages[0].content == "hello" - - -def test_buffer_with_xml_functional_token(): - functional_tokens = [FunctionalToken( - token="", - end_token="", - name="moss", - description="desc", - deliver=False, - )] - - buffer = DefaultBuffer(functional_tokens=functional_tokens) - contents = ["he", "llo w", "orld'] - content = "".join(contents) - for c in contents: - msg = Message.new_chunk(content=c) - buffer.add(msg) - flushed = buffer.flush() - assert len(flushed.messages) == 1 - assert len(list(flushed.callers)) > 0 - message = flushed.messages[0] - assert message.content == "hello " - assert message.memory == content - caller = flushed.callers[0] - assert caller.name == "moss" - assert caller.arguments == "world" - - -def test_buffer_with_visible_functional_token(): - functional_tokens = [FunctionalToken( - token="", - end_token="", - name="moss", - description="desc", - visible=True, - deliver=False, - )] - - buffer = DefaultBuffer(functional_tokens=functional_tokens) - contents = ["he", "llo w", "orld'] - content = "".join(contents) - for c in contents: - msg = Message.new_chunk(content=c) - buffer.add(msg) - flushed = buffer.flush() - assert len(flushed.messages) == 1 - assert len(list(flushed.callers)) > 0 - message = flushed.messages[0] - assert message.content == content - assert message.memory is None - caller = flushed.callers[0] - assert caller.name == "moss" - assert caller.arguments == "world" +# deprecated +# from ghostos.core.messages import ( +# Message +# ) +# from ghostos.core.llms import FunctionalToken +# from ghostos.framework.messages import DefaultBuffer +# +# +# def test_default_buffer_baseline(): +# buffer = DefaultBuffer() +# buffer2 = DefaultBuffer() +# +# content1 = "hello" +# content2 = "world" +# +# msg1 = Message.new_head() +# sent = buffer.add(msg1) +# i = 0 +# for item in sent: +# buffer2.add(item) +# i += 1 +# # 空首包也发送, 对齐 moonshot 协议. +# assert i == 1 +# +# for c in content1: +# pack = Message.new_chunk(content=c) +# sent = buffer.add(pack) +# for item in sent: +# buffer2.add(item) +# +# buffed = buffer.flush() +# assert len(buffed.messages) == 1 +# assert buffed.messages[0].content == content1 +# assert buffed.messages[0].memory is None +# +# new_head = Message.new_head() +# buffer2.add(new_head) +# +# for c in content2: +# pack = Message.new_chunk(content=c) +# buffer2.add(pack) +# +# buffed = buffer2.flush() +# print(buffed) +# assert len(buffed.messages) == 2 +# +# +# def test_functional_token_baseline(): +# buffer = DefaultBuffer( +# functional_tokens=[ +# FunctionalToken(token=":moss>", name="moss", description="desc", deliver=False) +# ] +# ) +# +# content = """ +# hello +# :moss> +# world +# """ +# +# for c in content: +# msg = Message.new_chunk(content=c) +# buffer.add(msg) +# +# flushed = buffer.flush() +# assert len(flushed.messages) == 1 +# assert len(flushed.callers) == 1 +# assert flushed.callers[0].name == "moss" +# assert flushed.callers[0].arguments == "\nworld\n" +# assert flushed.messages[0].content == "\nhello\n" +# +# +# def test_buffer_sent(): +# buffer = DefaultBuffer() +# content = "hello world" +# count = 0 +# count_has_message_id = 0 +# +# for c in content: +# msg = Message.new_chunk(content=c) +# sent = buffer.add(msg) +# for i in sent: +# assert not i.is_empty() +# if i.msg_id: +# count_has_message_id += 1 +# count += 1 +# assert count == len(content) +# assert count_has_message_id == count +# assert len(buffer.flush().messages) == 1 +# +# +# def test_buffer_sent_one_tail(): +# buffer = DefaultBuffer() +# content = "hello world" +# tails = 0 +# for c in content: +# msg = Message.new_chunk(content=c) +# sent = buffer.add(msg) +# for i in sent: +# if not i.chunk: +# tails += 1 +# buffed = buffer.flush() +# for i in buffed.unsent: +# if not i.chunk: +# tails += 1 +# assert tails == 1 +# +# +# def test_buffer_with_moss_token(): +# data = '''{ +# "msg_id": "e28c37c8-4292-4c5e-8c22-25b85fd65af3", +# "created": 1722267720.0, +# "pack": false, +# "content": "" +# }''' +# import json +# j = json.loads(data) +# message = Message(**j) +# assert message.content is not None +# +# buffer = DefaultBuffer( +# functional_tokens=[FunctionalToken(token=">moss:", name="moss", description="desc", deliver=False)] +# ) +# +# content = "好的,我会帮你播放这首歌。\n\n>moss:\ndef main(os: MOSS) -> Operator:\n # Search for the song \"七里香\" by 周杰伦\n song_list = os.player.search(\"\", \"周杰伦\", \"七里香\")\n \n # Check if the song is found\n if \"七里香\" in song_list:\n # Play the song\n playing = os.player.play(\"七里香\")\n \n # Check if the song is playing\n if playing:\n return\n os.mindflow.finish(\"正在播放周杰伦的《七里香》。\")\n else:\n return os.mindflow.fail(\"无法播放周杰伦的《七里香》。\")\n else:\n return os.mindflow.fail(\"未找到周杰伦的《七里香》。\")" +# for c in content: +# p = Message.new_chunk(content=c) +# buffer.add(p) +# buffed = buffer.flush() +# assert len(buffed.messages) == 1 +# assert len(buffed.callers) == 1 +# +# +# def test_buffer_with_sep_content(): +# functional_tokens = [FunctionalToken( +# token=">moss:", +# name="moss", +# description="desc", +# deliver=False, +# )] +# +# buffer = DefaultBuffer(functional_tokens=functional_tokens) +# +# contents = ["he", "llo >mo", "ss: w", "orld"] +# content = "".join(contents) +# for c in contents: +# msg = Message.new_chunk(content=c) +# buffer.add(msg) +# flushed = buffer.flush() +# assert len(flushed.messages) == 1 +# assert len(list(flushed.callers)) > 0 +# message = flushed.messages[0] +# assert message.content == "hello " +# assert message.memory == content +# caller = flushed.callers[0] +# assert caller.name == "moss" +# assert caller.arguments == " world" +# +# unsent = list(flushed.unsent) +# assert len(unsent) == 1 +# assert unsent[0].content == "hello " +# assert unsent[0].memory == content +# assert len(unsent[0].callers) == 1 +# +# +# def test_buffer_with_tail_item(): +# buffer = DefaultBuffer() +# header = Message.new_head(content="") +# buffer.add(header) +# content = "hello" +# for c in content: +# msg = Message.new_chunk(content=c) +# buffer.add(msg) +# tail = Message.new_tail(content="hello world", msg_id=header.msg_id) +# buffer.add(tail) +# flushed = buffer.flush() +# assert len(flushed.messages) == 1 +# assert flushed.messages[0].content == "hello world" +# +# +# def test_buffer_header_with_payload(): +# buffer = DefaultBuffer() +# header = Message.new_head(content="") +# header.payloads["foo"] = {} +# buffer.add(header) +# content = "hello" +# buffer.add(Message.new_chunk(content="")) +# for c in content: +# msg = Message.new_chunk(content=c) +# buffer.add(msg) +# flushed = buffer.flush() +# assert len(flushed.messages) == 1 +# assert flushed.messages[0].content == "hello" +# +# +# def test_buffer_with_xml_functional_token(): +# functional_tokens = [FunctionalToken( +# token="", +# end_token="", +# name="moss", +# description="desc", +# deliver=False, +# )] +# +# buffer = DefaultBuffer(functional_tokens=functional_tokens) +# contents = ["he", "llo w", "orld'] +# content = "".join(contents) +# for c in contents: +# msg = Message.new_chunk(content=c) +# buffer.add(msg) +# flushed = buffer.flush() +# assert len(flushed.messages) == 1 +# assert len(list(flushed.callers)) > 0 +# message = flushed.messages[0] +# assert message.content == "hello " +# assert message.memory == content +# caller = flushed.callers[0] +# assert caller.name == "moss" +# assert caller.arguments == "world" +# +# +# def test_buffer_with_visible_functional_token(): +# functional_tokens = [FunctionalToken( +# token="", +# end_token="", +# name="moss", +# description="desc", +# visible=True, +# deliver=False, +# )] +# +# buffer = DefaultBuffer(functional_tokens=functional_tokens) +# contents = ["he", "llo w", "orld'] +# content = "".join(contents) +# for c in contents: +# msg = Message.new_chunk(content=c) +# buffer.add(msg) +# flushed = buffer.flush() +# assert len(flushed.messages) == 1 +# assert len(list(flushed.callers)) > 0 +# message = flushed.messages[0] +# assert message.content == content +# assert message.memory is None +# caller = flushed.callers[0] +# assert caller.name == "moss" +# assert caller.arguments == "world" diff --git a/tests/test_container.py b/tests/test_container.py index cfaf4997..3ae3f23b 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -104,9 +104,6 @@ class SomeProvider(Provider[int]): def singleton(self) -> bool: return True - def contract(self) -> ABSTRACT: - return self.get_instance_type() - def factory(self, con: Container) -> int: return 3 @@ -120,4 +117,3 @@ def factory(self, con: Container) -> int: assert p.singleton() assert p.factory(con) == 3 assert p.contract() is int - diff --git a/tests/test_prompter.py b/tests/test_prompter.py index c53c8a82..018770b7 100644 --- a/tests/test_prompter.py +++ b/tests/test_prompter.py @@ -25,4 +25,3 @@ def test_group_prompters(): assert "\n### 1.2.2\n" in p # test buffer is ok assert p == prompter.get_prompt(c) - assert prompter.__children__ is None From 94bdf8c9cd9627e112ebcad6377cb448330c02ae Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 12 Nov 2024 16:24:03 +0800 Subject: [PATCH 072/148] dev: define subclass of prompter for flexibilities --- ghostos/core/abcd/concepts.py | 68 ++++++++---- ghostos/core/moss/decorators.py | 12 +-- ghostos/core/moss/examples/baseline.py | 4 +- ghostos/core/moss/prompts.py | 16 ++- ghostos/framework/session/default.py | 85 +++++++++++++++ ghostos/helpers/modules.py | 2 +- ghostos/prompter.py | 142 ++++++++++++++++++++++--- tests/test_prompter.py | 14 ++- 8 files changed, 291 insertions(+), 52 deletions(-) create mode 100644 ghostos/framework/session/default.py diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index bfcf79b2..ef91479e 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from ghostos.identifier import Identifiable from ghostos.entity import EntityType -from ghostos.prompter import Prompter +from ghostos.prompter import Prompter, DataPrompter, DataPrompterDriver from ghostos.core.runtime import ( TaskState, ) @@ -16,7 +16,8 @@ from ghostos.core.runtime.threads import GoThreadInfo from ghostos.core.messages import MessageKind, Message, Stream, Caller, Payload from ghostos.contracts.logger import LoggerItf -from ghostos.container import Container +from ghostos.container import Container, Provider +from ghostos.identifier import get_identifier from pydantic import BaseModel """ @@ -89,6 +90,18 @@ class GhostDriver(Generic[G], ABC): def __init__(self, ghost: G) -> None: self.ghost = ghost + def make_task_id(self, parent_scope: Session.Scope) -> str: + from ghostos.helpers import md5 + id_ = get_identifier(self.ghost) + if id_.id: + # if ghost instance has id, it is unique in process. + scope_ids = f"{parent_scope.process_id}-{id_.id}" + else: + # if ghost do not have id, it is unique to parent by name + scope_ids = f"{parent_scope.process_id}-{parent_scope.task_id}-{id_.name}" + # the task id point to a unique entity + return md5(scope_ids) + @abstractmethod def get_goal(self, session: Session) -> Optional[G.Artifact]: """ @@ -108,24 +121,22 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: pass -class Context(Payload, Prompter, ABC): +class Context(Payload, DataPrompter, ABC): """ - context model that ghost care about + context prompter that generate prompt to provide information + the modeling defines strong-typed configuration to generate prompt. """ key = "ghostos_context" - @abstractmethod - def self_prompt(self, container: Container) -> str: - """ - generate prompt from model values with libraries that container provides. - :param container: IoC container provides library implementation. - :return: natural language prompt - """ - pass + __driver__: Optional[Type[ContextDriver]] = None - @abstractmethod - def get_title(self) -> str: - pass + +class ContextDriver(DataPrompterDriver, ABC): + """ + the context driver is separated from context data. + LLM see + """ + pass class Operator(ABC): @@ -179,6 +190,18 @@ def container(self) -> Container: """ pass + @abstractmethod + def create_shell( + self, + shell_id: str, + process_id: Optional[str] = None, + *providers: Provider + ) -> Shell: + pass + + +class Shell(Protocol): + @abstractmethod def send_event(self, event: Event) -> None: """ @@ -188,10 +211,10 @@ def send_event(self, event: Event) -> None: pass @abstractmethod - def converse( + def sync( self, ghost: G, - context: G.Props, + context: Union[G.Context, Prompter, None], ) -> Conversation[G]: """ create a top-level conversation with a ghost. @@ -204,9 +227,9 @@ def converse( def call( self, ghost: G, - props: G.Props, + context: G.Context, instructions: Optional[Iterable[Message]] = None, - *, + *prompters: Prompter, timeout: float = 0.0, ) -> Tuple[Union[G.Artifact, None], TaskState]: """ @@ -258,8 +281,8 @@ def is_done(self) -> bool: def respond( self, inputs: Iterable[Message], - context: Optional[Prompter] = None, - *, + context: G.Context, + *prompters: Prompter, history: Optional[Iterable[Message]] = None, ) -> Iterable[Message]: """ @@ -376,7 +399,8 @@ class Scope(BaseModel): """ scope of the session. """ - root_id: str + shell_id: str + process_id: str task_id: str parent_task_id: Optional[str] = None diff --git a/ghostos/core/moss/decorators.py b/ghostos/core/moss/decorators.py index cc809adc..5d713a9d 100644 --- a/ghostos/core/moss/decorators.py +++ b/ghostos/core/moss/decorators.py @@ -1,6 +1,6 @@ import inspect from typing import Callable, Optional, Any, Type -from ghostos.prompter import set_prompter, set_class_prompter +from ghostos.prompter import set_prompt, set_class_prompt from ghostos.core.moss.utils import ( get_callable_definition, make_class_prompt, strip_source_indent, @@ -40,7 +40,7 @@ def prompter(): source = strip_source_indent(source) return source - set_class_prompter(cls, prompter, force) + set_class_prompt(cls, prompter, force) return cls return decorator @@ -61,7 +61,7 @@ def prompter() -> str: source = strip_source_indent(source) return source - set_prompter(fn, prompter, force) + set_prompt(fn, prompter, force) return fn return decorator @@ -80,7 +80,7 @@ def prompter() -> str: prompt = get_callable_definition(fn, doc=doc) return prompt - set_prompter(fn, prompter, force) + set_prompt(fn, prompter, force) else: raise AttributeError(f"fn '{fn}' has to be a function or method") return fn @@ -102,7 +102,7 @@ def prompter() -> str: prompt = make_class_prompt(source=source, doc=doc) return prompt - set_class_prompter(cls, prompter, force) + set_class_prompt(cls, prompter, force) return cls return wrapper @@ -168,7 +168,7 @@ def prompter() -> str: # 5. return return combined_prompt - set_class_prompter(cls, prompter, force) + set_class_prompt(cls, prompter, force) return cls return wrapper diff --git a/ghostos/core/moss/examples/baseline.py b/ghostos/core/moss/examples/baseline.py index 85d16964..0cd39ad4 100644 --- a/ghostos/core/moss/examples/baseline.py +++ b/ghostos/core/moss/examples/baseline.py @@ -3,7 +3,7 @@ from ghostos.container import Container from ghostos.core.moss.abcd import Moss as Parent -from ghostos.prompter import Prompter +from ghostos.prompter import ModelPrompter from inspect import getmembers, getsource from pydantic import BaseModel @@ -21,7 +21,7 @@ def plus(a: int, b: int) -> int: return a + b -class TestPrompter(Prompter): +class TestPrompter(ModelPrompter): line: str = "TestPrompter" def self_prompt(self, container: Container) -> str: diff --git a/ghostos/core/moss/prompts.py b/ghostos/core/moss/prompts.py index e54f34df..761dc634 100644 --- a/ghostos/core/moss/prompts.py +++ b/ghostos/core/moss/prompts.py @@ -4,6 +4,7 @@ get_callable_definition, ) from ghostos.prompter import get_defined_prompt +from pydantic import BaseModel import inspect """ @@ -44,7 +45,9 @@ 多条 prompt 用 "\n\n".join(prompts) 的方式拼接. """ -ignore_modules = {"pydantic"} +ignore_modules = { + "pydantic", +} def reflect_module_locals( @@ -104,8 +107,10 @@ def reflect_module_attr( return None elif value_modulename == current_module: return None - elif value_modulename in ignore_modules: - return None + for ignore_module_name in ignore_modules: + if value_modulename.startswith(ignore_module_name): + return None + return get_prompt(value) @@ -124,9 +129,10 @@ def get_prompt(value: Any) -> Optional[str]: if inspect.isclass(value): # only reflect abstract class - if inspect.isabstract(value): + if inspect.isabstract(value) or issubclass(value, BaseModel): source = inspect.getsource(value) - return source + if source: + return source elif inspect.isfunction(value) or inspect.ismethod(value): # 默认都给方法展示 definition. return get_callable_definition(value) diff --git a/ghostos/framework/session/default.py b/ghostos/framework/session/default.py new file mode 100644 index 00000000..599637e7 --- /dev/null +++ b/ghostos/framework/session/default.py @@ -0,0 +1,85 @@ +from typing import Optional, List, Iterable, Tuple, Self + +from ghostos.core.abcd.concepts import Session, G +from ghostos.core.messages import MessageKind, Message, Caller +from ghostos.core.runtime import TaskBrief +from ghostos.prompter import Prompter +from ghostos.container import Container + + +class SessionImpl(Session): + + def __init__( + self, + container: Container, + ): + self.container = container + stream: Stream + + scope: Scope + """the running scope of the session""" + + state: Dict[str, Union[Dict, BaseModel]] + """session state that keep session state values""" + + container: Container + """Session level container""" + + task: GoTaskStruct + """current task""" + + thread: GoThreadInfo + """thread info of the task""" + + logger: LoggerItf + + def is_alive(self) -> bool: + pass + + def get_ghost(self) -> G: + pass + + def get_context(self) -> Optional[Prompter]: + pass + + def get_artifact(self) -> G.Artifact: + pass + + def goal(self) -> G.Artifact: + pass + + def refresh(self) -> Self: + pass + + def flow(self) -> Flow: + pass + + def messenger(self) -> "Messenger": + pass + + def respond(self, messages: Iterable[MessageKind], remember: bool = True) -> Tuple[List[Message], List[Caller]]: + pass + + def cancel_subtask(self, ghost: G, reason: str = "") -> None: + pass + + def create_tasks(self, *tasks: "GoTaskStruct") -> None: + pass + + def fire_events(self, *events: "Event") -> None: + pass + + def get_task_briefs(self, *task_ids) -> List[TaskBrief]: + pass + + def save(self) -> None: + pass + + def fail(self, err: Optional[Exception]) -> bool: + pass + + def done(self) -> None: + pass + + def destroy(self) -> None: + pass \ No newline at end of file diff --git a/ghostos/helpers/modules.py b/ghostos/helpers/modules.py index 816cf64d..f5dd1166 100644 --- a/ghostos/helpers/modules.py +++ b/ghostos/helpers/modules.py @@ -60,7 +60,7 @@ def get_module_spec(module, spec: str) -> Optional[Any]: def generate_module_spec(value: Any) -> Tuple[str, Optional[str]]: if inspect.ismodule(value): return value.__name__, None - elif inspect.isclass(value): + elif inspect.isclass(value) or inspect.isfunction(value): module = getattr(value, '__module__', '') spec = getattr(value, '__qualname__', getattr(value, '__name__', "")) return module, spec diff --git a/ghostos/prompter.py b/ghostos/prompter.py index 3dc18a80..9b2344ea 100644 --- a/ghostos/prompter.py +++ b/ghostos/prompter.py @@ -2,22 +2,22 @@ import inspect from typing import ( - List, Self, Union, Callable, Any, Protocol, Optional, Dict, + List, Self, Union, Callable, Any, Protocol, Optional, Dict, TypeVar, Type, Generic, ) from abc import ABC, abstractmethod -from types import ModuleType +from types import ModuleType, FunctionType from ghostos.container import Container -from ghostos.helpers import generate_import_path -import json - +from ghostos.helpers import generate_import_path, import_class_from_path, import_from_path from pydantic import BaseModel, Field -from .entity import EntityClass, EntityMeta, from_entity_meta, to_entity_meta +from ghostos.entity import EntityClass, EntityMeta, from_entity_meta, to_entity_meta +import json __all__ = [ 'get_defined_prompt', - 'set_prompter', 'set_class_prompter', - 'Prompter', + 'set_prompt', 'set_class_prompt', + 'Prompter', 'DataPrompter', 'DataPrompterDriver', 'TextPrmt', + 'InspectPrmt', 'PromptAbleObj', 'PromptAbleClass', ] @@ -54,12 +54,12 @@ def get_defined_prompt_attr(value: Any) -> Union[None, str, Callable[[], str]]: return None -def set_prompter(obj: Any, prompter: Union[Callable[[], str], str], force: bool = False) -> None: +def set_prompt(obj: Any, prompter: Union[Callable[[], str], str], force: bool = False) -> None: if force or not hasattr(obj, '__prompt__'): setattr(obj, '__prompt__', prompter) -def set_class_prompter(cls: type, prompter: Union[Callable[[], str], str], force: bool = False) -> None: +def set_class_prompt(cls: type, prompter: Union[Callable[[], str], str], force: bool = False) -> None: if hasattr(cls, '__class__prompt__'): fn = getattr(cls, '__class_prompt__') cls_name = generate_import_path(cls) @@ -71,7 +71,9 @@ def set_class_prompter(cls: type, prompter: Union[Callable[[], str], str], force setattr(cls, '__class_prompt__', prompter) -class Prompter(BaseModel, EntityClass, ABC): +# ---- prompter ---- # + +class Prompter(EntityClass, ABC): """ is strong-typed model for runtime alternative properties of a ghost. """ @@ -139,7 +141,8 @@ def get_prompt(self, container: Container, depth: int = 0) -> str: return "" # generate output prompt - prompts.insert(0, title) + if title: + prompts.insert(0, title) output = "" for paragraph in prompts: paragraph = paragraph.strip() @@ -150,7 +153,7 @@ def get_prompt(self, container: Container, depth: int = 0) -> str: def __to_entity_meta__(self) -> EntityMeta: type_ = generate_import_path(self.__class__) - ctx_data = self.model_dump(exclude_defaults=True) + ctx_data = self.__to_entity_meta_data__() children_data = [] if self.__children__ is not None: for child in self.__children__: @@ -159,12 +162,21 @@ def __to_entity_meta__(self) -> EntityMeta: content = json.dumps(data) return EntityMeta(type=type_, content=content) + @abstractmethod + def __to_entity_meta_data__(self) -> Dict[str, Any]: + pass + + @classmethod + @abstractmethod + def __from_entity_meta_data__(cls, data: Dict[str, Any]) -> Self: + pass + @classmethod def __from_entity_meta__(cls, meta: EntityMeta) -> Self: data = json.loads(meta["content"]) ctx_data = data["ctx"] children_data = data["children"] - result = cls(**ctx_data) + result = cls.__from_entity_meta_data__(ctx_data) children = [] for child in children_data: children.append(from_entity_meta(child)) @@ -183,7 +195,61 @@ def flatten(self, index: str = "") -> Dict[str, Self]: return result -class TextPrmt(Prompter): +class ModelPrompter(BaseModel, Prompter, ABC): + + def __to_entity_meta_data__(self) -> Dict[str, Any]: + return self.model_dump(exclude_defaults=True) + + @classmethod + def __from_entity_meta_data__(cls, data: Dict[str, Any]) -> Self: + return cls(**data) + + +class DataPrompter(ModelPrompter, ABC): + __driver__: Optional[Type[DataPrompterDriver]] = None + + def get_driver(self) -> DataPrompterDriver: + driver = self.__driver__ + if driver is None: + driver_path = generate_import_path(self.__class__) + "Driver" + driver = import_class_from_path(driver_path, DataPrompterDriver) + return driver(self) + + def self_prompt(self, container: Container) -> str: + """ + generate prompt from model values with libraries that container provides. + :param container: IoC container provides library implementation. + :return: natural language prompt + """ + return self.get_driver().self_prompt(container) + + def get_title(self) -> str: + return self.get_driver().get_title() + + +D = TypeVar("D", bound=DataPrompter) + + +class DataPrompterDriver(Generic[D], ABC): + + def __init__(self, data: D): + self.data = data + + @abstractmethod + def self_prompt(self, container: Container) -> str: + """ + generate prompt from model values with libraries that container provides. + :param container: IoC container provides library implementation. + :return: natural language prompt + """ + pass + + @abstractmethod + def get_title(self) -> str: + pass + + +class TextPrmt(ModelPrompter): title: str = "" content: str = "" @@ -194,6 +260,52 @@ def get_title(self) -> str: return self.title +class InspectPrmt(DataPrompter): + title: str = Field( + default="Code Inspection", + description="The title of the inspect prompt.", + ) + source_target: List[str] = Field( + default_factory=list, + description="Inspect source code of these targets. ", + ) + + def inspect_source(self, target: Union[type, Callable, str]) -> Self: + if not isinstance(target, str): + target = generate_import_path(target) + self.source_target.append(target) + return self + + +class InspectPrmtDriver(DataPrompterDriver[InspectPrmt]): + + def self_prompt(self, container: Container) -> str: + prompts = {} + for target in self.data.source_target: + got = import_from_path(target) + source = inspect.getsource(got) + prompts[target] = source + + result = "" + for target, source in prompts.items(): + source = source.strip() + if not source: + continue + result += f""" + +source code of `{target}`: +```python +{source} +``` +""" + return result.strip() + + def get_title(self) -> str: + pass + + +# ---- prompt-able ---- # + class PromptAbleObj(ABC): """ 拥有 __prompt__ 方法的类. diff --git a/tests/test_prompter.py b/tests/test_prompter.py index 018770b7..15362edd 100644 --- a/tests/test_prompter.py +++ b/tests/test_prompter.py @@ -1,4 +1,7 @@ -from ghostos.prompter import TextPrmt, PromptAbleClass, PromptAbleObj +from ghostos.prompter import ( + TextPrmt, PromptAbleClass, PromptAbleObj, + InspectPrmt, +) from ghostos.container import Container import inspect @@ -25,3 +28,12 @@ def test_group_prompters(): assert "\n### 1.2.2\n" in p # test buffer is ok assert p == prompter.get_prompt(c) + + +def test_inspect_prompters(): + prmt = InspectPrmt() + prmt.inspect_source(InspectPrmt) + prmt.inspect_source(test_group_prompters) + c = Container() + prompt = prmt.get_prompt(c) + assert f":{test_group_prompters.__name__}" in prompt From 4b11ceed683bce691bd2eb540ebda006ac3d9631 Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 12 Nov 2024 23:27:39 +0800 Subject: [PATCH 073/148] dev: new session implementaion --- .../memories/.gitkeep.py => __init__.py} | 0 ghostos/container.py | 27 +- ghostos/contracts/logger.py | 13 +- ghostos/core/abcd/concepts.py | 214 +++++----- ghostos/core/abcd/utils.py | 104 ++--- ghostos/core/agents/moss_agent.py | 2 +- ghostos/core/errors.py | 40 -- ghostos/core/ghostos2.py | 257 ------------ ghostos/core/moss/abcd.py | 10 +- ghostos/core/moss/impl.py | 37 +- ghostos/core/runtime/__init__.py | 2 +- ghostos/core/runtime/events.py | 14 +- ghostos/core/runtime/tasks.py | 23 +- ghostos/core/runtime/threads.py | 20 +- ghostos/demo/configs/ghosts.yml | 18 - ghostos/demo/configs/llms_conf.yml | 59 --- ghostos/demo/configs/logging.yml | 28 -- ghostos/demo/runtime/cache/.gitignore | 2 - ghostos/demo/runtime/events/.gitignore | 2 - ghostos/demo/runtime/processes/.gitignore | 2 - ghostos/demo/runtime/tasks/.gitignore | 2 - ghostos/demo/runtime/threads/.gitignore | 2 - .../framework/ghostos/conversation_impl.py | 89 +++++ ghostos/framework/ghostos/ghostos_impl.py | 0 ghostos/framework/ghostos/operation.py | 27 ++ ghostos/framework/ghostos/session_impl.py | 367 ++++++++++++++++++ ghostos/framework/ghostos/shell_impl.py | 0 ghostos/framework/session/basic.py | 1 + ghostos/framework/session/default.py | 85 ---- 29 files changed, 750 insertions(+), 697 deletions(-) rename ghostos/{demo/memories/.gitkeep.py => __init__.py} (100%) delete mode 100644 ghostos/core/ghostos2.py delete mode 100644 ghostos/demo/configs/ghosts.yml delete mode 100644 ghostos/demo/configs/llms_conf.yml delete mode 100644 ghostos/demo/configs/logging.yml delete mode 100644 ghostos/demo/runtime/cache/.gitignore delete mode 100644 ghostos/demo/runtime/events/.gitignore delete mode 100644 ghostos/demo/runtime/processes/.gitignore delete mode 100644 ghostos/demo/runtime/tasks/.gitignore delete mode 100644 ghostos/demo/runtime/threads/.gitignore create mode 100644 ghostos/framework/ghostos/conversation_impl.py create mode 100644 ghostos/framework/ghostos/ghostos_impl.py create mode 100644 ghostos/framework/ghostos/operation.py create mode 100644 ghostos/framework/ghostos/session_impl.py create mode 100644 ghostos/framework/ghostos/shell_impl.py delete mode 100644 ghostos/framework/session/default.py diff --git a/ghostos/demo/memories/.gitkeep.py b/ghostos/__init__.py similarity index 100% rename from ghostos/demo/memories/.gitkeep.py rename to ghostos/__init__.py diff --git a/ghostos/container.py b/ghostos/container.py index db591abc..e30a745d 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -79,6 +79,14 @@ def get_bound(self, abstract: Type[INSTANCE]) -> Union[INSTANCE, Provider]: """ pass + @abstractmethod + def get_provider(self, abstract: Type[INSTANCE]) -> Optional[Provider[INSTANCE]]: + pass + + @abstractmethod + def rebind(self, abstract: Type[INSTANCE]) -> None: + pass + @abstractmethod def fetch(self, abstract: Type[INSTANCE], strict: bool = False) -> Optional[INSTANCE]: """ @@ -268,10 +276,6 @@ def register(self, *providers: Provider) -> None: self._register(provider) def _register(self, provider: Provider) -> None: - if isinstance(provider, Bootstrapper) and self._bootstrapped: - # 添加 bootstrapper. - provider.bootstrap(self) - contract = provider.contract() self._bind_contract(contract) self._register_provider(contract, provider) @@ -280,6 +284,9 @@ def _register(self, provider: Provider) -> None: for alias in provider.aliases(): if alias not in self._bound: self._bind_alias(alias, contract) + if isinstance(provider, Bootstrapper) and self._bootstrapped: + # 添加 bootstrapper. + provider.bootstrap(self) def _bind_alias(self, alias: Any, contract: Any) -> None: self._aliases[alias] = contract @@ -315,6 +322,18 @@ def fetch(self, abstract: Type[INSTANCE], strict: bool = False) -> Optional[INST return instance return None + def get_provider(self, abstract: Type[INSTANCE]) -> Optional[Provider[INSTANCE]]: + if abstract in self._providers: + return self._providers[abstract] + if self.parent is not None: + return self.parent.get_provider(abstract) + return None + + def rebind(self, abstract: Type[INSTANCE]) -> None: + provider = self.get_provider(abstract) + if provider is not None: + self.register(provider) + def force_fetch(self, contract: Type[INSTANCE], strict: bool = False) -> INSTANCE: """ if fetch contract failed, raise error. diff --git a/ghostos/contracts/logger.py b/ghostos/contracts/logger.py index 3c02ea01..5f8014f6 100644 --- a/ghostos/contracts/logger.py +++ b/ghostos/contracts/logger.py @@ -1,11 +1,14 @@ import logging from abc import abstractmethod from logging.config import dictConfig -from logging import getLogger, LoggerAdapter +from logging import getLogger, LoggerAdapter, Logger from typing import Protocol, Optional import yaml -__all__ = ['LoggerItf', 'config_logging', 'get_logger', 'get_console_logger'] +__all__ = [ + 'LoggerItf', 'config_logging', 'get_logger', 'get_console_logger', + 'wrap_logger', +] class LoggerItf(Protocol): @@ -96,6 +99,12 @@ def get_logger(name: Optional[str] = None, extra: Optional[dict] = None) -> Logg return LoggerAdapter(getLogger(name), extra=extra) +def wrap_logger(logger: LoggerItf, extra: dict) -> LoggerItf: + if isinstance(logger, LoggerAdapter) or isinstance(logger, Logger): + return LoggerAdapter(logger, extra) + return logger + + def config_logging(conf_path: str) -> None: """ configurate logging by yaml config diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index ef91479e..ef935410 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -50,7 +50,10 @@ 2. ghost shall be defined by code, which can be generated by meta-agents. """ -__all__ = ("Ghost", "Session", "GhostDriver", "GhostOS", "Operator", "StateValue", "Action") +__all__ = ( + "Ghost", "Session", "GhostDriver", "GhostOS", "Operator", "StateValue", "Action", + "Shell", "Operation", "Scope", "Conversation", +) class Ghost(Identifiable, EntityType, ABC): @@ -66,7 +69,7 @@ class Ghost(Identifiable, EntityType, ABC): Artifact: ClassVar[Union[Type, None]] = None """ the model of the ghost's artifact, is completing during runtime""" - Context: ClassVar[Type[Prompter], None] = None + Context: ClassVar[Type[Context], None] = None """ the model of the ghost's context, is completing during runtime'""" Driver: Type[GhostDriver] = None @@ -90,7 +93,7 @@ class GhostDriver(Generic[G], ABC): def __init__(self, ghost: G) -> None: self.ghost = ghost - def make_task_id(self, parent_scope: Session.Scope) -> str: + def make_task_id(self, parent_scope: Scope) -> str: from ghostos.helpers import md5 id_ = get_identifier(self.ghost) if id_.id: @@ -103,7 +106,7 @@ def make_task_id(self, parent_scope: Session.Scope) -> str: return md5(scope_ids) @abstractmethod - def get_goal(self, session: Session) -> Optional[G.Artifact]: + def get_artifact(self, session: Session) -> Optional[G.Artifact]: """ generate the ghost goal from session_state may be the Goal Model is a SessionStateValue that bind to it. @@ -113,6 +116,14 @@ def get_goal(self, session: Session) -> Optional[G.Artifact]: """ pass + @abstractmethod + def parse_event( + self, + session: Session, + event: Event, + ) -> Union[Event, None]: + pass + @abstractmethod def on_event(self, session: Session, event: Event) -> Union[Operator, None]: """ @@ -202,6 +213,13 @@ def create_shell( class Shell(Protocol): + @abstractmethod + def container(self) -> Container: + """ + root container for GhostOS + """ + pass + @abstractmethod def send_event(self, event: Event) -> None: """ @@ -214,7 +232,7 @@ def send_event(self, event: Event) -> None: def sync( self, ghost: G, - context: Union[G.Context, Prompter, None], + context: Union[G.Context, None], ) -> Conversation[G]: """ create a top-level conversation with a ghost. @@ -229,7 +247,7 @@ def call( ghost: G, context: G.Context, instructions: Optional[Iterable[Message]] = None, - *prompters: Prompter, + prompters: Optional[List[Prompter]] = None, timeout: float = 0.0, ) -> Tuple[Union[G.Artifact, None], TaskState]: """ @@ -264,16 +282,9 @@ class Conversation(Protocol[G]): """ @abstractmethod - def session(self) -> Session: - """ - Session of the Conversation - """ - pass - - @abstractmethod - def is_done(self) -> bool: + def container(self) -> Container: """ - weather the conversation is done or not + root container for GhostOS """ pass @@ -281,9 +292,8 @@ def is_done(self) -> bool: def respond( self, inputs: Iterable[Message], - context: G.Context, - *prompters: Prompter, - history: Optional[Iterable[Message]] = None, + context: Optional[G.Context] = None, + history: Optional[List[Message]] = None, ) -> Iterable[Message]: """ create response immediately by inputs. the inputs will change to event. @@ -384,81 +394,87 @@ def get_or_bind(self, session: Session) -> Self: return value -class Session(Generic[G], ABC): +class Operation(ABC): """ - Session 管理了一个有状态的会话. 所谓 "有状态的会话", 通常指的是: - shell + ghost + 多轮对话/多轮思考 运行中的状态. - - Session 则提供了 Ghost 的 Task 运行时状态统一管理的 API. - 通常每个运行中的 Task 都会创建一个独立的 Session. - Session 在运行周期里不会立刻调用底层 IO 存储消息, 而是要等一个周期正常结束. - 这是为了减少运行时错误对状态机造成的副作用. + default operations """ - class Scope(BaseModel): + # --- 基本操作 --- # + @abstractmethod + def finish(self, status: str = "", *replies: MessageKind) -> Operator: + """ + finish self task + :param status: describe status of the task + :param replies: replies to parent task or user + """ + pass + + @abstractmethod + def fail(self, status: str = "", *replies: MessageKind) -> Operator: """ - scope of the session. + self task failed. + :param status: describe status of the task + :param replies: replies to parent task or user """ - shell_id: str - process_id: str - task_id: str - parent_task_id: Optional[str] = None + pass - class Flow(ABC): + @abstractmethod + def wait(self, status: str = "", *replies: MessageKind) -> Operator: """ - task flow + wait for the parent task or user to provide more information or further instruction. + :param status: describe current status + :param replies: question, inform or """ + pass - # --- 基本操作 --- # - @abstractmethod - def done(self, status: str = "", *replies: MessageKind) -> Operator: - """ - finish self task - :param status: describe status of the task - :param replies: replies to parent task or user - """ - pass + @abstractmethod + def observe(self, *messages: MessageKind) -> Operator: + pass - @abstractmethod - def fail(self, status: str = "", *replies: MessageKind) -> Operator: - """ - self task failed. - :param status: describe status of the task - :param replies: replies to parent task or user - """ - pass + @abstractmethod + def on_error(self, *messages: MessageKind) -> Operator: + pass - @abstractmethod - def wait(self, status: str = "", *replies: MessageKind) -> Operator: - """ - wait for the parent task or user to provide more information or further instruction. - :param status: describe current status - :param replies: question, inform or - """ - pass - @abstractmethod - def observe(self, *messages: MessageKind) -> Operator: - pass +class Scope(BaseModel): + """ + scope of the session. + """ + shell_id: str + process_id: str + task_id: str + parent_task_id: Optional[str] = None - @abstractmethod - def on_error(self, *messages: MessageKind) -> Operator: - pass + +class Session(Generic[G], ABC): + """ + Session 管理了一个有状态的会话. 所谓 "有状态的会话", 通常指的是: + shell + ghost + 多轮对话/多轮思考 运行中的状态. + + Session 则提供了 Ghost 的 Task 运行时状态统一管理的 API. + 通常每个运行中的 Task 都会创建一个独立的 Session. + Session 在运行周期里不会立刻调用底层 IO 存储消息, 而是要等一个周期正常结束. + 这是为了减少运行时错误对状态机造成的副作用. + """ stream: Stream scope: Scope """the running scope of the session""" - state: Dict[str, Union[Dict, BaseModel]] + state: Dict[str, EntityType] """session state that keep session state values""" container: Container """Session level container""" + ghost: G + task: GoTaskStruct """current task""" + subtasks: Dict[str, TaskBrief] + thread: GoThreadInfo """thread info of the task""" @@ -477,11 +493,7 @@ def is_alive(self) -> bool: pass @abstractmethod - def get_ghost(self) -> G: - """ - current ghost instance - :return: - """ + def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator]]: pass @abstractmethod @@ -499,18 +511,18 @@ def get_artifact(self) -> G.Artifact: pass @abstractmethod - def goal(self) -> G.Artifact: - pass - - @abstractmethod - def refresh(self) -> Self: + def refresh(self) -> bool: """ refresh the session, update overdue time and task lock. """ pass @abstractmethod - def flow(self) -> Flow: + def save(self): + pass + + @abstractmethod + def operates(self) -> Operation: pass @abstractmethod @@ -543,6 +555,7 @@ def cancel_subtask(self, ghost: G, reason: str = "") -> None: """ pass + @abstractmethod def send_subtask(self, ghost: G, *messages: MessageKind, ctx: Optional[G.Props] = None) -> None: """ 发送消息给子任务. 如果子任务不存在, 会创建. @@ -554,17 +567,28 @@ def send_subtask(self, ghost: G, *messages: MessageKind, ctx: Optional[G.Props] """ pass - def create_subtask(self, ghost: G, ctx: G.Props, instruction: str) -> None: + @abstractmethod + def create_subtask( + self, + ghost: G, + ctx: G.Context, + task_name: Optional[str] = None, + task_description: Optional[str] = None, + ) -> None: """ 创建子任务并运行. - :param ghost: - :param ctx: - :param instruction: - :return: """ pass - def call(self, ghost: G, ctx: G.Props) -> G.Artifact: + @abstractmethod + def create_threads( + self, + *threads: GoThreadInfo, + ) -> None: + pass + + @abstractmethod + def call(self, ghost: G, ctx: G.Context) -> G.Artifact: """ 创建一个子任务, 阻塞并等待它完成. :param ghost: @@ -575,13 +599,6 @@ def call(self, ghost: G, ctx: G.Props) -> G.Artifact: # --- 更底层的 API. --- # - @abstractmethod - def create_tasks(self, *tasks: "GoTaskStruct") -> None: - """ - 创建多个 task. 只有 session.done() 的时候才会执行. - """ - pass - @abstractmethod def fire_events(self, *events: "Event") -> None: """ @@ -591,28 +608,13 @@ def fire_events(self, *events: "Event") -> None: pass @abstractmethod - def get_task_briefs(self, *task_ids) -> List[TaskBrief]: + def get_task_briefs(self, *task_ids: str) -> Dict[str, TaskBrief]: """ 获取多个任务的简介. :param task_ids: 可以指定要获取的 task id """ pass - @abstractmethod - def save(self) -> None: - """ - 完成 session, 需要清理和真正保存状态. - 需要做的事情包括: - 1. 推送 events, events 要考虑 task 允许的栈深问题. 这个可以后续再做. - 2. 保存 task. task 要对自己的子 task 做垃圾回收. 并且保留一定的子 task 数, 包含 dead task. - 3. 保存 thread - 4. 保存 processes. - 5. 考虑到可能发生异常, 要做 transaction. - 6. 退出相关的逻辑只能在 finish 里实现. - :return: - """ - pass - @abstractmethod def fail(self, err: Optional[Exception]) -> bool: """ diff --git a/ghostos/core/abcd/utils.py b/ghostos/core/abcd/utils.py index da4271c4..dbd5107e 100644 --- a/ghostos/core/abcd/utils.py +++ b/ghostos/core/abcd/utils.py @@ -1,12 +1,13 @@ -from typing import TypeVar, Optional, Type, Union -from ghostos.helpers import import_class_from_path, generate_import_path, md5 -from ghostos.identifier import get_identifier, to_entity_meta +from typing import Optional, Type, Union +from ghostos.helpers import import_class_from_path, md5 +from ghostos.identifier import get_identifier from ghostos.core.runtime import Runtime, GoTaskStruct -from .concepts import Ghost, GhostDriver +from ghostos.entity import to_entity_meta +from .concepts import Ghost, GhostDriver, Session, Operator +from ghostos.core.runtime import Event __all__ = [ - 'get_ghost_task', 'get_or_create_ghost_task', - 'get_ghost_driver', 'get_ghost_driver_type', + 'get_ghost_driver', 'get_ghost_driver_type', 'is_ghost', ] @@ -43,68 +44,41 @@ def is_ghost(value) -> bool: return False -def make_unique_ghost_id( - shell_id: str, - **scope_ids: str, -) -> str: - """ - make unique ghost id - :param shell_id: the shell id must exist. - :param scope_ids: - :return: md5 hash - """ - ids = f"shell:{shell_id}" - keys = sorted(scope_ids.keys()) - for key in keys: - scope = scope_ids[key] - ids += f":{key}:{scope}" - return md5(ids) +def fire_session_event(session: Session, event: Event) -> Optional[Operator]: + event, op = session.parse_event(event) + if op is not None: + return op + if event is None: + # if event is intercepted, stop the run. + return None + driver = get_ghost_driver(session.ghost) + return driver.on_event(session, event) -def get_or_create_ghost_task(runtime: Runtime, ghost: Ghost, parent_task_id: Optional[str]) -> GoTaskStruct: - """ - default way to find or create ghost task - :param runtime: - :param ghost: - :param parent_task_id: - :return: - """ - task = get_ghost_task(runtime, ghost, parent_task_id) - if task is None: - task = make_ghost_task(runtime, ghost, parent_task_id) - return task +class InitOperator(Operator): + def __init__(self, event: Event): + self.event = event + def run(self, session: Session) -> Union[Operator, None]: + return fire_session_event(session, self.event) -def get_ghost_task(runtime: Runtime, ghost: Ghost, parent_task_id: Optional[str]) -> Union[GoTaskStruct, None]: - driver = get_ghost_driver(ghost) - task_id = driver.make_task_id(runtime, parent_task_id) - task = runtime.tasks.get_task(task_id) - if task is None: - return None - # update task's meta from ghost. - task.meta = to_entity_meta(ghost) - return task + def destroy(self): + del self.event -def make_ghost_task(runtime: Runtime, ghost: Ghost, parent_task_id: Optional[str]) -> GoTaskStruct: - """ - default way to create a task - :param runtime: - :param ghost: - :param parent_task_id: - :return: - """ - driver = get_ghost_driver(ghost) - task_id = driver.make_task_id(runtime, parent_task_id) - id_ = get_identifier(ghost) - meta = to_entity_meta(ghost) - task_ = GoTaskStruct.new( - task_id=task_id, - shell_id=runtime.shell_id, - process_id=runtime.process_id, - name=id_.name, - description=id_.description, - meta=meta, - parent_task_id=parent_task_id - ) - return task_ +def run_session_event(session: Session, event: Event, max_step: int) -> None: + with session: + op = InitOperator(event) + step = 0 + while op is not None: + step += 1 + if step > max_step: + raise RuntimeError(f"Max step {max_step} reached") + if not session.refresh(): + raise RuntimeError("Session refresh failed") + session.logger.info("start session op %s", op) + next_op = op.run(session) + session.logger.info("done session op %s", op) + op.destroy() + session.save() + op = next_op diff --git a/ghostos/core/agents/moss_agent.py b/ghostos/core/agents/moss_agent.py index 87df7427..3a34af27 100644 --- a/ghostos/core/agents/moss_agent.py +++ b/ghostos/core/agents/moss_agent.py @@ -93,7 +93,7 @@ def get_module(self) -> ModuleType: m = import_from_path(self.ghost.moss_module) return m - def get_goal(self, session: Session) -> Optional[MossAgent.GoalType]: + def get_artifact(self, session: Session) -> Optional[MossAgent.GoalType]: m = self.get_module() if __agent_goal__.__name__ not in m.__dict__: return None diff --git a/ghostos/core/errors.py b/ghostos/core/errors.py index 715c1a85..e69de29b 100644 --- a/ghostos/core/errors.py +++ b/ghostos/core/errors.py @@ -1,40 +0,0 @@ -from typing import Optional - - -class GhostOSError(Exception): - # todo: 深入理解 python exception 再实现细节. - - def __init__(self, message: str, parent: Optional[Exception] = None): - super().__init__(message) - self.catch = parent - if self.catch is not None: - # todo: 不太会用 python 的异常. - self.__cause__ = self.catch.__cause__ - - -class RaisedNotice(GhostOSError): - """ - 不是真正的 error, 而是用于 try catch 的一个 notice. - """ - pass - - -class GhostContextError(GhostOSError): - """ - 一次运行级别的 error, 让运行无效. - """ - pass - - -class GhostOSIOError(GhostOSError): - """ - 运行时的 io 异常. 通常影响的只是 context. - """ - pass - - -class GhostOSRuntimeError(GhostOSError): - """ - 让一个 runtime 进程失效退出. - """ - pass diff --git a/ghostos/core/ghostos2.py b/ghostos/core/ghostos2.py deleted file mode 100644 index ac8b7aa8..00000000 --- a/ghostos/core/ghostos2.py +++ /dev/null @@ -1,257 +0,0 @@ -from typing import Optional, Generic, TypeVar, Iterable, Callable, Tuple, Union, List, Type -from typing_extensions import Literal, Self -from abc import ABC, abstractmethod -from ghostos.container import Container -from ghostos.core.ghosts import ( - Ghost, GhostConf, Inputs, - Shell, Thought -) -from ghostos.core.runtime import ( - Event, - GoProcess, - GoTaskStruct, GoTasks, - GoThreadInfo, GoThreads, - EventBus, -) -from ghostos.core.messages import ( - Stream, Message, Received, -) - - -class Host(ABC): - """ - An instance of a bot or an agent. - Composed by a shell and the ghost. - """ - - @abstractmethod - def process(self) -> GoProcess: - """ - process of the host instance. - """ - pass - - @abstractmethod - def conf(self) -> GhostConf: - """ - ghost conf to generate a ghost instance - """ - pass - - @abstractmethod - def shell(self) -> Shell: - """ - the shell of the Host - """ - pass - - @abstractmethod - def send_event(self, event: Event) -> None: - """ - send an event to the ghost, but not handle it immediately - """ - pass - - @abstractmethod - def task(self, task_id: Optional[str] = None) -> Optional[GoTaskStruct]: - pass - - @abstractmethod - def tasks(self) -> GoTasks: - pass - - @abstractmethod - def thread(self, task_id: Optional[str] = None) -> Optional[GoThreadInfo]: - pass - - @abstractmethod - def history(self, task_id: Optional[str] = None) -> Optional[List[Message]]: - pass - - @abstractmethod - def threads(self) -> GoThreads: - pass - - -class Run(ABC): - @abstractmethod - def task(self) -> GoTaskStruct: - pass - - @abstractmethod - def host(self) -> Host: - pass - - @abstractmethod - def is_main_task(self) -> bool: - pass - - @abstractmethod - def event(self) -> Event: - pass - - @abstractmethod - def thought(self) -> None: - pass - - @abstractmethod - def receive(self) -> Iterable[Received]: - pass - - @abstractmethod - def stop(self, wait: bool = True, *, cancel_futures: bool = False) -> None: - """ - :param wait: - :param cancel_futures: - :return: - """ - pass - - -class Runner(ABC): - """ - Is a way to run host's tasks. - You can either use it synchronously by run-frame in a controlled loop; - or asynchronously by loop_util_stop function. - """ - - @abstractmethod - def run_frame(self) -> Optional[Run]: - """ - run a single frame of background tasks, - one frame means a task handle one event. - :return: a Run instance that can retrieve the messages. stop is useless - """ - pass - - @abstractmethod - def loop_until_stop( - self, - on_run: Callable[[Run], None], - on_error: Callable[[Run, Exception], None], - stop: Optional[Callable[[], bool]] = None, - worker: int = 4, - idle: float = -1, - ) -> None: - """ - block the main loop, run background task frame until stop is called. - the actual logic is: - 1. pop task notification from eventbus, if none, idle or stop - 2. lock the task, if failed, drop the notification and go on popping. - 3. consume task event from eventbus - 4. if event is None, go on popping task notification - 5. if event exists, run a task frame with the event and callback function. - 6. checkpoint: check stop condition - 7. redirect to step 1 - - :param on_run: when a Run is generated by some task, event. - can do something, like push message to someone - :param on_error: usually log error, or stop everything by run.stop() - :param stop: stop condition callback. if return True, the loop will stop at checkpoint. - :param worker: concurrent worker number. - :param idle: if no work to do, idle a while, in seconds. if negative, the loop stop when in idle. - :return: block the main loop. or you can call this function in another thread. - """ - pass - - -class HostRunner(Runner, ABC): - @abstractmethod - def host(self) -> Host: - """ - host runner can get to the host instance. - """ - pass - - @abstractmethod - def send_inputs( - self, - inputs: Inputs, - *, - mod: Literal["block", "queue", "intercept"] = "block", - timeout: float = 2, - ) -> Optional[Run]: - """ - send inputs to the ghost main task and try to create a synchronize response. - the inputs will be filtered by main task's thought, to generate an event or intercept it. - then the main task will execute a frame immediately, and return a Run instance. - - if another process lock the main task, the `mod` decide the following actions. - - :param inputs: the unparsed inputs. - :param mod: - - block: if the main task is locked, retry until the timeout is reached, then return with error message. - - queue: if the main task is locked and timeout, send an asynchronize event, and return None - - intercept: if the main task is locked and timeout, force to lock the main task, intercept other run. - :param timeout: timeout in second, before the main task's locker required. - :return: if locked the main task, run a frame and return a Run instance. - """ - pass - - @abstractmethod - def run_task_frame(self, task_id: Optional[str] = None) -> Optional[Run]: - """ - run a background task frame manually - :param task_id: if task_id is None, pop a task notification from eventbus. - :return: if run is None, means no event found or lock task failed. - """ - pass - - -class GhostOS(ABC): - - @abstractmethod - def container(self) -> Container: - pass - - @abstractmethod - def register(self, conf: GhostConf) -> None: - pass - - @abstractmethod - def forge( - self, - shell: Shell, - ghost_id: Union[str, GhostConf], - ) -> Host: - """ - build a Host (robot or agent) with it shell (body) and ghost (brain). - shell id is the session id of the ghost. - :param shell: - :param ghost_id: - :return: - :exception: NotImplementedError if the ghost_id is not registered - """ - pass - - @abstractmethod - def send_event(self, event: Event) -> None: - """ - send an async event to the system. - shall be handled by background run. - """ - pass - - @abstractmethod - def create_runner(self) -> Runner: - """ - create a background runner for all the tasks in this ghostos system. - """ - pass - - @abstractmethod - def create_host_runner( - self, - shell: Shell, - ghost_id: Union[str, GhostConf], - process_id: Optional[str] = None, - ) -> HostRunner: - """ - create a host runner that has its own process eventbus. - only host runner can receive its task events. - :param shell: - :param ghost_id: - :param process_id: the specific process id, - :return: - """ - pass diff --git a/ghostos/core/moss/abcd.py b/ghostos/core/moss/abcd.py index 6127185b..3aa48e32 100644 --- a/ghostos/core/moss/abcd.py +++ b/ghostos/core/moss/abcd.py @@ -54,6 +54,7 @@ 'AttrPrompts', 'MOSS_TYPE_NAME', 'MOSS_VALUE_NAME', 'MOSS_HIDDEN_MARK', 'MOSS_HIDDEN_UNMARK', + 'Injection', ] MOSS_TYPE_NAME = "Moss" @@ -86,11 +87,18 @@ def fetch(self, abstract: Type[T]) -> Optional[T]: """ pass + @abstractmethod + def pprint(self, *args, **kwargs) -> None: + """ + pretty printer + """ + pass + class Injection(ABC): @abstractmethod - def on_inject(self, compiler: MossCompiler, property_name: str) -> Self: + def on_inject(self, runtime: MossRuntime, property_name: str) -> Self: pass diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index b87808eb..d37c0037 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -1,6 +1,6 @@ import inspect from types import ModuleType -from typing import Optional, Any, Dict, get_type_hints, Type, List +from typing import Optional, Any, Dict, get_type_hints, Type, List, Callable import io from ghostos.container import Container, Provider @@ -9,6 +9,7 @@ Moss, MossCompiler, MossRuntime, MossPrompter, MOSS_VALUE_NAME, MOSS_TYPE_NAME, MOSS_HIDDEN_MARK, MOSS_HIDDEN_UNMARK, + Injection, ) from ghostos.core.moss.pycontext import PyContext from ghostos.prompter import Prompter, TextPrmt @@ -120,25 +121,30 @@ def destroy(self) -> None: del self._injections -def new_moss_stub(cls: Type[Moss], container: Container, pycontext: PyContext) -> Moss: +def new_moss_stub(cls: Type[Moss], container: Container, pycontext: PyContext, pprint: Callable) -> Moss: # cls 必须不包含参数. class MossType(cls): __pycontext__ = pycontext __container__ = container + __output__ = "" def fetch(self, abstract: Type[cls.T]) -> Optional[cls.T]: return self.__container__.fetch(abstract) + def pprint(self, *args, **kwargs) -> None: + pprint(*args, **kwargs) + def __setattr__(self, _name, _value): if self.__pycontext__.allow_prop(_value): self.__pycontext__.set_prop(_name, _value) self.__dict__[_name] = _value def destroy(self) -> None: - MossType.__pycontext__ = None - MossType.__container__ = None + del self.__pycontext__ + del self.__container__ stub = MossType() + # 反向注入. for name, value in cls.__dict__.items(): if name in pycontext.properties or name.startswith("_"): continue @@ -181,19 +187,22 @@ def _compile_moss(self) -> Moss: # 创建 stub. pycontext = self._pycontext - moss = new_moss_stub(moss_type, self._container, pycontext) + moss = new_moss_stub(moss_type, self._container, pycontext, self.pprint) + + def inject(attr_name: str, injected: Any) -> Any: + if isinstance(injected, Injection): + injected.on_inject(self, attr_name) + setattr(moss, attr_name, injected) # 初始化 pycontext variable for name, prop in pycontext.iter_props(self._compiled): # 直接用 property 作为值. - setattr(moss, name, prop) - self._injected.add(name) + inject(name, prop) # 反向注入 for name, injection in self._injections.items(): - setattr(moss, name, injection) - self._injected.add(name) + inject(name, injection) # 初始化基于容器的依赖注入. typehints = get_type_hints(moss_type, localns=self._compiled.__dict__) @@ -207,8 +216,7 @@ def _compile_moss(self) -> Moss: # 为 None 才依赖注入. value = self._container.force_fetch(typehint) # 依赖注入. - setattr(moss, name, value) - self._injected.add(name) + inject(name, value) self._compiled.__dict__[MOSS_VALUE_NAME] = moss self._compiled.__dict__[MOSS_TYPE_NAME] = moss_type @@ -240,6 +248,13 @@ def dump_pycontext(self) -> PyContext: def dump_std_output(self) -> str: return self._runtime_std_output + def pprint(self, *args: Any, **kwargs: Any) -> None: + from pprint import pprint + out = io.StringIO() + with redirect_stdout(out): + pprint(*args, **kwargs) + self._runtime_std_output += str(out.getvalue()) + @contextmanager def redirect_stdout(self): buffer = io.StringIO() diff --git a/ghostos/core/runtime/__init__.py b/ghostos/core/runtime/__init__.py index 2a54db03..ea888d0b 100644 --- a/ghostos/core/runtime/__init__.py +++ b/ghostos/core/runtime/__init__.py @@ -1,6 +1,6 @@ from ghostos.core.runtime.tasks import ( GoTaskStruct, TaskPayload, TaskBrief, - GoTasks, TaskState, + GoTasks, TaskState, TaskLocker, ) from ghostos.core.runtime.threads import GoThreads, GoThreadInfo, thread_to_chat, Turn from ghostos.core.runtime.processes import GoProcess, GoProcesses diff --git a/ghostos/core/runtime/events.py b/ghostos/core/runtime/events.py index 7dc85234..7ede2842 100644 --- a/ghostos/core/runtime/events.py +++ b/ghostos/core/runtime/events.py @@ -4,6 +4,7 @@ from enum import Enum from pydantic import BaseModel, Field from ghostos.core.messages.message import Message +from ghostos.entity import EntityMeta from ghostos.helpers import uuid from contextlib import contextmanager @@ -28,6 +29,9 @@ class Event(BaseModel): default="", description="event type" ) + context: Optional[EntityMeta] = Field( + default=None, + ) attrs: Dict[str, Any] = Field( default_factory=dict, description="event attributes that follow the types." @@ -53,6 +57,10 @@ class Event(BaseModel): default_factory=list, description="list of messages sent by this event", ) + history: Optional[List[Message]] = Field( + default=None, + description="if the event reset the history" + ) instruction: str = Field( default="", description="instruction from the event telling what to do. wrapped by system type message after the messages", @@ -93,6 +101,7 @@ def new( instruction: str = "", eid: Optional[str] = None, payloads: Optional[Dict] = None, + context: Optional[EntityMeta] = None, ) -> "Event": id_ = eid if eid else uuid() type_ = event_type @@ -107,6 +116,7 @@ def new( instruction=instruction, messages=messages, payloads=payloads, + context=context, ) @@ -119,7 +129,7 @@ class EventTypes(str, Enum): CREATED = "created" - REQUEST = "request" + INPUT = "input" NOTIFY = "notify" @@ -158,6 +168,7 @@ def new( instruction: str = "", eid: Optional[str] = None, payloads: Optional[Dict] = None, + context: Optional[EntityMeta] = None, ) -> Event: type_ = str(self.value) payloads = payloads if payloads is not None else {} @@ -171,6 +182,7 @@ def new( messages=messages, eid=eid, payloads=payloads, + context=context, ) diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index 1a29bdd7..66117885 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -47,6 +47,9 @@ def is_dead(cls, state: str) -> bool: class GoTaskStruct(BaseModel): # -- scope --- # + shell_id: str = Field( + description="the shell id of the task", + ) process_id: str = Field( description=""" the id of the process that the task belongs to. @@ -83,12 +86,14 @@ class GoTaskStruct(BaseModel): default=None, description="the context entity", ) + state_values: Dict[str, EntityMeta] = Field( + default_factory=dict, + description="the state values of the task", + ) state: str = Field( default=TaskState.NEW.value, - description=""" - the state of the current task. - """ + description="the state of the current task." ) status_desc: str = Field( @@ -144,12 +149,18 @@ class GoTaskStruct(BaseModel): default=0, description="the turn number of the task runs", ) + errors: int = Field( + default=0, + description="continual task errors count", + ) @classmethod def new( cls, *, task_id: str, + shell_id: str, process_id: str, + depth: int, name: str, description: str, meta: EntityMeta, @@ -159,7 +170,9 @@ def new( ) -> "GoTaskStruct": return GoTaskStruct( task_id=task_id, + shell_id=shell_id, process_id=process_id, + depth=depth, thread_id=task_id, parent=parent_task_id, meta=meta, @@ -257,7 +270,7 @@ class TaskPayload(Payload): task_id: str = Field(description="the id of the task") task_name: str = Field(description="the name of the task") process_id: str = Field(description="the id of the process") - session_id: str = Field(description="the session id of the task") + shell_id: str = Field(description="the session id of the task") thread_id: str = Field(description="the id of the thread") @classmethod @@ -265,7 +278,7 @@ def from_task(cls, task: GoTaskStruct) -> "TaskPayload": return cls( task_id=task.task_id, task_name=task.name, - session_id=task.shell_id, + shell_id=task.shell_id, process_id=task.process_id, thread_id=task.thread_id, ) diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index bdaf53bd..4a6335ea 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Iterable, Dict, Any +from typing import Optional, List, Iterable, Dict, Any, Self import time from abc import ABC, abstractmethod from pydantic import BaseModel, Field @@ -42,8 +42,13 @@ class Turn(BaseModel): extra: Dict[str, Any] = Field(default_factory=dict, description="extra information") @classmethod - def new(cls, event: Optional[Event], *, turn_id: Optional[str] = None, - pycontext: Optional[PyContext] = None) -> "Turn": + def new( + cls, + event: Optional[Event], + *, + turn_id: Optional[str] = None, + pycontext: Optional[PyContext] = None, + ) -> "Turn": data = {"event": event} if turn_id is None and event is not None: turn_id = event.event_id @@ -267,6 +272,15 @@ def fork(self, tid: Optional[str] = None) -> "GoThreadInfo": thread = self.model_copy(update=dict(id=tid, root_id=root_id, parent_id=parent_id), deep=True) return thread + def reset_history(self, messages: Iterable[Message]) -> Self: + forked = self.fork() + forked.history = [] + forked.current = None + on_created = Turn.new(event=None) + on_created.append(*messages) + forked.on_created = on_created + return forked + def thread_copy(self, update: Optional[dict] = None) -> "GoThreadInfo": return self.model_copy(update=update, deep=True) diff --git a/ghostos/demo/configs/ghosts.yml b/ghostos/demo/configs/ghosts.yml deleted file mode 100644 index 0bc0ea1c..00000000 --- a/ghostos/demo/configs/ghosts.yml +++ /dev/null @@ -1,18 +0,0 @@ -ghosts: - baseline: - type: "ghostos.framework.ghosts:DemoGhostConf" - data: - id: baseline - name: jojo - description: simple agent that can talk with user. - meta_prompt: |+ - You are an assistant named JoJo. - You shall chat with user friendly. - thought_meta: - type: "ghostos.thoughts:ChatThought" - data: - task_name: "chat" - task_desc: "chat with user" - llm_api: "" - instruction: Let's chat! - diff --git a/ghostos/demo/configs/llms_conf.yml b/ghostos/demo/configs/llms_conf.yml deleted file mode 100644 index 6024fe64..00000000 --- a/ghostos/demo/configs/llms_conf.yml +++ /dev/null @@ -1,59 +0,0 @@ -# DetailConfigs ghostos.framework.llms.llms::LLMsYamlConfig -services: - - name: moonshot - base_url: https://api.moonshot.cn/v1 - token: $MOONSHOT_API_KEY - - name: openai - base_url: https://api.openai.com/v1 - token: $OPENAI_API_KEY - proxy: $OPENAI_PROXY - - name: anthropic - token: $ANTHROPIC_API_KEY - proxy: $OPENAI_PROXY - base_url: https://api.anthropic.com/v1 - - name: deepseek - token: $DEEPSEEK_API_KEY - base_url: https://api.deepseek.com/beta - # proxy: $OPENAI_PROXY -# Configure default LLM API here. -default: - # service: moonshot - # model: moonshot-v1-32k - service: openai - model: gpt-4o -# The models below can be edited as you want, see details: ghostos.core.llms.configs:ModelConf -# the key of models is a `llm_api_name`, value is a ModelConf instance. -models: - moonshot-v1-8k: - service: moonshot - model: moonshot-v1-8k - moonshot-v1-32k: - service: moonshot - model: moonshot-v1-32k - moonshot-v1-128k: - service: moonshot - model: moonshot-v1-128k - gpt-3.5-turbo: - service: openai - model: gpt-3.5-turbo - gpt-4: - service: openai - model: gpt-4 - gpt-4-turbo: - service: openai - model: gpt-4-turbo - gpt-4o: - service: openai - model: gpt-4o - claude-3-5-sonnet: # 200K context window, 3$/M input, 3.75$/M cache write, 0.3$/M cache read, 15$/M output - service: anthropic - model: claude-3-5-sonnet-20240620 - claude-3-haiku: # 200K context window, 0.25$/M input, 0.3$/M cache write, 0.03$/M cache read, 1.25$/M output - service: anthropic - model: claude-3-haiku-20240307 - deepseek-chat: # 128k context window, 4k output window. 1Y/M input, 0.1Y/M cache hit, 2Y/M output - service: deepseek - model: deepseek/deepseek-chat - deepseek-coder: # 128k context window, 8k output window. 1Y/M input, 0.1Y/M cache hit, 2Y/M output - service: deepseek - model: deepseek/deepseek-coder diff --git a/ghostos/demo/configs/logging.yml b/ghostos/demo/configs/logging.yml deleted file mode 100644 index bcb03d8e..00000000 --- a/ghostos/demo/configs/logging.yml +++ /dev/null @@ -1,28 +0,0 @@ -# logging_config.yml - -version: 1 - -formatters: - default: - format: "%(asctime)s - %(name)s - %(levelname)s: %(message)s" - ghost: - format: "%(asctime)s - %(name)s - %(levelname)s: %(message)s - %(trace)s" - -handlers: - debug_file: - class: logging.FileHandler - formatter: default - filename: debug.log - console: - class: logging.StreamHandler - level: DEBUG - formatter: default - stream: ext://sys.stdout - -loggers: - debug: - handlers: [ debug_file ] - level: DEBUG - console: - handlers: [ console ] - level: DEBUG diff --git a/ghostos/demo/runtime/cache/.gitignore b/ghostos/demo/runtime/cache/.gitignore deleted file mode 100644 index c96a04f0..00000000 --- a/ghostos/demo/runtime/cache/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore \ No newline at end of file diff --git a/ghostos/demo/runtime/events/.gitignore b/ghostos/demo/runtime/events/.gitignore deleted file mode 100644 index c96a04f0..00000000 --- a/ghostos/demo/runtime/events/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore \ No newline at end of file diff --git a/ghostos/demo/runtime/processes/.gitignore b/ghostos/demo/runtime/processes/.gitignore deleted file mode 100644 index c96a04f0..00000000 --- a/ghostos/demo/runtime/processes/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore \ No newline at end of file diff --git a/ghostos/demo/runtime/tasks/.gitignore b/ghostos/demo/runtime/tasks/.gitignore deleted file mode 100644 index c96a04f0..00000000 --- a/ghostos/demo/runtime/tasks/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore \ No newline at end of file diff --git a/ghostos/demo/runtime/threads/.gitignore b/ghostos/demo/runtime/threads/.gitignore deleted file mode 100644 index c96a04f0..00000000 --- a/ghostos/demo/runtime/threads/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore \ No newline at end of file diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py new file mode 100644 index 00000000..9a50c78f --- /dev/null +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -0,0 +1,89 @@ +from typing import Optional, Iterable, List, TypeVar + +from ghostos.container import Container +from ghostos.core.abcd.concepts import Conversation, Scope, Ghost, Session +from ghostos.core.abcd.utils import get_ghost_driver +from ghostos.core.messages import Message +from ghostos.core.runtime import ( + Event, EventTypes, + GoTaskStruct, TaskLocker, GoTasks, +) +from ghostos.prompter import Prompter +from ghostos.identifier import get_identifier +from ghostos.entity import to_entity_meta + +G = TypeVar("G", bound=Ghost) + + +class ConversationImpl(Conversation[G]): + + def __init__( + self, + shell_id: str, + process_id: str, + container: Container, + task: GoTaskStruct, + task_locker: TaskLocker, + ): + self._container = container + self._scope = Scope( + shell_id=shell_id, + process_id=process_id, + task_id=task.task_id, + parent_task_id=task.parent_task_id, + ) + self._task = task + self._locker = task_locker + # ghost_id = get_identifier(self._ghost) + # task_id = ghost_driver.make_task_id(self._scope) + # tasks = container.force_fetch(GoTasks) + # task = tasks.get_task(task_id) + # context_meta = to_entity_meta(context) if context is not None else None + # if task is None: + # task = GoTaskStruct.new( + # task_id=task_id, + # shell_id=shell_id, + # process_id=process_id, + # depth=0, + # name=ghost_id.name, + # description=ghost_id.description, + # meta=to_entity_meta(ghost), + # context=context_meta, + # parent_task_id=None, + # ) + # else: + # task.meta = to_entity_meta(ghost) + # if context_meta: + # task.meta = context_meta + + def container(self) -> Container: + return self._container + + def respond( + self, + inputs: Iterable[Message], + context: Optional[G.Context] = None, + history: Optional[List[Message]] = None, + ) -> Iterable[Message]: + context_meta = to_entity_meta(context) if context is not None else None + event = EventTypes.INPUT.new( + task_id=self._task.task_id, + messages=list(inputs), + context=context_meta, + ) + return self.respond_event(event) + + def respond_event(self, event: Event) -> Iterable[Message]: + pass + + def pop_event(self) -> Optional[Event]: + pass + + def fail(self, error: Exception) -> bool: + pass + + def close(self): + pass + + def closed(self) -> bool: + pass diff --git a/ghostos/framework/ghostos/ghostos_impl.py b/ghostos/framework/ghostos/ghostos_impl.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/framework/ghostos/operation.py b/ghostos/framework/ghostos/operation.py new file mode 100644 index 00000000..0d3b243c --- /dev/null +++ b/ghostos/framework/ghostos/operation.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from ghostos.core.abcd import Operator +from ghostos.core.abcd.concepts import Operation, Session +from ghostos.core.messages import MessageKind, MessageKindParser + + +class OperationImpl(Operation): + + def __init__(self, parser: MessageKindParser, session: Session): + self.session = session + self.parser = parser + + def finish(self, status: str = "", *replies: MessageKind) -> Operator: + pass + + def fail(self, status: str = "", *replies: MessageKind) -> Operator: + pass + + def wait(self, status: str = "", *replies: MessageKind) -> Operator: + pass + + def observe(self, *messages: MessageKind) -> Operator: + pass + + def on_error(self, *messages: MessageKind) -> Operator: + pass diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py new file mode 100644 index 00000000..efd9d5df --- /dev/null +++ b/ghostos/framework/ghostos/session_impl.py @@ -0,0 +1,367 @@ +from typing import Optional, List, Iterable, Tuple, Self, TypeVar, Dict, Union + +from dataclasses import dataclass +from ghostos.core.abcd.concepts import Session, Ghost, GhostDriver, Shell, Scope, Operation, Operator +from ghostos.core.abcd.utils import get_ghost_driver +from ghostos.core.messages import MessageKind, Message, Caller, Stream, Role, MessageKindParser +from ghostos.core.runtime import ( + TaskBrief, GoTaskStruct, TaskLocker, TaskPayload, GoTasks, TaskState, + EventBus, Event, EventTypes, + GoThreads, + Messenger, GoThreadInfo, +) +from ghostos.prompter import Prompter +from ghostos.contracts.logger import wrap_logger, LoggerItf +from ghostos.container import Container +from ghostos.entity import to_entity_meta, from_entity_meta, get_entity, EntityType +from ghostos.identifier import get_identifier +from ghostos.framework.messengers import DefaultMessenger + +G = TypeVar("G", bound=Ghost) + + +class EmptyOperator(Operator): + + def run(self, session: Session) -> Union[Operator, None]: + return None + + def destroy(self): + pass + + +class SessionImpl(Session[G]): + + def __init__( + self, + container: Container, + stream: Stream, + task: GoTaskStruct, + locker: TaskLocker, + max_errors: int, + ): + # session level container + self.container = Container(parent=container) + self.upstream = stream + self.task = task + self.locker = locker + threads = container.force_fetch(GoThreads) + thread = threads.get_thread(task.thread_id, create=True) + self.thread = thread + self.scope = Scope( + shell_id=task.shell_id, + process_id=task.process_id, + task_id=task.task_id, + parent_task_id=task.parent_task_id, + ) + logger = container.force_fetch(LoggerItf) + self.logger = wrap_logger( + logger, + extra=self.scope.model_dump(), + ) + + self.ghost: G = get_entity(self.task.meta, Ghost) + self.ghost_driver: GhostDriver[G] = self.ghost.Driver(self.ghost) + identifier = get_identifier(self.ghost) + self._message_parser = MessageKindParser( + name=identifier.name, + role=Role.ASSISTANT.value, + ) + self.state = self.unmarshal_state(task) + self._max_errors = max_errors + self._fetched_task_briefs: Dict[str, TaskBrief] = {} + self._creating_tasks: Dict[str, GoTaskStruct] = {} + self._firing_events: List[Event] = [] + self._saving_threads: Dict[str, GoThreadInfo] = {} + self.subtasks = {} + self._failed = False + self._done = False + self._destroyed = False + self.refresh() + + @staticmethod + def unmarshal_state(task: GoTaskStruct) -> Dict[str, EntityType]: + state_values = {} + for key, entity_meta in task.state_values.items(): + entity = from_entity_meta(entity_meta) + state_values[key] = entity + return state_values + + def is_alive(self) -> bool: + if self._failed or self._destroyed: + return False + return self.locker.acquired() and self.upstream.alive() + + def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator]]: + driver = get_ghost_driver(self.ghost) + # always let ghost driver decide event handling logic first. + event = driver.parse_event(self, event) + if event is None: + return None, None + # notification do not trigger the handling + if EventTypes.NOTIFY.value == event.type: + self.thread.new_turn(event) + return None, None + + if EventTypes.ERROR.value == event.type: + self.task.error += 1 + if self.task.errors > self._max_errors: + # if reach max errors, fail the task + return None, self.operates().fail("task failed too much, exceeds max errors") + + if EventTypes.INPUT.value == event.type: + # only input event can reset errors. + self.task.errors = 0 + if event.context is not None: + self.task.context = event.context + if event.history: + self.thread = self.thread.reset_history(event.history) + event.history = [] + + elif self.task.is_dead(): + # dead task can only respond event from parent input. + self.thread.new_turn(event) + return None, EmptyOperator() + + if EventTypes.CANCEL.value == event.type: + # cancel self and all subtasks. + self.task.errors = 0 + self.thread.new_turn(event) + self.task.state = TaskState.CANCELLED + for child in self.subtasks.values(): + event = EventTypes.CANCEL.new( + task_id=child.task_id, + messages=[], + from_task_id=self.task.task_id, + from_task_name=self.task.name, + reason="parent task is canceled", + instruction="cancel what you are doing", + ) + self.fire_events(event) + return None, EmptyOperator() + + event.history = [] + event.context = None + return event, None + + def operates(self) -> Operation: + pass + + def get_context(self) -> Optional[Prompter]: + if self.task.context is None: + return None + return get_entity(self.task.context, Prompter) + + def get_artifact(self) -> G.Artifact: + return self.ghost_driver.get_artifact(self) + + def refresh(self) -> bool: + if self._failed or self._destroyed or not self.is_alive(): + return False + if self.locker.refresh(): + self._fetched_task_briefs = {} + self._firing_events = [] + self._creating_tasks = {} + self._saving_threads = {} + self.subtasks = {} + self.task = self.task.new_turn() + if len(self.task.children) > 0: + self.subtasks = self.get_task_briefs(*self.task.children) + return True + return False + + def messenger(self) -> Messenger: + task_payload = TaskPayload.from_task(self.task) + identity = get_identifier(self.ghost) + return DefaultMessenger( + upstream=self.upstream, + name=identity.name, + role=Role.ASSISTANT.value, + payloads=[task_payload], + ) + + def respond(self, messages: Iterable[MessageKind], remember: bool = True) -> Tuple[List[Message], List[Caller]]: + messenger = self.messenger() + messenger.send(messages) + messages, callers = messenger.flush() + if remember: + self.thread.append(*messages) + return messages, callers + + def cancel_subtask(self, ghost: G, reason: str = "") -> None: + driver = get_ghost_driver(ghost) + task_id = driver.make_task_id(self.scope) + tasks = self.container.force_fetch(GoTasks) + subtask = tasks.get_task(task_id) + if subtask is None: + return + event = EventTypes.CANCEL.new( + task_id=task_id, + reason=reason, + messages=[], + from_task_id=self.task.task_id, + from_task_name=self.task.name, + ) + self.fire_events(event) + + def send_subtask(self, ghost: G, *messages: MessageKind, ctx: Optional[G.Context] = None) -> None: + driver = get_ghost_driver(ghost) + task_id = driver.make_task_id(self.scope) + tasks = self.container.force_fetch(GoTasks) + subtask = tasks.get_task(task_id) + + event_messages = list(self._message_parser.parse(messages)) + if subtask is None: + self.create_subtask(ghost, ctx) + event = EventTypes.CREATED.new( + task_id=task_id, + messages=event_messages, + from_task_id=self.task.task_id, + from_task_name=self.task.name, + ) + self.fire_events(event) + else: + event = EventTypes.INPUT.new( + task_id=task_id, + messages=event_messages, + from_task_id=self.task.task_id, + from_task_name=self.task.name, + ) + self.fire_events(event) + + def create_subtask( + self, + ghost: G, + ctx: G.Context, + task_name: Optional[str] = None, + task_description: Optional[str] = None, + ) -> None: + driver = get_ghost_driver(ghost) + task_id = driver.make_task_id(self.scope) + identifier = get_identifier(self.ghost) + task_name = task_name or identifier.name + task_description = task_description or identifier.description + context_meta = to_entity_meta(ctx) if ctx is not None else None + task = GoTaskStruct.new( + task_id=task_id, + shell_id=self.task.shell_id, + process_id=self.task.process_id, + depth=self.task.depth + 1, + name=task_name, + description=task_description, + meta=to_entity_meta(ghost), + context=context_meta, + parent_task_id=self.task.task_id, + ) + self._creating_tasks[task_id] = task + + def create_threads(self, *threads: GoThreadInfo) -> None: + for t in threads: + self._saving_threads[t.id] = t + + def call(self, ghost: G, ctx: G.Props) -> G.Artifact: + shell = self.container.force_fetch(Shell) + return shell.call(ghost, ctx) + + def fire_events(self, *events: "Event") -> None: + self._firing_events.extend(events) + + def get_task_briefs(self, *task_ids: str) -> Dict[str, TaskBrief]: + ids = set(task_ids) + result = {} + fetch = [] + for task_id in ids: + if task_id in self._fetched_task_briefs: + result[task_id] = self._fetched_task_briefs[task_id] + else: + fetch.append(task_id) + if fetch: + tasks = self.container.force_fetch(GoTasks) + briefs = tasks.get_task_briefs(fetch) + for task_brief in briefs.values(): + result[task_brief.task_id] = task_brief + self._fetched_task_briefs[task_brief.task_id] = task_brief + return result + + def save(self) -> None: + self._update_subtasks() + self._update_state_changes() + self._do_create_tasks() + self._do_save_threads() + self._do_fire_events() + self.refresh() + + def _update_subtasks(self): + children = [] + for tid, tb in self.subtasks.items(): + if TaskState.is_dead(tb.state): + continue + children.append(tid) + self.task.children = children + + def _update_state_changes(self) -> None: + task = self.task + task.thread_id = self.thread.id + task.meta = to_entity_meta(self.ghost) + state_values = {} + for name, value in self.state: + state_values[name] = to_entity_meta(value) + thread = self.thread + task.state_values = state_values + tasks = self.container.force_fetch(GoTasks) + threads = self.container.force_fetch(GoThreads) + tasks.save_task(task) + threads.save_thread(thread) + + def _do_create_tasks(self) -> None: + tasks = self.container.force_fetch(GoTasks) + if self._creating_tasks: + tasks.save_task(*self._creating_tasks.values()) + self._creating_tasks = [] + + def _do_save_threads(self) -> None: + threads = self.container.force_fetch(GoThreads) + if self._saving_threads: + threads.save_thread(*self._saving_threads.values()) + self._saving_threads = [] + + def _do_fire_events(self) -> None: + if not self._firing_events: + return + bus = self.container.force_fetch(EventBus) + for e in self._firing_events: + # all the sub-tasks need notification + notify = True + if e.task_id == self.task.parent: + notify = self.task.depth - 1 == 0 + bus.send_event(e, notify) + self._firing_events = [] + + def fail(self, err: Optional[Exception]) -> bool: + if self._failed: + return True + self._failed = True + self.logger.error("Session failed: %s", err) + return False + + def done(self) -> None: + if not self.is_alive(): + return + self._done = True + self.save() + self.destroy() + + def destroy(self) -> None: + if self._destroyed: + return + self._destroyed = True + self.container.destroy() + del self.container + del self._firing_events + del self.subtasks + del self.task + del self.thread + del self._fetched_task_briefs + del self.state + del self.ghost + del self.ghost_driver + del self.scope diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/framework/session/basic.py b/ghostos/framework/session/basic.py index 0c582acd..b9aa7834 100644 --- a/ghostos/framework/session/basic.py +++ b/ghostos/framework/session/basic.py @@ -204,6 +204,7 @@ def _do_quit(self) -> None: self._firing_events.append(event) self._do_fire_events() + def _do_create_tasks(self) -> None: if self._creating: self._tasks.save_task(*self._creating) diff --git a/ghostos/framework/session/default.py b/ghostos/framework/session/default.py deleted file mode 100644 index 599637e7..00000000 --- a/ghostos/framework/session/default.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Optional, List, Iterable, Tuple, Self - -from ghostos.core.abcd.concepts import Session, G -from ghostos.core.messages import MessageKind, Message, Caller -from ghostos.core.runtime import TaskBrief -from ghostos.prompter import Prompter -from ghostos.container import Container - - -class SessionImpl(Session): - - def __init__( - self, - container: Container, - ): - self.container = container - stream: Stream - - scope: Scope - """the running scope of the session""" - - state: Dict[str, Union[Dict, BaseModel]] - """session state that keep session state values""" - - container: Container - """Session level container""" - - task: GoTaskStruct - """current task""" - - thread: GoThreadInfo - """thread info of the task""" - - logger: LoggerItf - - def is_alive(self) -> bool: - pass - - def get_ghost(self) -> G: - pass - - def get_context(self) -> Optional[Prompter]: - pass - - def get_artifact(self) -> G.Artifact: - pass - - def goal(self) -> G.Artifact: - pass - - def refresh(self) -> Self: - pass - - def flow(self) -> Flow: - pass - - def messenger(self) -> "Messenger": - pass - - def respond(self, messages: Iterable[MessageKind], remember: bool = True) -> Tuple[List[Message], List[Caller]]: - pass - - def cancel_subtask(self, ghost: G, reason: str = "") -> None: - pass - - def create_tasks(self, *tasks: "GoTaskStruct") -> None: - pass - - def fire_events(self, *events: "Event") -> None: - pass - - def get_task_briefs(self, *task_ids) -> List[TaskBrief]: - pass - - def save(self) -> None: - pass - - def fail(self, err: Optional[Exception]) -> bool: - pass - - def done(self) -> None: - pass - - def destroy(self) -> None: - pass \ No newline at end of file From ed42ca25695fc65664f4df94412198133bd0253f Mon Sep 17 00:00:00 2001 From: zhuming Date: Wed, 13 Nov 2024 22:47:33 +0800 Subject: [PATCH 074/148] dev: implements ghostos, start test in two days --- ghostos/core/abcd/concepts.py | 110 ++++--- ghostos/core/abcd/utils.py | 5 +- ghostos/core/agents/moss_agent.py | 4 +- ghostos/core/messages/transport.py | 8 + ghostos/core/moss/prompts.py | 3 +- ghostos/core/runtime/events.py | 4 + ghostos/core/runtime/processes.py | 40 +-- ghostos/core/runtime/tasks.py | 11 +- .../framework/ghostos/conversation_impl.py | 175 ++++++++--- ghostos/framework/ghostos/ghostos_impl.py | 56 ++++ ghostos/framework/ghostos/operation.py | 167 ++++++++++- ghostos/framework/ghostos/session_impl.py | 115 +++++-- ghostos/framework/ghostos/shell_impl.py | 283 ++++++++++++++++++ .../framework/processes/storage_processes.py | 46 +-- ghostos/identifier.py | 9 +- ghostos/prompter.py | 25 +- tests/test_container.py | 20 +- 17 files changed, 864 insertions(+), 217 deletions(-) diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index ef935410..58de3ca6 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import ( Type, Generic, Protocol, ClassVar, TypeVar, - Tuple, Optional, Iterable, List, Self, Union, Dict, + Tuple, Optional, Iterable, List, Self, Union, Dict, Any ) from abc import ABC, abstractmethod @@ -14,7 +14,7 @@ from ghostos.core.runtime.events import Event from ghostos.core.runtime.tasks import GoTaskStruct, TaskBrief from ghostos.core.runtime.threads import GoThreadInfo -from ghostos.core.messages import MessageKind, Message, Stream, Caller, Payload +from ghostos.core.messages import MessageKind, Message, Stream, Caller, Payload, Receiver from ghostos.contracts.logger import LoggerItf from ghostos.container import Container, Provider from ghostos.identifier import get_identifier @@ -52,7 +52,7 @@ __all__ = ( "Ghost", "Session", "GhostDriver", "GhostOS", "Operator", "StateValue", "Action", - "Shell", "Operation", "Scope", "Conversation", + "Shell", "Operation", "Scope", "Conversation", "Background", ) @@ -204,6 +204,7 @@ def container(self) -> Container: @abstractmethod def create_shell( self, + name: str, shell_id: str, process_id: Optional[str] = None, *providers: Provider @@ -211,7 +212,26 @@ def create_shell( pass -class Shell(Protocol): +class Background(ABC): + + @abstractmethod + def on_error(self, error: Exception) -> bool: + pass + + @abstractmethod + def on_event(self, event: Event, retriever: Receiver) -> None: + pass + + @abstractmethod + def stopped(self) -> bool: + pass + + @abstractmethod + def halt(self) -> int: + pass + + +class Shell(ABC): @abstractmethod def container(self) -> Container: @@ -249,6 +269,7 @@ def call( instructions: Optional[Iterable[Message]] = None, prompters: Optional[List[Prompter]] = None, timeout: float = 0.0, + stream: Optional[Stream] = None, ) -> Tuple[Union[G.Artifact, None], TaskState]: """ run a ghost task until it stopped, @@ -256,11 +277,7 @@ def call( pass @abstractmethod - def background_run_event( - self, - *, - timeout: float = 0.0, - ) -> Union[Event, None]: + def run_background_event(self, background: Optional[Background] = None) -> Union[Event, None]: """ run the event loop for the ghosts in the Shell. 1. pop task notification. @@ -270,11 +287,18 @@ def background_run_event( 5. send a task notification after handling, make sure someone check the task events are empty. only the tasks that depth > 0 have notifications. background run itself is blocking method, run it in a separate thread for parallel execution. - :param timeout: :return: the handled event """ pass + @abstractmethod + def background_run(self, worker: int = 4, background: Optional[Background] = None) -> None: + pass + + @abstractmethod + def close(self): + pass + class Conversation(Protocol[G]): """ @@ -288,20 +312,32 @@ def container(self) -> Container: """ pass + @abstractmethod + def task(self) -> GoTaskStruct: + pass + + @abstractmethod + def get_artifact(self) -> Tuple[Union[G.Artifact, None], TaskState]: + pass + + @abstractmethod + def ask(self, query: str, user_name: str = "") -> Receiver: + pass + @abstractmethod def respond( self, inputs: Iterable[Message], context: Optional[G.Context] = None, history: Optional[List[Message]] = None, - ) -> Iterable[Message]: + ) -> Receiver: """ create response immediately by inputs. the inputs will change to event. """ pass @abstractmethod - def respond_event(self, event: Event) -> Iterable[Message]: + def respond_event(self, event: Event) -> Receiver: """ create response to the event immediately :param event: @@ -316,6 +352,10 @@ def pop_event(self) -> Optional[Event]: """ pass + @abstractmethod + def send_event(self, event: Event) -> None: + pass + @abstractmethod def fail(self, error: Exception) -> bool: """ @@ -342,7 +382,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - if self.close(): + if self.closed(): return if exc_val is not None: return self.fail(exc_val) @@ -410,10 +450,10 @@ def finish(self, status: str = "", *replies: MessageKind) -> Operator: pass @abstractmethod - def fail(self, status: str = "", *replies: MessageKind) -> Operator: + def fail(self, reason: str = "", *replies: MessageKind) -> Operator: """ self task failed. - :param status: describe status of the task + :param reason: describe status of the task :param replies: replies to parent task or user """ pass @@ -428,7 +468,14 @@ def wait(self, status: str = "", *replies: MessageKind) -> Operator: pass @abstractmethod - def observe(self, *messages: MessageKind) -> Operator: + def observe(self, *messages: MessageKind, instruction: str = "", sync: bool = False) -> Operator: + """ + observer std output and values + :param messages: observation info. + :param instruction: instruction when receive the observation. + :param sync: if True, observe immediately, otherwise check other event first + :return: + """ pass @abstractmethod @@ -519,6 +566,9 @@ def refresh(self) -> bool: @abstractmethod def save(self): + """ + save status. + """ pass @abstractmethod @@ -616,33 +666,9 @@ def get_task_briefs(self, *task_ids: str) -> Dict[str, TaskBrief]: pass @abstractmethod - def fail(self, err: Optional[Exception]) -> bool: - """ - 任务执行异常的处理. 需要判断任务是致命的, 还是可以恢复. - :param err: - :return: - """ - pass - - @abstractmethod - def done(self) -> None: + def __enter__(self): pass @abstractmethod - def destroy(self) -> None: - """ - 手动清理数据, 方便垃圾回收. - """ - pass - - def __enter__(self) -> "Session": - return self - def __exit__(self, exc_type, exc_val, exc_tb): - intercept = None - if exc_val is not None: - intercept = self.fail(exc_val) - else: - self.done() - self.destroy() - return intercept + pass diff --git a/ghostos/core/abcd/utils.py b/ghostos/core/abcd/utils.py index dbd5107e..d326ed6a 100644 --- a/ghostos/core/abcd/utils.py +++ b/ghostos/core/abcd/utils.py @@ -1,13 +1,13 @@ from typing import Optional, Type, Union -from ghostos.helpers import import_class_from_path, md5 +from ghostos.helpers import import_class_from_path from ghostos.identifier import get_identifier -from ghostos.core.runtime import Runtime, GoTaskStruct from ghostos.entity import to_entity_meta from .concepts import Ghost, GhostDriver, Session, Operator from ghostos.core.runtime import Event __all__ = [ 'get_ghost_driver', 'get_ghost_driver_type', 'is_ghost', + 'run_session_event', 'fire_session_event', ] @@ -80,5 +80,6 @@ def run_session_event(session: Session, event: Event, max_step: int) -> None: next_op = op.run(session) session.logger.info("done session op %s", op) op.destroy() + # session do save after each op session.save() op = next_op diff --git a/ghostos/core/agents/moss_agent.py b/ghostos/core/agents/moss_agent.py index 3a34af27..d2c90eb0 100644 --- a/ghostos/core/agents/moss_agent.py +++ b/ghostos/core/agents/moss_agent.py @@ -306,7 +306,7 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: message = caller.new_output(output) if op is None: # if std output is not empty, and op is none, observe the output as default. - return session.flow().observe(message) + return session.operates().observe(message) else: session.respond([message], remember=True) return op @@ -317,4 +317,4 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: @staticmethod def fire_error(session: Session, caller: Caller, error: str) -> Operator: message = caller.new_output(error) - return session.flow().on_error(message) + return session.operates().on_error(message) diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index 7ba1f38e..28b373da 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -103,6 +103,10 @@ def error(self) -> Optional[Message]: def close(self): pass + @abstractmethod + def wait(self): + pass + def __enter__(self): return self @@ -178,6 +182,10 @@ def done(self) -> bool: def error(self) -> Optional[Message]: return self._error + def wait(self): + while not self._done and not self._closed and not self._error: + time.sleep(self._idle) + def close(self): if self._closed: return diff --git a/ghostos/core/moss/prompts.py b/ghostos/core/moss/prompts.py index 761dc634..79812d35 100644 --- a/ghostos/core/moss/prompts.py +++ b/ghostos/core/moss/prompts.py @@ -5,6 +5,7 @@ ) from ghostos.prompter import get_defined_prompt from pydantic import BaseModel +from dataclasses import is_dataclass import inspect """ @@ -129,7 +130,7 @@ def get_prompt(value: Any) -> Optional[str]: if inspect.isclass(value): # only reflect abstract class - if inspect.isabstract(value) or issubclass(value, BaseModel): + if inspect.isabstract(value) or issubclass(value, BaseModel) or is_dataclass(value): source = inspect.getsource(value) if source: return source diff --git a/ghostos/core/runtime/events.py b/ghostos/core/runtime/events.py index 7ede2842..43b2773b 100644 --- a/ghostos/core/runtime/events.py +++ b/ghostos/core/runtime/events.py @@ -102,6 +102,7 @@ def new( eid: Optional[str] = None, payloads: Optional[Dict] = None, context: Optional[EntityMeta] = None, + history: Optional[List[Message]] = None, ) -> "Event": id_ = eid if eid else uuid() type_ = event_type @@ -117,6 +118,7 @@ def new( messages=messages, payloads=payloads, context=context, + history=history, ) @@ -169,6 +171,7 @@ def new( eid: Optional[str] = None, payloads: Optional[Dict] = None, context: Optional[EntityMeta] = None, + history: Optional[List[Message]] = None, ) -> Event: type_ = str(self.value) payloads = payloads if payloads is not None else {} @@ -183,6 +186,7 @@ def new( eid=eid, payloads=payloads, context=context, + history=history, ) diff --git a/ghostos/core/runtime/processes.py b/ghostos/core/runtime/processes.py index 57d6e41f..65018da9 100644 --- a/ghostos/core/runtime/processes.py +++ b/ghostos/core/runtime/processes.py @@ -19,43 +19,20 @@ class GoProcess(BaseModel): """, ) - session_id: str = Field( + shell_id: str = Field( description="session id in which the process belongs", ) - main_task_id: str = Field( - description=""" -The main task is the root task of the process task tree. -""", - ) - ghost_meta: EntityMeta = Field( - description=""" -The meta data that waken the sleeping ghost in disputed services. -""" - ) - initialized: bool = Field( - default=False, - description="if the process is initialized or not.", - ) - quited: bool = Field( - default=False, - description="if the process is quited or not.", - ) @classmethod def new( cls, *, - session_id: str, - ghost_meta: EntityMeta, + shell_id: str, process_id: Optional[str] = None, - main_task_id: Optional[str] = None, ) -> "GoProcess": process_id = process_id if process_id else uuid() - main_task_id = process_id if main_task_id is None else main_task_id return GoProcess( - session_id=session_id, + shell_id=shell_id, process_id=process_id, - main_task_id=main_task_id, - ghost_meta=ghost_meta, ) @@ -65,17 +42,10 @@ class GoProcesses(ABC): """ @abstractmethod - def get_process(self, process_id: str) -> Optional[GoProcess]: + def get_process(self, shell_id: str) -> Optional[GoProcess]: """ get process by id - :param process_id: process id - """ - pass - - @abstractmethod - def get_session_process(self, session_id: str) -> Optional[GoProcess]: - """ - get session process by session id + :param shell_id: shell id """ pass diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index 66117885..e721f745 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -47,6 +47,11 @@ def is_dead(cls, state: str) -> bool: class GoTaskStruct(BaseModel): # -- scope --- # + task_id: str = Field( + description=""" + the id of the task. + """, + ) shell_id: str = Field( description="the shell id of the task", ) @@ -55,11 +60,7 @@ class GoTaskStruct(BaseModel): the id of the process that the task belongs to. """, ) - task_id: str = Field( - description=""" - the id of the task. - """, - ) + thread_id: str = Field( description=""" the id of the thread that contains the context of the task. diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index 9a50c78f..a736d150 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -1,16 +1,37 @@ -from typing import Optional, Iterable, List, TypeVar +from typing import Optional, Iterable, List, TypeVar, Tuple, Union from ghostos.container import Container -from ghostos.core.abcd.concepts import Conversation, Scope, Ghost, Session -from ghostos.core.abcd.utils import get_ghost_driver -from ghostos.core.messages import Message +from ghostos.core.abcd.concepts import Conversation, Scope, Ghost +from ghostos.core.abcd.utils import run_session_event +from ghostos.core.messages import ( + Message, Role, + Stream, Receiver, new_arr_connection, +) from ghostos.core.runtime import ( - Event, EventTypes, - GoTaskStruct, TaskLocker, GoTasks, + Event, EventTypes, EventBus, + GoTaskStruct, TaskLocker, GoTasks, TaskState, ) -from ghostos.prompter import Prompter -from ghostos.identifier import get_identifier +from ghostos.contracts.pool import Pool +from ghostos.contracts.logger import LoggerItf, wrap_logger from ghostos.entity import to_entity_meta +from pydantic import BaseModel, Field +from .session_impl import SessionImpl + + +class ConversationConf(BaseModel): + message_receiver_idle: float = Field( + 0.05, + description="The time in seconds to wait between retrievals", + ) + max_session_step: int = Field( + 10, + description="The maximum number of steps to run session event", + ) + max_task_errors: int = Field( + 3, + description="The maximum error number of task", + ) + G = TypeVar("G", bound=Ghost) @@ -19,71 +40,141 @@ class ConversationImpl(Conversation[G]): def __init__( self, - shell_id: str, - process_id: str, + conf: ConversationConf, container: Container, task: GoTaskStruct, task_locker: TaskLocker, + is_background: bool, ): + self._conf = conf self._container = container self._scope = Scope( - shell_id=shell_id, - process_id=process_id, + shell_id=task.shell_id, + process_id=task.process_id, task_id=task.task_id, parent_task_id=task.parent_task_id, ) - self._task = task + self._pool = self._container.force_fetch(Pool) + logger = container.force_fetch(LoggerItf) + self._logger = wrap_logger(logger, self._scope.model_dump()) + self._is_background = is_background self._locker = task_locker - # ghost_id = get_identifier(self._ghost) - # task_id = ghost_driver.make_task_id(self._scope) - # tasks = container.force_fetch(GoTasks) - # task = tasks.get_task(task_id) - # context_meta = to_entity_meta(context) if context is not None else None - # if task is None: - # task = GoTaskStruct.new( - # task_id=task_id, - # shell_id=shell_id, - # process_id=process_id, - # depth=0, - # name=ghost_id.name, - # description=ghost_id.description, - # meta=to_entity_meta(ghost), - # context=context_meta, - # parent_task_id=None, - # ) - # else: - # task.meta = to_entity_meta(ghost) - # if context_meta: - # task.meta = context_meta + self._tasks = container.force_fetch(GoTasks) + self._eventbus = container.force_fetch(EventBus) + self._closed = False + self._bootstrap() + + def _bootstrap(self): + self._container.set(LoggerItf, self._logger) + self._container.bootstrap() def container(self) -> Container: + self._validate_closed() return self._container + def task(self) -> GoTaskStruct: + return self._tasks.get_task(self._scope.task_id) + + def get_artifact(self) -> Tuple[Union[G.Artifact, None], TaskState]: + task = self.task() + session = self._create_session(task, self._locker, None) + with session: + return session.get_artifact(), TaskState(session.task.state) + + def ask(self, query: str, user_name: str = "") -> Receiver: + message = Role.USER.new(content=query, name=user_name) + return self.respond([message]) + def respond( self, inputs: Iterable[Message], context: Optional[G.Context] = None, history: Optional[List[Message]] = None, - ) -> Iterable[Message]: + ) -> Receiver: + self._validate_closed() context_meta = to_entity_meta(context) if context is not None else None event = EventTypes.INPUT.new( - task_id=self._task.task_id, + task_id=self._scope.task_id, messages=list(inputs), context=context_meta, + history=history, ) return self.respond_event(event) - def respond_event(self, event: Event) -> Iterable[Message]: - pass + def respond_event( + self, + event: Event, + timeout: float = 0.0, + ) -> Receiver: + self._validate_closed() + stream, retriever = new_arr_connection( + timeout=timeout, + idle=self._conf.message_receiver_idle, + complete_only=self._is_background, + ) + self._pool.submit(self._submit_session_event, event, stream) + return retriever + + def _validate_closed(self): + if self._closed: + raise RuntimeError(f"Conversation is closed") + + def _submit_session_event(self, event: Event, stream: Stream) -> None: + with stream: + task = self._tasks.get_task(event.task_id) + session = self._create_session(task, self._locker, stream) + with session: + try: + run_session_event(session, event, self._conf.max_session_step) + self._eventbus.notify_task(event.task_id) + except Exception as e: + self.fail(error=e) + + def _create_session( + self, + task: GoTaskStruct, + locker: TaskLocker, + stream: Optional[Stream], + ) -> SessionImpl: + container = Container(parent=self._container) + return SessionImpl( + container=container, + stream=stream, + task=task, + locker=locker, + max_errors=self._conf.max_task_errors, + ) def pop_event(self) -> Optional[Event]: - pass + return self._eventbus.pop_task_event(self._scope.task_id) + + def send_event(self, event: Event) -> None: + task = self._tasks.get_task(event.task_id) + notify = True + if task: + notify = task.depth > 0 + self._eventbus.send_event(event, notify) def fail(self, error: Exception) -> bool: - pass + if self._closed: + return False + self.close() + return False def close(self): - pass + if self._closed: + return + self._closed = True + self._locker.release() + self._destroy() + + def _destroy(self): + self._container.destroy() + del self._container + del self._tasks + del self._eventbus + del self._pool + del self._logger def closed(self) -> bool: - pass + return self._closed diff --git a/ghostos/framework/ghostos/ghostos_impl.py b/ghostos/framework/ghostos/ghostos_impl.py index e69de29b..b155a20c 100644 --- a/ghostos/framework/ghostos/ghostos_impl.py +++ b/ghostos/framework/ghostos/ghostos_impl.py @@ -0,0 +1,56 @@ +from typing import Optional, Dict + +from ghostos.core.abcd.concepts import GhostOS, Shell +from ghostos.core.runtime import GoProcesses, GoProcess +from ghostos.container import Container, Provider +from ghostos.contracts.configs import Configs, YamlConfig +from pydantic import BaseModel, Field +from .shell_impl import ShellImpl, ShellConf + + +class GhostOSConfig(YamlConfig): + relative_path = "ghostos.yml" + shells: Dict[str, ShellConf] = Field( + description="the shell configurations", + ) + + +class GhostOSImpl(GhostOS): + + def __init__( + self, + container: Container, + ): + self._container = container + self._processes = container.force_fetch(GoProcesses) + self._configs = container.force_fetch(Configs) + self._ghostos_config = self._configs.get(GhostOSConfig) + + def container(self) -> Container: + return self._container + + def create_shell( + self, + name: str, + shell_id: str, + process_id: Optional[str] = None, + *providers: Provider, + ) -> Shell: + if name not in self._ghostos_config.shells: + raise NotImplementedError(f"Shell `{name}` not implemented") + shell_conf = self._ghostos_config.shells[name] + process = self._processes.get_process(shell_id) + if process is None: + process = GoProcess.new(shell_id=shell_id, process_id=process_id) + elif process_id is not None and process.process_id != process_id: + process = GoProcess.new(shell_id=shell_id, process_id=process_id) + self._processes.save_process(process) + + # prepare container + container = Container(parent=self._container) + return ShellImpl( + config=shell_conf, + container=container, + process=process, + *providers, + ) diff --git a/ghostos/framework/ghostos/operation.py b/ghostos/framework/ghostos/operation.py index 0d3b243c..48e36088 100644 --- a/ghostos/framework/ghostos/operation.py +++ b/ghostos/framework/ghostos/operation.py @@ -1,27 +1,170 @@ from __future__ import annotations -from ghostos.core.abcd import Operator -from ghostos.core.abcd.concepts import Operation, Session -from ghostos.core.messages import MessageKind, MessageKindParser +from typing import Union, List +from abc import ABC + +from ghostos.core.abcd.concepts import Operation, Session, Operator +from ghostos.core.abcd.utils import fire_session_event +from ghostos.core.runtime import TaskState, EventTypes +from ghostos.core.messages import MessageKind, MessageKindParser, Message, Role class OperationImpl(Operation): - def __init__(self, parser: MessageKindParser, session: Session): - self.session = session + def __init__(self, parser: MessageKindParser): self.parser = parser def finish(self, status: str = "", *replies: MessageKind) -> Operator: - pass + messages = self.parser.parse(replies) + return FinishOperator(status, list(messages)) - def fail(self, status: str = "", *replies: MessageKind) -> Operator: - pass + def fail(self, reason: str = "", *replies: MessageKind) -> Operator: + messages = self.parser.parse(replies) + return FailOperator(reason, list(messages)) def wait(self, status: str = "", *replies: MessageKind) -> Operator: - pass + messages = self.parser.parse(replies) + return WaitOperator(status, list(messages)) - def observe(self, *messages: MessageKind) -> Operator: - pass + def observe(self, *messages: MessageKind, instruction: str = "", sync: bool = False) -> Operator: + messages = self.parser.parse(messages) + return ObservationOperator(list(messages), instruction, sync) def on_error(self, *messages: MessageKind) -> Operator: - pass + messages = self.parser.parse(messages) + return ErrorOperator(list(messages)) + + +class AbcOperator(Operator, ABC): + + def __init__( + self, + status: str, + messages: List[Message], + ): + self.status = status + self.messages = messages + + def destroy(self): + del self.messages + + +class ErrorOperator(Operator): + + def __init__(self, messages: List[Message]): + self.messages = messages + + def run(self, session: Session) -> Union[Operator, None]: + task = session.task + event = EventTypes.ERROR.new( + task_id=task.task_id, + messages=self.messages, + from_task_id=task.task_id, + from_task_name=task.name, + ) + return fire_session_event(session, event) + + def destroy(self): + del self.messages + + +class ObservationOperator(Operator): + + def __init__(self, messages: List[Message], instruction: str, sync: bool): + self.messages = messages + self.instruction = instruction + self.sync: bool = sync + + def run(self, session: Session) -> Union[Operator, None]: + if len(self.messages) == 0 and not self.instruction: + return None + + task = session.task + event = EventTypes.ROTATE.new( + task_id=task.task_id, + messages=self.messages, + from_task_id=task.task_id, + from_task_name=task.name, + instruction=self.instruction, + ) + if self.sync: + return fire_session_event(session, event) + else: + msg = Role.SYSTEM.new(content=f"issue observation at turn {task.turns}") + session.thread.append(msg) + event.reason = f"receive observation at turn {task.turns}" + session.fire_events(event) + return None + + def destroy(self): + del self.messages + + +class FailOperator(Operator): + def __init__( + self, + reason: str, + messages: List[Message], + ): + self.reason = reason + self.messages = messages + + def run(self, session: Session) -> Union[Operator, None]: + task = session.task + session.task.state = TaskState.FAILED.value + session.task.status_desc = f"[FAILED] {self.reason}" + if task.parent: + event = EventTypes.FAILURE_CALLBACK.new( + task_id=task.parent, + messages=self.messages, + from_task_id=task.task_id, + from_task_name=task.name, + reason=f"task {task.name} is failed: {self.reason}", + ) + session.fire_events(event) + elif self.messages: + session.respond(self.messages) + return None + + def destroy(self): + del self.messages + + +class WaitOperator(AbcOperator, ABC): + + def run(self, session: Session) -> Union[Operator, None]: + if len(self.messages) > 0: + task = session.task + task.state = TaskState.WAITING.value + task.status_desc = self.status + if task.parent: + event = EventTypes.WAIT_CALLBACK.new( + task_id=task.parent, + messages=self.messages, + from_task_id=task.task_id, + from_task_name=task.name, + ) + session.fire_events(event) + else: + session.respond(self.messages) + return None + + +class FinishOperator(AbcOperator): + + def run(self, session: Session) -> Union[Operator, None]: + task = session.task + session.task.state = TaskState.FINISHED.value + session.task.status_desc = self.status + if task.parent: + event = EventTypes.FINISH_CALLBACK.new( + task_id=task.parent, + messages=self.messages, + from_task_id=task.task_id, + from_task_name=task.name, + reason=f"task {task.name} is finished." + ) + session.fire_events(event) + elif self.messages: + session.respond(self.messages) + return None diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index efd9d5df..768d4ca7 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -1,9 +1,10 @@ -from typing import Optional, List, Iterable, Tuple, Self, TypeVar, Dict, Union +from typing import Optional, List, Iterable, Tuple, TypeVar, Dict, Union -from dataclasses import dataclass from ghostos.core.abcd.concepts import Session, Ghost, GhostDriver, Shell, Scope, Operation, Operator from ghostos.core.abcd.utils import get_ghost_driver -from ghostos.core.messages import MessageKind, Message, Caller, Stream, Role, MessageKindParser +from ghostos.core.messages import ( + MessageKind, Message, Caller, Stream, Role, MessageKindParser, MessageType +) from ghostos.core.runtime import ( TaskBrief, GoTaskStruct, TaskLocker, TaskPayload, GoTasks, TaskState, EventBus, Event, EventTypes, @@ -12,10 +13,11 @@ ) from ghostos.prompter import Prompter from ghostos.contracts.logger import wrap_logger, LoggerItf -from ghostos.container import Container +from ghostos.container import Container, provide, Contracts from ghostos.entity import to_entity_meta, from_entity_meta, get_entity, EntityType from ghostos.identifier import get_identifier from ghostos.framework.messengers import DefaultMessenger +from .operation import OperationImpl G = TypeVar("G", bound=Ghost) @@ -30,17 +32,22 @@ def destroy(self): class SessionImpl(Session[G]): + contracts = Contracts([ + GoThreads, + GoTasks, + EventBus, + ]) def __init__( self, container: Container, - stream: Stream, + stream: Optional[Stream], task: GoTaskStruct, locker: TaskLocker, max_errors: int, ): # session level container - self.container = Container(parent=container) + self.container = container self.upstream = stream self.task = task self.locker = locker @@ -76,7 +83,20 @@ def __init__( self._failed = False self._done = False self._destroyed = False - self.refresh() + self._bootstrap() + if not self.refresh(): + raise RuntimeError(f"Failed to start session") + + def _bootstrap(self): + self.contracts.validate(self.container) + self.container.set(Session, self) + self.container.set(LoggerItf, self.logger) + self.container.set(Scope, self.scope) + self.container.register(provide(GoTaskStruct, False)(lambda c: self.task)) + self.container.register(provide(GoThreadInfo, False)(lambda c: self.thread)) + self.container.register(provide(Operation, False)(lambda c: self.operates())) + self.container.register(provide(Messenger, False)(lambda c: self.messenger())) + self.container.bootstrap() @staticmethod def unmarshal_state(task: GoTaskStruct) -> Dict[str, EntityType]: @@ -91,7 +111,12 @@ def is_alive(self) -> bool: return False return self.locker.acquired() and self.upstream.alive() + def _validate_alive(self): + if not self.is_alive(): + raise RuntimeError(f"Session is not alive") + def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator]]: + self._validate_alive() driver = get_ghost_driver(self.ghost) # always let ghost driver decide event handling logic first. event = driver.parse_event(self, event) @@ -102,12 +127,6 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] self.thread.new_turn(event) return None, None - if EventTypes.ERROR.value == event.type: - self.task.error += 1 - if self.task.errors > self._max_errors: - # if reach max errors, fail the task - return None, self.operates().fail("task failed too much, exceeds max errors") - if EventTypes.INPUT.value == event.type: # only input event can reset errors. self.task.errors = 0 @@ -117,11 +136,18 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] self.thread = self.thread.reset_history(event.history) event.history = [] + # other event elif self.task.is_dead(): # dead task can only respond event from parent input. self.thread.new_turn(event) return None, EmptyOperator() + if EventTypes.ERROR.value == event.type: + self.task.error += 1 + if self.task.errors > self._max_errors: + # if reach max errors, fail the task + return None, self.operates().fail("task failed too much, exceeds max errors") + if EventTypes.CANCEL.value == event.type: # cancel self and all subtasks. self.task.errors = 0 @@ -144,7 +170,8 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] return event, None def operates(self) -> Operation: - pass + self._validate_alive() + return OperationImpl(self._message_parser) def get_context(self) -> Optional[Prompter]: if self.task.context is None: @@ -157,19 +184,22 @@ def get_artifact(self) -> G.Artifact: def refresh(self) -> bool: if self._failed or self._destroyed or not self.is_alive(): return False - if self.locker.refresh(): - self._fetched_task_briefs = {} - self._firing_events = [] - self._creating_tasks = {} - self._saving_threads = {} - self.subtasks = {} - self.task = self.task.new_turn() - if len(self.task.children) > 0: - self.subtasks = self.get_task_briefs(*self.task.children) - return True - return False + return self.locker.refresh() + + def _reset(self): + self._fetched_task_briefs = {} + self._firing_events = [] + self._creating_tasks = {} + self._saving_threads = {} + self.subtasks = {} + self.task = self.task.new_turn() + if len(self.task.children) > 0: + subtasks = self.get_task_briefs(*self.task.children) + subtasks = sorted(subtasks.items(), key=lambda x: x.updated) + self.subtasks = {tid: t for tid, t in subtasks} def messenger(self) -> Messenger: + self._validate_alive() task_payload = TaskPayload.from_task(self.task) identity = get_identifier(self.ghost) return DefaultMessenger( @@ -180,6 +210,7 @@ def messenger(self) -> Messenger: ) def respond(self, messages: Iterable[MessageKind], remember: bool = True) -> Tuple[List[Message], List[Caller]]: + self._validate_alive() messenger = self.messenger() messenger.send(messages) messages, callers = messenger.flush() @@ -188,6 +219,7 @@ def respond(self, messages: Iterable[MessageKind], remember: bool = True) -> Tup return messages, callers def cancel_subtask(self, ghost: G, reason: str = "") -> None: + self._validate_alive() driver = get_ghost_driver(ghost) task_id = driver.make_task_id(self.scope) tasks = self.container.force_fetch(GoTasks) @@ -204,6 +236,7 @@ def cancel_subtask(self, ghost: G, reason: str = "") -> None: self.fire_events(event) def send_subtask(self, ghost: G, *messages: MessageKind, ctx: Optional[G.Context] = None) -> None: + self._validate_alive() driver = get_ghost_driver(ghost) task_id = driver.make_task_id(self.scope) tasks = self.container.force_fetch(GoTasks) @@ -235,6 +268,7 @@ def create_subtask( task_name: Optional[str] = None, task_description: Optional[str] = None, ) -> None: + self._validate_alive() driver = get_ghost_driver(ghost) task_id = driver.make_task_id(self.scope) identifier = get_identifier(self.ghost) @@ -255,17 +289,21 @@ def create_subtask( self._creating_tasks[task_id] = task def create_threads(self, *threads: GoThreadInfo) -> None: + self._validate_alive() for t in threads: self._saving_threads[t.id] = t def call(self, ghost: G, ctx: G.Props) -> G.Artifact: + self._validate_alive() shell = self.container.force_fetch(Shell) return shell.call(ghost, ctx) def fire_events(self, *events: "Event") -> None: + self._validate_alive() self._firing_events.extend(events) def get_task_briefs(self, *task_ids: str) -> Dict[str, TaskBrief]: + self._validate_alive() ids = set(task_ids) result = {} fetch = [] @@ -283,12 +321,13 @@ def get_task_briefs(self, *task_ids: str) -> Dict[str, TaskBrief]: return result def save(self) -> None: + self._validate_alive() self._update_subtasks() self._update_state_changes() self._do_create_tasks() self._do_save_threads() self._do_fire_events() - self.refresh() + self._reset() def _update_subtasks(self): children = [] @@ -336,24 +375,34 @@ def _do_fire_events(self) -> None: bus.send_event(e, notify) self._firing_events = [] + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_val is not None: + intercepted = self.fail(exc_val) + self.destroy() + return intercepted + else: + self.save() + self.destroy() + def fail(self, err: Optional[Exception]) -> bool: if self._failed: return True self._failed = True self.logger.error("Session failed: %s", err) + if self.upstream is not None: + message = MessageType.ERROR.new(content=str(err)) + self.upstream.deliver(message) return False - def done(self) -> None: - if not self.is_alive(): - return - self._done = True - self.save() - self.destroy() - def destroy(self) -> None: if self._destroyed: return self._destroyed = True + self.locker.release() + del self.locker self.container.destroy() del self.container del self._firing_events diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index e69de29b..cf3c028b 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -0,0 +1,283 @@ +import time +from typing import Union, Optional, Iterable, List, Tuple, TypeVar + +from ghostos.container import Container +from ghostos.core.abcd.concepts import Shell, Conversation, Ghost, Scope, Background +from ghostos.core.abcd.utils import get_ghost_driver +from ghostos.core.messages import Message, Receiver +from ghostos.core.runtime import ( + Event, GoProcess, EventBus, + GoTasks, TaskState, GoTaskStruct, +) +from ghostos.core.messages import Stream +from ghostos.prompter import Prompter +from ghostos.container import Provider +from ghostos.helpers import uuid, Timeleft +from ghostos.identifier import get_identifier +from ghostos.entity import to_entity_meta +from ghostos.prompter import TextPrmt +from pydantic import BaseModel, Field +from threading import Thread +from .conversation_impl import ConversationImpl, ConversationConf + + +class ShellConf(BaseModel): + persona: str = Field( + description="the persona of the shell root agents", + ) + max_session_steps: int = Field( + default=10, + ) + max_task_errors: int = Field( + default=3, + ) + background_idle_time: float = Field(0.5) + + +G = TypeVar("G", bound=Ghost) + + +class ShellImpl(Shell): + + def __init__( + self, + config: ShellConf, + container: Container, + process: GoProcess, + *providers: Provider, + ): + self._conf = config + # prepare container + for provider in providers: + container.register(provider) + self._container = container + # bind self + self._container.set(Shell, self) + self._container.set(ShellImpl, self) + self._container.set(ShellConf, config) + self._shell_id = process.shell_id + self._process_id = process.process_id + self._scope = Scope( + shell_id=self._shell_id, + process_id=self._process_id, + task_id=self._process_id, + ) + self._eventbus = container.force_fetch(EventBus) + self._tasks = container.force_fetch(GoTasks) + self._workers: List[Thread] = [] + self._closed = False + self._background_started = False + # bootstrap the container. + self._container.bootstrap() + + def container(self) -> Container: + return self._container + + def send_event(self, event: Event) -> None: + task_id = event.task_id + task = self._tasks.get_task(task_id) + notify = True + if task: + notify = task.depth > 0 + self._eventbus.send_event(event, notify) + + def sync(self, ghost: G, context: Union[G.Context, None]) -> Conversation: + driver = get_ghost_driver(ghost) + task_id = driver.make_task_id(self._scope) + task = self._tasks.get_task(task_id) + if task is None: + task = self.create_root_task(ghost, context) + task.meta = to_entity_meta(ghost) + if context is not None: + task.context = to_entity_meta(context) + conversation = self.sync_task(task, throw=True, is_background=False) + if context is not None: + return conversation + raise RuntimeError(f'Cannot sync ghost') + + def sync_task( + self, + task: GoTaskStruct, + *, + throw: bool, + is_background: bool, + ) -> Optional[Conversation]: + locker = self._tasks.lock_task(task.task_id) + if locker.acquire(): + conf = ConversationConf( + max_session_steps=self._conf.max_session_steps, + max_task_errors=self._conf.max_task_errors, + ) + self._tasks.save_task(task) + return ConversationImpl( + conf=conf, + container=Container(parent=self._container), + task=task, + task_locker=locker, + is_background=is_background, + ) + elif throw: + raise RuntimeError(f'Task {task.task_id} already locked') + return None + + def call( + self, + ghost: G, context: G.Context, + instructions: Optional[Iterable[Message]] = None, + prompters: Optional[List[Prompter]] = None, + timeout: float = 0.0, + stream: Optional[Stream] = None, + ) -> Tuple[Union[G.Artifact, None], TaskState]: + + def send_message(receiver: Receiver): + with receiver: + if stream: + stream.send(receiver.recv()) + else: + receiver.wait() + + timeleft = Timeleft(timeout) + task = self.create_root_task(ghost, context) + conversation = self.sync_task(task, throw=True, is_background=False) + with conversation: + r = conversation.respond(instructions) + send_message(r) + + while timeleft.alive(): + task = conversation.task() + if task.is_dead(): + break + e = conversation.pop_event() + if e is not None: + r = conversation.respond_event(e) + send_message(r) + else: + conversation.ask("continue to fulfill your task or fail") + return conversation.get_artifact() + + def create_root_task( + self, + ghost: G, + context: G.Context, + prompters: Optional[List[Prompter]] = None, + ) -> GoTaskStruct: + task_id = uuid() + id_ = get_identifier(ghost) + if prompters: + if context is None: + context = TextPrmt().with_children(*prompters) + elif isinstance(context, Prompter): + context = context.add_child(*prompters) + + context_meta = to_entity_meta(context) if context else None + task = GoTaskStruct.new( + task_id=task_id, + shell_id=self._scope.shell_id, + process_id=self._scope.process_id, + depth=0, + name=id_.name, + description=id_.description, + meta=to_entity_meta(ghost), + context=context_meta, + parent_task_id=None, + ) + self._tasks.save_task(task) + return task + + def run_background_event( + self, + background: Optional[Background] = None, + ) -> Union[Event, None]: + self._validate_closed() + task_id = self._eventbus.pop_task_notification() + if task_id is None: + return None + + task = self._tasks.get_task(task_id) + if task is None: + self._eventbus.clear_task(task_id) + return None + + conversation = self.sync_task(task, False, True) + if conversation is None: + return None + + def on_event(e: Event, r: Receiver) -> None: + if background: + background.on_event(e, r) + + with conversation: + event = conversation.pop_event() + if event is None: + return None + try: + receiver = conversation.respond_event(event) + with receiver: + on_event(event, receiver) + receiver.wait() + return event + except Exception as err: + if background: + intercepted = background.on_error(err) + if not intercepted: + raise + finally: + self._eventbus.notify_task(self._scope.task_id) + + def background_run(self, worker: int = 4, background: Optional[Background] = None) -> None: + self._validate_closed() + if self._background_started: + raise RuntimeError(f'background run already started') + + for i in range(worker): + t = Thread(target=self._run_background_worker, args=(background,)) + t.start() + self._workers.append(t) + + def _run_background_worker(self, background: Optional[Background] = None): + def is_stopped() -> bool: + if self._closed: + return True + if background: + return background.stopped() + return False + + def idle(): + time.sleep(self._conf.background_idle_time) + + def halt() -> bool: + if background: + halt_time = background.halt() + if halt_time > 0: + time.sleep(halt_time) + return True + return False + + while not is_stopped(): + if halt(): + continue + try: + handled_event = self.run_background_event(background) + if handled_event: + continue + except Exception as err: + self.close() + return + idle() + + def _validate_closed(self): + if self._closed: + raise RuntimeError(f'Shell is closed') + + def close(self): + if self._closed: + return + self._closed = True + if self._workers: + for t in self._workers: + t.join() + self._container.destroy() + del self._container + del self._workers + del self._eventbus + del self._tasks diff --git a/ghostos/framework/processes/storage_processes.py b/ghostos/framework/processes/storage_processes.py index 8e2c6b1b..72393ca3 100644 --- a/ghostos/framework/processes/storage_processes.py +++ b/ghostos/framework/processes/storage_processes.py @@ -5,8 +5,8 @@ from ghostos.contracts.storage import Storage from ghostos.contracts.logger import LoggerItf from ghostos.contracts.workspace import Workspace -from threading import Lock from ghostos.container import Provider, Container +from ghostos.helpers import yaml_pretty_dump __all__ = ['StorageGoProcessesImpl', 'StorageProcessImplProvider', 'WorkspaceProcessesProvider'] @@ -17,7 +17,6 @@ class StorageGoProcessesImpl(GoProcesses): def __init__(self, storage: Storage, logger: LoggerItf): self._storage = storage self._logger = logger - self._lock = Lock() def _get_session_process_map(self) -> Dict[str, str]: filename = self.session_map_name @@ -28,40 +27,23 @@ def _get_session_process_map(self) -> Dict[str, str]: return {} @staticmethod - def _get_process_filename(process_id: str) -> str: - return f"{process_id}.process.yml" + def _get_process_filename(shell_id: str) -> str: + return f"{shell_id}.process.yml" - def get_process(self, process_id: str) -> Optional[GoProcess]: - filename = self._get_process_filename(process_id) - if self._storage.exists(filename): - content = self._storage.get(filename) - data = yaml.safe_load(content) - process = GoProcess(**data) - return process - return None - - def _save_session_process_map(self, session_map: Dict[str, str]) -> None: - content = yaml.safe_dump(session_map) - filename = self.session_map_name - self._storage.put(filename, content.encode("utf-8")) - - def get_session_process(self, session_id: str) -> Optional[GoProcess]: - m = self._get_session_process_map() - process_id = m.get(session_id, None) - if process_id is None: + def get_process(self, shell_id: str) -> Optional[GoProcess]: + filename = self._get_process_filename(shell_id) + if not self._storage.exists(filename): return None - return self.get_process(process_id) + content = self._storage.get(filename) + data = yaml.safe_load(content) + process = GoProcess(**data) + return process def save_process(self, process: GoProcess) -> None: - session_id = process.session_id - process_id = process.process_id - with self._lock: - m = self._get_session_process_map() - m[session_id] = process_id - self._save_session_process_map(m) - content = yaml.safe_dump(process.model_dump(exclude_defaults=True)) - filename = self._get_process_filename(process_id) - self._storage.put(filename, content.encode("utf-8")) + filename = self._get_process_filename(process.shell_id) + data = process.model_dump(exclude_defaults=True) + content = yaml_pretty_dump(data) + self._storage.put(filename, content.encode()) class StorageProcessImplProvider(Provider[GoProcesses]): diff --git a/ghostos/identifier.py b/ghostos/identifier.py index a5fdb749..f4072eb3 100644 --- a/ghostos/identifier.py +++ b/ghostos/identifier.py @@ -19,10 +19,17 @@ ] -def get_identifier(value: Any, throw: bool = False) -> Union[Identifier, None]: +def get_identifier(value: Any) -> Identifier: """ get identifier or not from any value """ + id_ = try_get_identifier(value) + if id_ is None: + raise AttributeError(f'{value} is not an identifier') + return id_ + + +def try_get_identifier(value: Any, throw: bool = False) -> Union[Identifier, None]: try: if value is None: return None diff --git a/ghostos/prompter.py b/ghostos/prompter.py index 9b2344ea..bd69968f 100644 --- a/ghostos/prompter.py +++ b/ghostos/prompter.py @@ -2,10 +2,10 @@ import inspect from typing import ( - List, Self, Union, Callable, Any, Protocol, Optional, Dict, TypeVar, Type, Generic, + List, Self, Union, Callable, Any, Protocol, Optional, Dict, TypeVar, Type, Generic, Set, ) from abc import ABC, abstractmethod -from types import ModuleType, FunctionType +from types import ModuleType from ghostos.container import Container from ghostos.helpers import generate_import_path, import_class_from_path, import_from_path from pydantic import BaseModel, Field @@ -80,17 +80,22 @@ class Prompter(EntityClass, ABC): priority: int = Field(default=0, description='Priority of this prompter.') - __children__: Optional[List[Prompter]] = None + __children__: Optional[Set[Prompter]] = None """ children is fractal sub context nodes""" __self_prompt__: Optional[str] = None - def with_children(self, *children: Prompter) -> Prompter: - if self.__children__ is None: - self.__children__ = [] + def with_children(self, *children: Prompter) -> Self: children = list(children) if len(children) > 0: - self.__children__.extend(children) + self.add_child(*children) + return self + + def add_child(self, *prompters: Prompter) -> Self: + if self.__children__ is None: + self.__children__ = set() + for prompter in prompters: + self.__children__.add(prompter) return self @abstractmethod @@ -110,7 +115,7 @@ def get_title(self) -> str: pass def get_priority(self) -> int: - return 0 + return self.priority def get_prompt(self, container: Container, depth: int = 0) -> str: """ @@ -123,8 +128,10 @@ def get_prompt(self, container: Container, depth: int = 0) -> str: return self.__self_prompt__ title = self.get_title() + depth = depth if title: title = '#' * (depth + 1) + ' ' + title + depth = depth + 1 self_prompt = self.self_prompt(container) prompts = [] @@ -133,7 +140,7 @@ def get_prompt(self, container: Container, depth: int = 0) -> str: if self.__children__ is not None: for child in self.__children__: - child_prompt = child.get_prompt(container, depth=depth + 1) + child_prompt = child.get_prompt(container, depth=depth) if child_prompt: prompts.append(child_prompt) # empty prompts diff --git a/tests/test_container.py b/tests/test_container.py index 3ae3f23b..96c0e5cd 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -3,7 +3,7 @@ from abc import ABCMeta, abstractmethod from typing import Type, Dict, get_args, get_origin -from ghostos.container import Container, Provider, ABSTRACT +from ghostos.container import Container, Provider, provide def test_container_baseline(): @@ -117,3 +117,21 @@ def factory(self, con: Container) -> int: assert p.singleton() assert p.factory(con) == 3 assert p.contract() is int + + +def test_provide_with_lambda(): + container = Container() + container.register(provide(int)(lambda c: 10)) + container.register(provide(str)(lambda c: "hello")) + + assert container.force_fetch(int) == 10 + assert container.force_fetch(str) == "hello" + + +def test_provide_in_loop(): + container = Container() + for a, fn in {int: lambda c: 10, str: lambda c: "hello"}.items(): + container.register(provide(a)(fn)) + + assert container.force_fetch(int) == 10 + assert container.force_fetch(str) == "hello" From ad7fb836f6822d5efe578eec0dc5edf060845889 Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 14 Nov 2024 18:17:01 +0800 Subject: [PATCH 075/148] dev: implements variable message --- ghostos/bootstrap.py | 16 +- ghostos/contracts/variables.py | 77 +++---- ghostos/core/abcd/concepts.py | 212 ++++++++++-------- ghostos/core/agents/moss_agent.py | 4 +- ghostos/core/messages/__init__.py | 6 +- ghostos/core/messages/message.py | 101 +++------ ghostos/core/messages/message_classes.py | 135 +++++++---- ghostos/core/messages/openai.py | 47 ++-- ghostos/core/moss/__init__.py | 2 +- ghostos/core/moss/abcd.py | 4 + ghostos/core/moss/impl.py | 15 +- ghostos/core/runtime/threads.py | 4 +- ghostos/framework/ghostos/session_impl.py | 24 +- ghostos/framework/ghostos/shell_impl.py | 17 +- .../{operation.py => taskflow_impl.py} | 86 ++++++- ghostos/framework/llms/openai_driver.py | 27 ++- ghostos/framework/llms/providers.py | 6 +- ghostos/framework/variables/__init__.py | 7 + ghostos/framework/variables/variables_impl.py | 77 +++++++ ghostos/identifier.py | 2 +- ghostos/prompter.py | 69 +++--- tests/core/messages/test_message_parser.py | 22 +- tests/core/moss/examples/test_baseline.py | 1 + tests/framework/eventbuses/test_mem_impl.py | 2 +- tests/framework/tasks/test_storage_impl.py | 2 + tests/framework/variables/test_variables.py | 28 +++ tests/python/test_pydantic.py | 11 + tests/test_prompter.py | 16 +- 28 files changed, 665 insertions(+), 355 deletions(-) rename ghostos/framework/ghostos/{operation.py => taskflow_impl.py} (64%) create mode 100644 ghostos/framework/variables/__init__.py create mode 100644 ghostos/framework/variables/variables_impl.py create mode 100644 tests/framework/variables/test_variables.py diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 4fb4e3f3..74f40829 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -89,11 +89,12 @@ def default_application_contracts() -> Contracts: Application level contracts """ from ghostos.core.moss import MossCompiler + from ghostos.core.messages.openai import OpenAIMessageParser from ghostos.contracts.pool import Pool from ghostos.contracts.shutdown import Shutdown from ghostos.contracts.modules import Modules from ghostos.contracts.workspace import Workspace - from ghostos.entity import EntityFactory + from ghostos.contracts.variables import Variables from ghostos.framework.configs import Configs from ghostos.framework.processes import GoProcesses from ghostos.framework.threads import GoThreads @@ -108,6 +109,7 @@ def default_application_contracts() -> Contracts: # workspace contracts Workspace, # application workspace implementation Configs, # application configs repository + Variables, # system contracts Pool, # multi-thread or process pool to submit async tasks @@ -117,10 +119,12 @@ def default_application_contracts() -> Contracts: LoggerItf, # the logger instance of application Modules, # the import_module proxy - EntityFactory, # wrap and un-wrap Entity class DocumentRegistry, + # messages + OpenAIMessageParser, + # moss MossCompiler, @@ -154,6 +158,7 @@ def default_application_providers( from ghostos.contracts.shutdown import ShutdownProvider from ghostos.contracts.modules import DefaultModulesProvider from ghostos.core.moss import DefaultMOSSProvider + from ghostos.core.messages.openai import DefaultOpenAIParserProvider from ghostos.framework.workspaces import BasicWorkspaceProvider from ghostos.framework.configs import WorkspaceConfigsProvider from ghostos.framework.processes import WorkspaceProcessesProvider @@ -162,7 +167,7 @@ def default_application_providers( from ghostos.framework.eventbuses import MemEventBusImplProvider from ghostos.framework.llms import ConfigBasedLLMsProvider, PromptStorageInWorkspaceProvider from ghostos.framework.logger import NamedLoggerProvider - from ghostos.framework.entities import EntityFactoryProvider + from ghostos.framework.variables import WorkspaceVariablesProvider from ghostos.core.aifunc import DefaultAIFuncExecutorProvider, AIFuncRepoByConfigsProvider from ghostos.framework.documents import ConfiguredDocumentRegistryProvider return [ @@ -180,6 +185,10 @@ def default_application_providers( WorkspaceProcessesProvider(runtime_processes_dir), WorkspaceTasksProvider(runtime_tasks_dir), ConfiguredDocumentRegistryProvider("documents_registry.yml"), + WorkspaceVariablesProvider(), + + # --- messages --- # + DefaultOpenAIParserProvider(), # --- session ---# MsgThreadsRepoByWorkSpaceProvider(runtime_threads_dir), @@ -194,7 +203,6 @@ def default_application_providers( PromptStorageInWorkspaceProvider(), # --- basic library --- # - EntityFactoryProvider(), DefaultModulesProvider(), ShutdownProvider(), # WorkspaceTranslationProvider("translations"), diff --git a/ghostos/contracts/variables.py b/ghostos/contracts/variables.py index 84388705..5902e8d8 100644 --- a/ghostos/contracts/variables.py +++ b/ghostos/contracts/variables.py @@ -1,53 +1,48 @@ -from typing import AnyStr, TypedDict, Required, Union, Dict, List, TypeVar, Type, Optional -from typing_extensions import Self +from typing import Union, TypeVar, Type, Optional, Any from abc import ABC, abstractmethod -from pydantic import BaseModel +from pydantic import BaseModel, Field +from ghostos.entity import EntityType +T = TypeVar("T", bound=object) -class VarPtr(TypedDict): - id: Required[str] - kind: Required[str] - - -class VarData(TypedDict): - id: Required[str] - kind: Required[str] - data: Required[bytes] - - -class Var(ABC): - @abstractmethod - def marshal(self) -> bytes: - pass - - @classmethod - @abstractmethod - def unmarshal(cls, value: bytes) -> Self: - pass - - def ptr(self) -> VarPtr: - pass - - -V = TypeVar('V', Var, BaseModel, Dict, List, str, int, float) +__all__ = ['Variables'] class Variables(ABC): + """ + global library that save value to a pointer, + by the vid can get the value instance further. + """ + + class Var(BaseModel): + """ + unique pointer of a variable + """ + vid: str = Field(description="unique variable id") + type: str = Field(description="variable class type") + desc: str = Field(description="description about the variable") @abstractmethod - def save(self, v: Union[Var, BaseModel, dict, list, str, int, float]) -> VarPtr: + def save( + self, + val: Union[BaseModel, dict, list, str, int, float, bool, EntityType, Any], + desc: str = "", + ) -> Var: + """ + save a value and get a Var pointer, with vid can get its instance by load + :param val: a value that is serializable, at least can be serialized by pickle + :param desc: description about the variable, save to Var pointer + :return: the pointer of the value + """ pass @abstractmethod - def load(self, vid: str, wrapper: Type[V]) -> Optional[V]: + def load(self, vid: str, expect: Optional[Type[T]] = None, force: bool = False) -> Optional[T]: + """ + load a saved value by vid. + :param vid: unique variable id in the Var pointer + :param expect: if the expect type is given, throw error if value is not fit + :param force: if True, raise Error if value is None + :return: the unmarshalled value instance + """ pass - - -class ModelVar(Var): - - def __init__(self, model: BaseModel): - pass - - -class PickleVar(Var): - pass diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index 58de3ca6..20da7ad3 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -52,7 +52,7 @@ __all__ = ( "Ghost", "Session", "GhostDriver", "GhostOS", "Operator", "StateValue", "Action", - "Shell", "Operation", "Scope", "Conversation", "Background", + "Shell", "Taskflow", "Scope", "Conversation", "Background", ) @@ -252,7 +252,7 @@ def send_event(self, event: Event) -> None: def sync( self, ghost: G, - context: Union[G.Context, None], + context: Optional[G.Context] = None, ) -> Conversation[G]: """ create a top-level conversation with a ghost. @@ -265,9 +265,8 @@ def sync( def call( self, ghost: G, - context: G.Context, + context: Optional[G.Context] = None, instructions: Optional[Iterable[Message]] = None, - prompters: Optional[List[Prompter]] = None, timeout: float = 0.0, stream: Optional[Stream] = None, ) -> Tuple[Union[G.Artifact, None], TaskState]: @@ -434,55 +433,6 @@ def get_or_bind(self, session: Session) -> Self: return value -class Operation(ABC): - """ - default operations - """ - - # --- 基本操作 --- # - @abstractmethod - def finish(self, status: str = "", *replies: MessageKind) -> Operator: - """ - finish self task - :param status: describe status of the task - :param replies: replies to parent task or user - """ - pass - - @abstractmethod - def fail(self, reason: str = "", *replies: MessageKind) -> Operator: - """ - self task failed. - :param reason: describe status of the task - :param replies: replies to parent task or user - """ - pass - - @abstractmethod - def wait(self, status: str = "", *replies: MessageKind) -> Operator: - """ - wait for the parent task or user to provide more information or further instruction. - :param status: describe current status - :param replies: question, inform or - """ - pass - - @abstractmethod - def observe(self, *messages: MessageKind, instruction: str = "", sync: bool = False) -> Operator: - """ - observer std output and values - :param messages: observation info. - :param instruction: instruction when receive the observation. - :param sync: if True, observe immediately, otherwise check other event first - :return: - """ - pass - - @abstractmethod - def on_error(self, *messages: MessageKind) -> Operator: - pass - - class Scope(BaseModel): """ scope of the session. @@ -539,6 +489,10 @@ def is_alive(self) -> bool: """ pass + @abstractmethod + def to_messages(self, values: Iterable[Union[MessageKind, Any]]) -> List[Message]: + pass + @abstractmethod def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator]]: pass @@ -572,7 +526,10 @@ def save(self): pass @abstractmethod - def operates(self) -> Operation: + def taskflow(self) -> Taskflow: + """ + basic library to operates the current task + """ pass @abstractmethod @@ -595,41 +552,6 @@ def respond( """ pass - @abstractmethod - def cancel_subtask(self, ghost: G, reason: str = "") -> None: - """ - 取消子任务. - :param ghost: - :param reason: - :return: - """ - pass - - @abstractmethod - def send_subtask(self, ghost: G, *messages: MessageKind, ctx: Optional[G.Props] = None) -> None: - """ - 发送消息给子任务. 如果子任务不存在, 会创建. - 子任务会通过 event 与父任务通讯. - :param ghost: - :param messages: - :param ctx: - :return: - """ - pass - - @abstractmethod - def create_subtask( - self, - ghost: G, - ctx: G.Context, - task_name: Optional[str] = None, - task_description: Optional[str] = None, - ) -> None: - """ - 创建子任务并运行. - """ - pass - @abstractmethod def create_threads( self, @@ -672,3 +594,115 @@ def __enter__(self): @abstractmethod def __exit__(self, exc_type, exc_val, exc_tb): pass + + +class Taskflow(Prompter, ABC): + """ + default operations + """ + MessageKind = Union[str, Message, Any] + """message kind shall be string or serializable object""" + + # --- 基本操作 --- # + @abstractmethod + def finish(self, status: str = "", *replies: MessageKind) -> Operator: + """ + finish self task + :param status: describe status of the task + :param replies: replies to parent task or user + """ + pass + + @abstractmethod + def fail(self, reason: str = "", *replies: MessageKind) -> Operator: + """ + self task failed. + :param reason: describe status of the task + :param replies: replies to parent task or user + """ + pass + + @abstractmethod + def wait(self, status: str = "", *replies: MessageKind) -> Operator: + """ + wait for the parent task or user to provide more information or further instruction. + :param status: describe current status + :param replies: question, inform or + """ + pass + + @abstractmethod + def think(self, *messages: MessageKind, instruction: str = "", sync: bool = False) -> Operator: + """ + start next round thinking on messages + :param messages: observe target + :param instruction: instruction when receive the observation. + :param sync: if True, observe immediately, otherwise check other event first + :return: + """ + pass + + @abstractmethod + def observe(self, **kwargs) -> Operator: + """ + observe values + :param kwargs: + :return: + """ + + @abstractmethod + def error(self, *messages: MessageKind) -> Operator: + pass + + +class Subtasks(Prompter, ABC): + """ + library that can handle async subtasks by other ghost instance. + """ + MessageKind = Union[str, Message, Any] + """message kind shall be string or serializable object""" + + @abstractmethod + def cancel(self, name: str, reason: str = "") -> None: + """ + cancel an exists subtask + :param name: name of the task + :param reason: the reason to cancel it + :return: + """ + pass + + @abstractmethod + def send( + self, + name: str, + *messages: MessageKind, + ctx: Optional[Ghost.Context] = None, + ) -> None: + """ + send message to an existing subtask + :param name: name of the subtask + :param messages: the messages to the subtask + :param ctx: if given, update the ghost context of the task + :return: + """ + pass + + @abstractmethod + def create( + self, + ghost: Ghost, + instruction: str = "", + ctx: Optional[Ghost.Context] = None, + task_name: Optional[str] = None, + task_description: Optional[str] = None, + ) -> None: + """ + create subtask from a ghost instance + :param ghost: the ghost instance that handle the task + :param instruction: instruction to the ghost + :param ctx: the context that the ghost instance needed + :param task_name: if not given, use the ghost's name as the task name + :param task_description: if not given, use the ghost's description as the task description + """ + pass diff --git a/ghostos/core/agents/moss_agent.py b/ghostos/core/agents/moss_agent.py index d2c90eb0..26296fd6 100644 --- a/ghostos/core/agents/moss_agent.py +++ b/ghostos/core/agents/moss_agent.py @@ -306,7 +306,7 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: message = caller.new_output(output) if op is None: # if std output is not empty, and op is none, observe the output as default. - return session.operates().observe(message) + return session.taskflow().think(message) else: session.respond([message], remember=True) return op @@ -317,4 +317,4 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: @staticmethod def fire_error(session: Session, caller: Caller, error: str) -> Operator: message = caller.new_output(error) - return session.operates().on_error(message) + return session.taskflow().error(message) diff --git a/ghostos/core/messages/__init__.py b/ghostos/core/messages/__init__.py index 8271879b..3a0971d4 100644 --- a/ghostos/core/messages/__init__.py +++ b/ghostos/core/messages/__init__.py @@ -1,7 +1,11 @@ from ghostos.core.messages.message import ( Message, Role, MessageType, Caller, CallerOutput, - MessageClass, MessageKind, MessageKindParser, + MessageClass, MessageKind, + MessageClassesParser, +) +from ghostos.core.messages.message_classes import ( + MessageKindParser, VariableMessage, ) from ghostos.core.messages.payload import Payload from ghostos.core.messages.openai import ( diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 44d0a19e..4cc6fe24 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -1,17 +1,19 @@ from __future__ import annotations import enum import time -from typing import Optional, Dict, Set, Iterable, Union, List, Any, ClassVar +from typing import Optional, Dict, Set, Iterable, Union, List, Any, ClassVar, Type from typing_extensions import Self, Literal from abc import ABC, abstractmethod from pydantic import BaseModel, Field from ghostos.helpers import uuid +from ghostos.container import Container +from ghostos.entity import EntityType from copy import deepcopy __all__ = [ "Message", "Role", "MessageType", - "MessageClass", - "MessageKind", "MessageKindParser", + "MessageClass", "MessageClassesParser", + "MessageKind", "Caller", "CallerOutput", ] @@ -427,7 +429,7 @@ class MessageClass(ABC): A message class with every field that is strong-typed the payloads and attachments shall parse to dict when generate to a Message. """ - message_type: ClassVar[Union[MessageType, str]] + __message_type__: ClassVar[Union[MessageType, str]] @abstractmethod def to_message(self) -> Message: @@ -444,7 +446,7 @@ def from_message(cls, container: Message) -> Optional[Self]: pass @abstractmethod - def to_openai_param(self) -> Dict: + def to_openai_param(self, container: Optional[Container]) -> List[Dict]: pass @@ -469,6 +471,8 @@ def new_output(self, output: str) -> CallerOutput: class CallerOutput(BaseModel, MessageClass): + __message_type__ = MessageType.FUNCTION_OUTPUT.value + call_id: Optional[str] = Field(None, description="caller id") name: str = Field(description="caller name") content: Optional[str] = Field(description="caller output") @@ -492,7 +496,7 @@ def from_message(cls, container: Message) -> Optional[Self]: output=container.content, ) - def to_openai_param(self) -> Dict: + def to_openai_param(self, container: Optional[Container]) -> Dict: from openai.types.chat.chat_completion_tool_message_param import ChatCompletionToolMessageParam from openai.types.chat.chat_completion_function_message_param import ChatCompletionFunctionMessageParam if self.call_id: @@ -509,75 +513,28 @@ def to_openai_param(self) -> Dict: ) -class MessageClasses: +class MessageClassesParser: def __init__( self, - classes: Iterable[MessageClass], + classes: Iterable[Type[MessageClass]], ) -> None: - self.classes = {str(cls.message_type): cls for cls in classes} - - def parse(self, messages: List[Message]) -> List[MessageClass]: - result = [] - for message in messages: - if not message.is_complete(): - continue - if message.type not in self.classes: - continue - cls = self.classes[message.type] - item = cls.from_message(message) - if item is not None: - result.append(item) - return result - - def to_openai_params(self, messages: List[Message]) -> List[Dict]: - parsed = self.parse(messages) - result = [] - for message in parsed: - result.append(message.to_openai_param()) - return result - - -MessageKind = Union[Message, MessageClass, str] -"""sometimes we need three forms of the message to define an argument or property.""" + self.classes = {str(cls.__message_type__): cls for cls in classes} + def parse(self, message: Message) -> Optional[MessageClass]: + if not message.is_complete(): + return None + if message.type not in self.classes: + return None + cls = self.classes[message.type] + item = cls.from_message(message) + return item -class MessageKindParser: - """ - middleware that parse weak MessageKind into Message chunks - """ + def to_openai_params(self, message: Message, container: Optional[Container]) -> Optional[List[Dict]]: + parsed = self.parse(message) + if parsed is None: + return None + return parsed.to_openai_param(container) - def __init__( - self, *, - name: Optional[str] = None, - role: str = Role.ASSISTANT.value, - ref_id: Optional[str] = None, - ) -> None: - self.role = role - self.ref_id = ref_id - self.name = name - - def parse(self, messages: Iterable[MessageKind]) -> Iterable[Message]: - for item in messages: - if isinstance(item, Message): - yield self._with_ref(item) - if isinstance(item, MessageClass): - msg = item.to_message() - yield self._with_ref(msg) - if isinstance(item, str): - if not item: - # exclude empty message - continue - msg = Message.new_tail(content=item, role=self.role) - yield self._with_ref(msg) - else: - # todo: 需要日志? - pass - - def _with_ref(self, item: Message) -> Message: - if self.ref_id is not None: - item.ref_id = self.ref_id - if not item.role and self.role: - item.role = self.role - if not item.name and self.name: - item.name = self.name - return item + +MessageKind = Union[Message, MessageClass, str, EntityType] +"""sometimes we need three forms of the message to define an argument or property.""" diff --git a/ghostos/core/messages/message_classes.py b/ghostos/core/messages/message_classes.py index 6e875a2c..46944e1b 100644 --- a/ghostos/core/messages/message_classes.py +++ b/ghostos/core/messages/message_classes.py @@ -1,63 +1,122 @@ -from typing import Optional, Dict +from typing import Optional, Dict, List, Iterable, Any, Union from typing_extensions import Self -from .message import Message, MessageClass, MessageType +from ghostos.contracts.variables import Variables +from ghostos.container import Container +from ghostos.prompter import get_defined_prompt +from .message import Message, MessageClass, MessageType, CallerOutput, MessageKind, Role from pydantic import BaseModel, Field +__all__ = ["VariableMessage", "CallerOutput", "MessageKindParser"] -class DefaultMsgCls(MessageClass): - message_type = MessageType.DEFAULT - message: Message - def __init__(self, message: Message): - self.message = message - - def to_message(self) -> Message: - return self.message - - @classmethod - def from_message(cls, container: Message) -> Optional[Self]: - if container.is_complete(): - return cls(container) - return None - - def to_openai_param(self) -> Dict: - raise NotImplementedError("todo") - - -class VariableMsgCls(MessageClass, BaseModel): +class VariableMessage(MessageClass, BaseModel): """ 变量类型消息. """ - message_type: MessageType.VARIABLE + + __message_type__ = MessageType.VARIABLE.value role: str = Field(default="", description="who send the message") name: Optional[str] = Field(None, description="who send the message") - vid: str = Field(description="variable unique id") - type: str = Field(description="variable type, used to unmarshal the variable. could be any str, or import path") - description: str = Field("", description="Description of the variable") + + attrs: Variables.Var = Field( + description="variable pointer info" + ) + payloads: Dict[str, Dict] = Field( + default_factory=dict, + description="payload type key to payload item. payload shall be a strong-typed dict" + ) def to_message(self) -> Message: - return Message.new_tail( + message = Message.new_tail( type_=MessageType.VARIABLE.value, content="", role=self.role, name=self.name, - attrs=self.model_dump(include={"vid", "type", "description"}) + attrs=self.attrs.model_dump(), ) + message.payloads = self.payloads + return message @classmethod - def from_message(cls, container: Message) -> Optional[Self]: - if container.type != MessageType.VARIABLE.value: + def from_message(cls, message: Message) -> Optional[Self]: + if message.type != MessageType.VARIABLE.value: return None - data = container.attrs - if data is None: - return None - data["name"] = container.name - data["role"] = container.role - obj = cls(**data) + obj = cls( + role=message.role, + name=message.name, + attrs=message.attrs, + payloads=message.payloads, + ) return obj - def to_openai_param(self) -> Dict: - pass + def to_openai_param(self, container: Optional[Container]) -> List[Dict]: + content = f"""variable message: +vid: {self.attrs.vid} +type: {self.attrs.type} +desc: {self.attrs.desc} +""" + if container and container.bound(Variables): + variables = container.force_fetch(Variables) + v = variables.load(self.attrs.vid) + prompt = get_defined_prompt(v) + if prompt: + content += f"\nmore information:\n```\n{prompt}\n```" + + return [dict( + content=content, + role=self.role, + name=self.name, + )] + + +class MessageKindParser: + """ + middleware that parse weak MessageKind into Message chunks + """ + + def __init__( + self, + variables: Variables, + *, + name: Optional[str] = None, + role: str = Role.ASSISTANT.value, + ref_id: Optional[str] = None, + ) -> None: + self.variables = variables + self.role = role + self.ref_id = ref_id + self.name = name + + def parse(self, messages: Iterable[Union[MessageKind, Any]]) -> Iterable[Message]: + for item in messages: + if isinstance(item, Message): + yield self._with_ref(item) + if isinstance(item, MessageClass): + msg = item.to_message() + yield self._with_ref(msg) + if isinstance(item, str): + if not item: + # exclude empty message + continue + msg = Message.new_tail(content=item, role=self.role) + yield self._with_ref(msg) + else: + var = self.variables.save(item) + vm = VariableMessage( + name=self.name, + role=self.role, + attrs=var.model_dump(), + ) + yield vm.to_message() + + def _with_ref(self, item: Message) -> Message: + if self.ref_id is not None: + item.ref_id = self.ref_id + if not item.role and self.role: + item.role = self.role + if not item.name and self.name: + item.name = self.name + return item diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index 80b3ccf1..064b545d 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -1,4 +1,3 @@ -import time from typing import Iterable, Optional, Type, ClassVar, List from abc import ABC, abstractmethod from openai.types.chat.chat_completion_chunk import ChoiceDelta, ChatCompletionChunk @@ -12,7 +11,12 @@ from openai.types.chat.chat_completion_function_message_param import ChatCompletionFunctionMessageParam from openai.types.chat.chat_completion_tool_message_param import ChatCompletionToolMessageParam -from ghostos.core.messages import Message, MessageType, Role, Caller, CallerOutput, Payload, MessageClass +from ghostos.core.messages import ( + Message, MessageType, Role, Caller, Payload, MessageClass, MessageClassesParser +) +from ghostos.core.messages.message_classes import ( + CallerOutput, VariableMessage, +) from ghostos.container import Provider, Container from ghostos.helpers import import_class_from_path @@ -28,7 +32,10 @@ class OpenAIMessageParser(ABC): """ @abstractmethod - def parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: + def parse_message( + self, + message: Message, + ) -> Iterable[ChatCompletionMessageParam]: """ parse a Message to OpenAI chat completion message form. OpenAI's input message (ChatCompletionXXXParam) are different to ChatCompletion types, @@ -89,19 +96,28 @@ class DefaultOpenAIMessageParser(OpenAIMessageParser): default implementation of OpenAIMessageParser """ - def __init__(self, message_classes: List[Type[MessageClass]]): - self.message_classes = message_classes + def __init__( + self, + message_classes: Optional[List[Type[MessageClass]]], + container: Optional[Container], + ): + if message_classes is None: + message_classes = [ + CallerOutput, + VariableMessage, + ] + self.class_parser = MessageClassesParser(message_classes) + self.container = container def parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: if not message.is_complete(): return [] - # message class first. - for message_class in self.message_classes: - wrapped = message_class.from_message(message) - if wrapped is not None: - return [wrapped.to_openai_param()] - return self._parse_message(message) + wrapped = self.class_parser.to_openai_params(message, self.container) + if wrapped is not None: + yield from wrapped + else: + yield from self._parse_message(message) def _parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: if message.role == Role.ASSISTANT: @@ -250,11 +266,8 @@ class DefaultOpenAIParserProvider(Provider[OpenAIMessageParser]): """ def __init__(self, message_classes: Optional[List[str]] = None): - if message_classes is None: - classes = [ - CallerOutput, - ] - else: + classes = None + if message_classes is not None: classes = [] for import_path in message_classes: cls = import_class_from_path(import_path, MessageClass) @@ -265,4 +278,4 @@ def singleton(self) -> bool: return True def factory(self, con: Container) -> Optional[OpenAIMessageParser]: - return DefaultOpenAIMessageParser(self._message_classes) + return DefaultOpenAIMessageParser(self._message_classes, con) diff --git a/ghostos/core/moss/__init__.py b/ghostos/core/moss/__init__.py index f91d5b2e..d95ea1c2 100644 --- a/ghostos/core/moss/__init__.py +++ b/ghostos/core/moss/__init__.py @@ -1,7 +1,7 @@ from ghostos.container import Container from ghostos.core.moss.abcd import ( Moss, MossCompiler, MossRuntime, MossPrompter, Execution, - AttrPrompts, + AttrPrompts, Injection, MOSS_VALUE_NAME, MOSS_TYPE_NAME, MOSS_HIDDEN_MARK, MOSS_HIDDEN_UNMARK, ) from ghostos.core.moss.impl import DefaultMOSSProvider diff --git a/ghostos/core/moss/abcd.py b/ghostos/core/moss/abcd.py index 3aa48e32..90fb7602 100644 --- a/ghostos/core/moss/abcd.py +++ b/ghostos/core/moss/abcd.py @@ -101,6 +101,10 @@ class Injection(ABC): def on_inject(self, runtime: MossRuntime, property_name: str) -> Self: pass + @abstractmethod + def on_destroy(self) -> None: + pass + class MossCompiler(ABC): """ diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index d37c0037..579b664b 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -139,10 +139,6 @@ def __setattr__(self, _name, _value): self.__pycontext__.set_prop(_name, _value) self.__dict__[_name] = _value - def destroy(self) -> None: - del self.__pycontext__ - del self.__container__ - stub = MossType() # 反向注入. for name, value in cls.__dict__.items(): @@ -193,6 +189,7 @@ def inject(attr_name: str, injected: Any) -> Any: if isinstance(injected, Injection): injected.on_inject(self, attr_name) setattr(moss, attr_name, injected) + self._injected.add(attr_name) # 初始化 pycontext variable for name, prop in pycontext.iter_props(self._compiled): @@ -321,8 +318,11 @@ def destroy(self) -> None: if self._destroyed: return self._destroyed = True - if hasattr(self._moss, "destroy"): - self._moss.destroy() + data = self._moss.__dict__ + for val in data.values(): + if isinstance(val, Injection): + val.on_destroy() + self._moss.__dict__ = {} self._container.destroy() del self._container del self._injections @@ -330,6 +330,9 @@ def destroy(self) -> None: del self._moss del self._pycontext + def __del__(self): + self.destroy() + class DefaultMOSSProvider(Provider[MossCompiler]): """ diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index 4a6335ea..b119d297 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -36,8 +36,8 @@ class Turn(BaseModel): default_factory=PyContext, description="The PyContext instance", ) - created: float = Field( - default_factory=lambda: round(time.time(), 4), + created: int = Field( + default_factory=lambda: int(round(time.time(), 0)), ) extra: Dict[str, Any] = Field(default_factory=dict, description="extra information") diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 768d4ca7..4882ff10 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -1,6 +1,6 @@ -from typing import Optional, List, Iterable, Tuple, TypeVar, Dict, Union +from typing import Optional, List, Iterable, Tuple, TypeVar, Dict, Union, Any -from ghostos.core.abcd.concepts import Session, Ghost, GhostDriver, Shell, Scope, Operation, Operator +from ghostos.core.abcd.concepts import Session, Ghost, GhostDriver, Shell, Scope, Taskflow, Operator from ghostos.core.abcd.utils import get_ghost_driver from ghostos.core.messages import ( MessageKind, Message, Caller, Stream, Role, MessageKindParser, MessageType @@ -13,11 +13,12 @@ ) from ghostos.prompter import Prompter from ghostos.contracts.logger import wrap_logger, LoggerItf +from ghostos.contracts.variables import Variables from ghostos.container import Container, provide, Contracts from ghostos.entity import to_entity_meta, from_entity_meta, get_entity, EntityType from ghostos.identifier import get_identifier from ghostos.framework.messengers import DefaultMessenger -from .operation import OperationImpl +from .taskflow_impl import TaskflowImpl G = TypeVar("G", bound=Ghost) @@ -69,7 +70,9 @@ def __init__( self.ghost: G = get_entity(self.task.meta, Ghost) self.ghost_driver: GhostDriver[G] = self.ghost.Driver(self.ghost) identifier = get_identifier(self.ghost) + variables = container.force_fetch(Variables) self._message_parser = MessageKindParser( + variables, name=identifier.name, role=Role.ASSISTANT.value, ) @@ -92,9 +95,10 @@ def _bootstrap(self): self.container.set(Session, self) self.container.set(LoggerItf, self.logger) self.container.set(Scope, self.scope) + self.container.set(MessageKindParser, self._message_parser) self.container.register(provide(GoTaskStruct, False)(lambda c: self.task)) self.container.register(provide(GoThreadInfo, False)(lambda c: self.thread)) - self.container.register(provide(Operation, False)(lambda c: self.operates())) + self.container.register(provide(Taskflow, False)(lambda c: self.taskflow())) self.container.register(provide(Messenger, False)(lambda c: self.messenger())) self.container.bootstrap() @@ -115,6 +119,9 @@ def _validate_alive(self): if not self.is_alive(): raise RuntimeError(f"Session is not alive") + def to_messages(self, values: Iterable[Union[MessageKind, Any]]) -> List[Message]: + return list(self._message_parser.parse(values)) + def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator]]: self._validate_alive() driver = get_ghost_driver(self.ghost) @@ -146,7 +153,7 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] self.task.error += 1 if self.task.errors > self._max_errors: # if reach max errors, fail the task - return None, self.operates().fail("task failed too much, exceeds max errors") + return None, self.taskflow().fail("task failed too much, exceeds max errors") if EventTypes.CANCEL.value == event.type: # cancel self and all subtasks. @@ -169,9 +176,9 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] event.context = None return event, None - def operates(self) -> Operation: + def taskflow(self) -> Taskflow: self._validate_alive() - return OperationImpl(self._message_parser) + return TaskflowImpl(self, self._message_parser) def get_context(self) -> Optional[Prompter]: if self.task.context is None: @@ -414,3 +421,6 @@ def destroy(self) -> None: del self.ghost del self.ghost_driver del self.scope + + def __del__(self): + self.destroy() diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index cf3c028b..f7b1cff9 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -81,7 +81,7 @@ def send_event(self, event: Event) -> None: notify = task.depth > 0 self._eventbus.send_event(event, notify) - def sync(self, ghost: G, context: Union[G.Context, None]) -> Conversation: + def sync(self, ghost: G, context: Optional[G.Context] = None) -> Conversation: driver = get_ghost_driver(ghost) task_id = driver.make_task_id(self._scope) task = self._tasks.get_task(task_id) @@ -122,9 +122,9 @@ def sync_task( def call( self, - ghost: G, context: G.Context, + ghost: G, + context: Optional[G.Context] = None, instructions: Optional[Iterable[Message]] = None, - prompters: Optional[List[Prompter]] = None, timeout: float = 0.0, stream: Optional[Stream] = None, ) -> Tuple[Union[G.Artifact, None], TaskState]: @@ -158,17 +158,10 @@ def send_message(receiver: Receiver): def create_root_task( self, ghost: G, - context: G.Context, - prompters: Optional[List[Prompter]] = None, + context: Optional[G.Context], ) -> GoTaskStruct: task_id = uuid() id_ = get_identifier(ghost) - if prompters: - if context is None: - context = TextPrmt().with_children(*prompters) - elif isinstance(context, Prompter): - context = context.add_child(*prompters) - context_meta = to_entity_meta(context) if context else None task = GoTaskStruct.new( task_id=task_id, @@ -198,7 +191,7 @@ def run_background_event( self._eventbus.clear_task(task_id) return None - conversation = self.sync_task(task, False, True) + conversation = self.sync_task(task, throw=False, is_background=True) if conversation is None: return None diff --git a/ghostos/framework/ghostos/operation.py b/ghostos/framework/ghostos/taskflow_impl.py similarity index 64% rename from ghostos/framework/ghostos/operation.py rename to ghostos/framework/ghostos/taskflow_impl.py index 48e36088..201f65e5 100644 --- a/ghostos/framework/ghostos/operation.py +++ b/ghostos/framework/ghostos/taskflow_impl.py @@ -1,18 +1,49 @@ from __future__ import annotations -from typing import Union, List +from typing import Union, List, Self from abc import ABC -from ghostos.core.abcd.concepts import Operation, Session, Operator +from ghostos.container import Container +from ghostos.core.abcd.concepts import Taskflow, Session, Operator from ghostos.core.abcd.utils import fire_session_event -from ghostos.core.runtime import TaskState, EventTypes +from ghostos.core.runtime import TaskState, EventTypes, TaskBrief +from ghostos.core.moss import Injection, MossRuntime from ghostos.core.messages import MessageKind, MessageKindParser, Message, Role +from pprint import pprint +from contextlib import redirect_stdout +from io import StringIO +from ghostos.prompter import Prompter -class OperationImpl(Operation): +class TaskflowImpl(Taskflow, Prompter, Injection): - def __init__(self, parser: MessageKindParser): + def __init__(self, session: Session, parser: MessageKindParser): + self.task = session.task + self.container = session.container self.parser = parser + self._destroyed = False + + def self_prompt(self, container: Container) -> str: + brief = TaskBrief.from_task(self.task) + return f""" +You are handling a task `{brief.name}`: + +description: {brief.description} +state: {brief.state} +status_desc: {brief.status_desc} + +use Taskflow to change the task state if you need. +""" + + def get_title(self) -> str: + return "Taskflow" + + def on_inject(self, runtime: MossRuntime, property_name: str) -> Self: + self.container = runtime.container() + return self + + def on_destroy(self) -> None: + self.destroy() def finish(self, status: str = "", *replies: MessageKind) -> Operator: messages = self.parser.parse(replies) @@ -26,14 +57,42 @@ def wait(self, status: str = "", *replies: MessageKind) -> Operator: messages = self.parser.parse(replies) return WaitOperator(status, list(messages)) - def observe(self, *messages: MessageKind, instruction: str = "", sync: bool = False) -> Operator: + def think(self, *messages: MessageKind, instruction: str = "", sync: bool = False) -> Operator: messages = self.parser.parse(messages) - return ObservationOperator(list(messages), instruction, sync) + return RotateOperator(list(messages), instruction, sync) + + def observe(self, **kwargs) -> Operator: + task = self.task + observation = f"## observation on turn {task.turns}\n" + for key, value in kwargs.items(): + observation += f"\n### `{key}`\n" + if isinstance(value, Prompter): + content = value.get_prompt(self.container, depth=3) + else: + buffer = StringIO() + with redirect_stdout(buffer): + pprint(value) + content = str(buffer.getvalue()) + observation += f"\n```\n{content}\n```" + message = Role.SYSTEM.new(content="", memory=observation) + return RotateOperator( + messages=[message], + instruction="", + sync=False, + ) - def on_error(self, *messages: MessageKind) -> Operator: + def error(self, *messages: MessageKind) -> Operator: messages = self.parser.parse(messages) return ErrorOperator(list(messages)) + def destroy(self): + if self._destroyed: + return + self._destroyed = True + del self.container + del self.parser + del self.task + class AbcOperator(Operator, ABC): @@ -68,7 +127,7 @@ def destroy(self): del self.messages -class ObservationOperator(Operator): +class RotateOperator(Operator): def __init__(self, messages: List[Message], instruction: str, sync: bool): self.messages = messages @@ -156,10 +215,17 @@ def run(self, session: Session) -> Union[Operator, None]: task = session.task session.task.state = TaskState.FINISHED.value session.task.status_desc = self.status + messages = list(self.messages) + artifact = session.get_artifact() + if artifact is not None: + # send artifact + artifact_message = session.to_messages([artifact]) + messages.extend(artifact_message) + if task.parent: event = EventTypes.FINISH_CALLBACK.new( task_id=task.parent, - messages=self.messages, + messages=messages, from_task_id=task.task_id, from_task_name=task.name, reason=f"task {task.name} is finished." diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index a3134616..49d5a93b 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -6,11 +6,12 @@ from openai import NOT_GIVEN from openai.types.chat import ChatCompletion from openai.types.chat.chat_completion_stream_options_param import ChatCompletionStreamOptionsParam +from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam from openai.types.chat.chat_completion_chunk import ChatCompletionChunk from ghostos.core.messages import ( - Message, OpenAIMessageParser, DefaultOpenAIMessageParser, MessageType, - CompletionUsagePayload, + Message, OpenAIMessageParser, DefaultOpenAIMessageParser, + CompletionUsagePayload, Role, ) from ghostos.core.llms import ( LLMs, LLMDriver, LLMApi, ModelConf, ServiceConf, OPENAI_DRIVER_NAME, LITELLM_DRIVER_NAME, @@ -107,11 +108,14 @@ def get_model(self) -> ModelConf: def text_completion(self, prompt: str) -> str: raise NotImplemented("text_completion is deprecated, implement it later") + def parse_message_params(self, messages: List[Message]) -> List[ChatCompletionMessageParam]: + return list(self._parser.parse_message_list(messages)) + def _chat_completion(self, chat: Prompt, stream: bool) -> Union[ChatCompletion, Iterable[ChatCompletionChunk]]: chat = self.parse_chat(chat) include_usage = ChatCompletionStreamOptionsParam(include_usage=True) if stream else NOT_GIVEN messages = chat.get_messages() - messages = self._parser.parse_message_list(messages) + messages = self.parse_message_params(messages) if not messages: raise AttributeError("empty chat!!") return self._client.chat.completions.create( @@ -184,7 +188,7 @@ class OpenAIDriver(LLMDriver): def __init__(self, storage: PromptStorage, parser: Optional[OpenAIMessageParser] = None): if parser is None: - parser = DefaultOpenAIMessageParser([]) + parser = DefaultOpenAIMessageParser(None, None) self._parser = parser self._storage = storage @@ -202,7 +206,7 @@ class LitellmAdapter(OpenAIAdapter): def _chat_completion(self, chat: Prompt, stream: bool) -> ChatCompletion: messages = chat.get_messages() - messages = self._parser.parse_message_list(messages) + messages = self.parse_message_params(messages) response = litellm.completion( model=self._model.model, messages=list(messages), @@ -215,6 +219,19 @@ def _chat_completion(self, chat: Prompt, stream: bool) -> ChatCompletion: ) return response.choices[0].message + def parse_message_params(self, messages: List[Message]) -> List[ChatCompletionMessageParam]: + parsed = super().parse_message_params(messages) + outputs = [] + count = 0 + for message in parsed: + # filter all the system message to __system__ user message. + if count > 0 and "role" in message and message["role"] == Role.SYSTEM.value: + message["role"] = Role.USER.value + message["name"] = "__system__" + outputs.append(message) + count += 1 + return outputs + class LiteLLMDriver(OpenAIDriver): diff --git a/ghostos/framework/llms/providers.py b/ghostos/framework/llms/providers.py index 4abf90b6..696f1edc 100644 --- a/ghostos/framework/llms/providers.py +++ b/ghostos/framework/llms/providers.py @@ -3,6 +3,7 @@ from ghostos.contracts.configs import YamlConfig, Configs from ghostos.container import Provider, Container from ghostos.core.llms import LLMs, LLMsConfig, PromptStorage +from ghostos.core.messages.openai import OpenAIMessageParser from ghostos.framework.llms.llms import LLMsImpl from ghostos.framework.llms.openai_driver import OpenAIDriver, LiteLLMDriver from ghostos.framework.llms.prompt_storage_impl import PromptStorageImpl @@ -35,10 +36,11 @@ class LLMsYamlConfig(YamlConfig, LLMsConfig): configs = con.force_fetch(Configs) storage = con.force_fetch(PromptStorage) + parser = con.get(OpenAIMessageParser) conf = configs.get(LLMsYamlConfig) - openai_driver = OpenAIDriver(storage) - lite_llm_driver = LiteLLMDriver(storage) + openai_driver = OpenAIDriver(storage, parser) + lite_llm_driver = LiteLLMDriver(storage, parser) # register default drivers. llms = LLMsImpl(conf=conf, default_driver=openai_driver) diff --git a/ghostos/framework/variables/__init__.py b/ghostos/framework/variables/__init__.py new file mode 100644 index 00000000..c3f8aa38 --- /dev/null +++ b/ghostos/framework/variables/__init__.py @@ -0,0 +1,7 @@ +from ghostos.contracts.variables import Variables +from ghostos.framework.variables.variables_impl import VariablesImpl, WorkspaceVariablesProvider +from ghostos.framework.storage import MemStorage + +test_variables = VariablesImpl(MemStorage()) + +__all__ = ("Variables", "VariablesImpl", "WorkspaceVariablesProvider", "test_variables") diff --git a/ghostos/framework/variables/variables_impl.py b/ghostos/framework/variables/variables_impl.py new file mode 100644 index 00000000..c08f8b9b --- /dev/null +++ b/ghostos/framework/variables/variables_impl.py @@ -0,0 +1,77 @@ +from typing import Optional, Type, Union, Any, TypeVar + +from pydantic import BaseModel + +from ghostos.contracts.variables import Variables +from ghostos.contracts.storage import Storage +from ghostos.contracts.workspace import Workspace +from ghostos.entity import EntityType, to_entity_meta, from_entity_meta, EntityMeta +from ghostos.identifier import try_get_identifier +from ghostos.helpers import md5, generate_import_path, uuid +from ghostos.container import Provider, Container +import json + +T = TypeVar("T") + + +class VariablesImpl(Variables): + + def __init__(self, storage: Storage): + self.storage = storage + + def save( + self, + val: Union[BaseModel, dict, list, str, int, float, bool, EntityType, Any], + desc: str = "", + ) -> Variables.Var: + if isinstance(val, Variables.Var): + return val + entity_meta = to_entity_meta(val) + type_ = generate_import_path(type(val)) + id_ = try_get_identifier(val) + if id_ is not None and id_.id: + vid = md5(type_ + "::" + id_.id) + else: + vid = uuid() + var = Variables.Var( + vid=vid, + type=type_, + desc=desc, + ) + content = json.dumps(entity_meta) + filename = self._get_filename(vid) + self.storage.put(filename, content.encode()) + return var + + @staticmethod + def _get_filename(vid: str) -> str: + return f"{vid}.var.json" + + def load(self, vid: str, expect: Optional[Type[T]] = None, force: bool = False) -> Optional[T]: + filename = self._get_filename(vid) + if not self.storage.exists(filename): + if not force: + return None + else: + raise FileNotFoundError(f"variable {vid} not found at: {filename}") + content = self.storage.get(filename) + data = json.loads(content) + entity_meta = EntityMeta(**data) + entity = from_entity_meta(entity_meta) + if expect and not isinstance(entity, expect): + raise ValueError(f"variable {vid} expect {expect} but got {type(entity)}") + return entity + + +class WorkspaceVariablesProvider(Provider[Variables]): + + def __init__(self, relative_path: str = "variables"): + self.relative_path = relative_path + + def singleton(self) -> bool: + return True + + def factory(self, con: Container) -> Optional[Variables]: + ws = con.force_fetch(Workspace) + storage = ws.runtime().sub_storage(self.relative_path) + return VariablesImpl(storage) diff --git a/ghostos/identifier.py b/ghostos/identifier.py index f4072eb3..76268132 100644 --- a/ghostos/identifier.py +++ b/ghostos/identifier.py @@ -8,7 +8,7 @@ import inspect __all__ = [ - 'get_identifier', + 'get_identifier', 'try_get_identifier', 'identify_class', 'identify_class_id', diff --git a/ghostos/prompter.py b/ghostos/prompter.py index bd69968f..6e97ed69 100644 --- a/ghostos/prompter.py +++ b/ghostos/prompter.py @@ -9,7 +9,8 @@ from ghostos.container import Container from ghostos.helpers import generate_import_path, import_class_from_path, import_from_path from pydantic import BaseModel, Field -from ghostos.entity import EntityClass, EntityMeta, from_entity_meta, to_entity_meta +from ghostos.entity import EntityMeta, from_entity_meta, to_entity_meta + import json __all__ = [ @@ -22,8 +23,8 @@ ] -def get_defined_prompt(value: Any) -> Union[str, None]: - attr = get_defined_prompt_attr(value) +def get_defined_prompt(value: Any, container: Optional[Container] = None) -> Union[str, None]: + attr = get_defined_prompt_attr(value, container) if attr is None: return None if isinstance(attr, str): @@ -31,11 +32,13 @@ def get_defined_prompt(value: Any) -> Union[str, None]: return attr() -def get_defined_prompt_attr(value: Any) -> Union[None, str, Callable[[], str]]: +def get_defined_prompt_attr(value: Any, container: Optional[Container] = None) -> Union[None, str, Callable[[], str]]: if value is None: return None elif isinstance(value, PromptAbleObj): return value.__prompt__ + elif isinstance(value, Prompter) and container is not None: + return value.get_prompt(container) elif isinstance(value, type): if issubclass(value, PromptAbleClass): @@ -73,14 +76,14 @@ def set_class_prompt(cls: type, prompter: Union[Callable[[], str], str], force: # ---- prompter ---- # -class Prompter(EntityClass, ABC): +class Prompter(ABC): """ is strong-typed model for runtime alternative properties of a ghost. """ priority: int = Field(default=0, description='Priority of this prompter.') - __children__: Optional[Set[Prompter]] = None + __children__: Optional[List[Prompter]] = None """ children is fractal sub context nodes""" __self_prompt__: Optional[str] = None @@ -93,9 +96,9 @@ def with_children(self, *children: Prompter) -> Self: def add_child(self, *prompters: Prompter) -> Self: if self.__children__ is None: - self.__children__ = set() + self.__children__ = [] for prompter in prompters: - self.__children__.add(prompter) + self.__children__.append(prompter) return self @abstractmethod @@ -158,9 +161,24 @@ def get_prompt(self, container: Container, depth: int = 0) -> str: self.__self_prompt__ = output.strip() return self.__self_prompt__ + def flatten(self, index: str = "") -> Dict[str, Self]: + if not index: + index = "0" + result = {index: self} + idx = 0 + for child in self.__children__: + sub_index = index + "." + str(idx) + sub_flatten = child.flatten(sub_index) + for key in sub_flatten: + result[key] = sub_flatten[key] + return result + + +class ModelPrompter(BaseModel, Prompter, ABC): + def __to_entity_meta__(self) -> EntityMeta: type_ = generate_import_path(self.__class__) - ctx_data = self.__to_entity_meta_data__() + ctx_data = self.model_dump(exclude_defaults=True) children_data = [] if self.__children__ is not None: for child in self.__children__: @@ -169,48 +187,17 @@ def __to_entity_meta__(self) -> EntityMeta: content = json.dumps(data) return EntityMeta(type=type_, content=content) - @abstractmethod - def __to_entity_meta_data__(self) -> Dict[str, Any]: - pass - - @classmethod - @abstractmethod - def __from_entity_meta_data__(cls, data: Dict[str, Any]) -> Self: - pass - @classmethod def __from_entity_meta__(cls, meta: EntityMeta) -> Self: data = json.loads(meta["content"]) ctx_data = data["ctx"] children_data = data["children"] - result = cls.__from_entity_meta_data__(ctx_data) + result = cls(**ctx_data) children = [] for child in children_data: children.append(from_entity_meta(child)) return result.with_children(*children) - def flatten(self, index: str = "") -> Dict[str, Self]: - if not index: - index = "0" - result = {index: self} - idx = 0 - for child in self.__children__: - sub_index = index + "." + str(idx) - sub_flatten = child.flatten(sub_index) - for key in sub_flatten: - result[key] = sub_flatten[key] - return result - - -class ModelPrompter(BaseModel, Prompter, ABC): - - def __to_entity_meta_data__(self) -> Dict[str, Any]: - return self.model_dump(exclude_defaults=True) - - @classmethod - def __from_entity_meta_data__(cls, data: Dict[str, Any]) -> Self: - return cls(**data) - class DataPrompter(ModelPrompter, ABC): __driver__: Optional[Type[DataPrompterDriver]] = None diff --git a/tests/core/messages/test_message_parser.py b/tests/core/messages/test_message_parser.py index 7c0e4eec..09a2a7b5 100644 --- a/tests/core/messages/test_message_parser.py +++ b/tests/core/messages/test_message_parser.py @@ -1,8 +1,26 @@ -from ghostos.core.messages import MessageKindParser +from ghostos.core.messages import MessageKindParser, VariableMessage +from ghostos.framework.variables import test_variables +from pydantic import BaseModel def test_message_parser(): - parser = MessageKindParser() + parser = MessageKindParser(test_variables) messages = list(parser.parse(['Hello World'])) assert len(messages) == 1 assert messages[0].content == 'Hello World' + + +class Foo(BaseModel): + foo: str = "hello" + + +def test_variable_message(): + parser = MessageKindParser(test_variables) + messages = list(parser.parse([Foo()])) + assert len(messages) > 0 + + message = messages[0] + var = VariableMessage.from_message(message) + assert var is not None + value = test_variables.load(var.attrs.vid) + assert value.foo == "hello" diff --git a/tests/core/moss/examples/test_baseline.py b/tests/core/moss/examples/test_baseline.py index f3b4fcc2..80fb67c2 100644 --- a/tests/core/moss/examples/test_baseline.py +++ b/tests/core/moss/examples/test_baseline.py @@ -48,6 +48,7 @@ def test_baseline_exec(): prompt = prompter.dump_code_context() injection_prompt = prompter.moss_injections_prompt() + print("++++", injection_prompt) assert "tester" in injection_prompt # plus 方法存在. diff --git a/tests/framework/eventbuses/test_mem_impl.py b/tests/framework/eventbuses/test_mem_impl.py index 59f3090a..58783a24 100644 --- a/tests/framework/eventbuses/test_mem_impl.py +++ b/tests/framework/eventbuses/test_mem_impl.py @@ -4,7 +4,7 @@ def test_mem_impl_send_pop_event(): bus = MemEventBusImpl() - e = EventTypes.REQUEST.new("foo", []) + e = EventTypes.INPUT.new("foo", []) bus.send_event(e, notify=True) task_id = bus.pop_task_notification() assert task_id is not None diff --git a/tests/framework/tasks/test_storage_impl.py b/tests/framework/tasks/test_storage_impl.py index edb6d16e..20edc2a8 100644 --- a/tests/framework/tasks/test_storage_impl.py +++ b/tests/framework/tasks/test_storage_impl.py @@ -10,7 +10,9 @@ def test_storage_tasks_impl(): tasks = StorageGoTasksImpl(storage, FakeLogger()) task = GoTaskStruct.new( task_id="task_id", + shell_id="shell_id", process_id="process_id", + depth=0, name="name", description="description", meta=EntityMeta(type="type", content=""), diff --git a/tests/framework/variables/test_variables.py b/tests/framework/variables/test_variables.py new file mode 100644 index 00000000..a3233930 --- /dev/null +++ b/tests/framework/variables/test_variables.py @@ -0,0 +1,28 @@ +from ghostos.framework.variables.variables_impl import VariablesImpl +from ghostos.framework.storage import MemStorage +from pydantic import BaseModel + + +class Foo(BaseModel): + a: int = 123 + b: str = 'test' + + +def test_variables_impl_baseline(): + variables = VariablesImpl(MemStorage()) + v = variables.save(9527, "random int") + assert v.desc == "random int" + got = variables.load(v.vid, int, True) + assert got == 9527 + + cases = [ + (9527, "random int", int, True), + ("hello world", "", str, False), + (Foo(), "", Foo, True), + (Foo(), "", None, False), + ] + for case in cases: + value, desc, expect, force = case + v = variables.save(value, desc) + got = variables.load(v.vid, expect, force) + assert got == value, f"{value} != {got}" diff --git a/tests/python/test_pydantic.py b/tests/python/test_pydantic.py index 9846b945..4b54f3cc 100644 --- a/tests/python/test_pydantic.py +++ b/tests/python/test_pydantic.py @@ -164,3 +164,14 @@ class Baz(BaseModel): unmarshalled = Baz(**data) assert not isinstance(unmarshalled.baz[0], Foo) + + +def test_model_with_subclass(): + class Foo(BaseModel): + class Bar(BaseModel): + bar: str = "hello" + + bar: Bar = Field(default_factory=Bar) + + f = Foo() + assert f.bar.bar == "hello" diff --git a/tests/test_prompter.py b/tests/test_prompter.py index 15362edd..6dbdbddf 100644 --- a/tests/test_prompter.py +++ b/tests/test_prompter.py @@ -1,5 +1,5 @@ from ghostos.prompter import ( - TextPrmt, PromptAbleClass, PromptAbleObj, + TextPrmt, PromptAbleClass, PromptAbleObj, ModelPrompter, InspectPrmt, ) from ghostos.container import Container @@ -37,3 +37,17 @@ def test_inspect_prompters(): c = Container() prompt = prmt.get_prompt(c) assert f":{test_group_prompters.__name__}" in prompt + + +def test_model_prompters(): + class TestPrompter(ModelPrompter): + line: str = "TestPrompter" + + def self_prompt(self, container: Container) -> str: + return self.line + + def get_title(self) -> str: + return "" + + t = TestPrompter() + assert "TestPrompter" in t.get_prompt(Container()) From 4909c7a7d76e12dbb3da6f8607e26a695e21c038 Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 14 Nov 2024 23:40:31 +0800 Subject: [PATCH 076/148] dev: implements subtasks, close to test --- ghostos/core/abcd/concepts.py | 12 +- ghostos/core/runtime/tasks.py | 10 +- ghostos/framework/ghostos/session_impl.py | 81 +++-------- ghostos/framework/ghostos/subtasks_impl.py | 150 +++++++++++++++++++++ ghostos/framework/tasks/storage_tasks.py | 1 + tests/python/test_slice.py | 12 ++ 6 files changed, 196 insertions(+), 70 deletions(-) create mode 100644 ghostos/framework/ghostos/subtasks_impl.py diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index 20da7ad3..66694205 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -52,7 +52,8 @@ __all__ = ( "Ghost", "Session", "GhostDriver", "GhostOS", "Operator", "StateValue", "Action", - "Shell", "Taskflow", "Scope", "Conversation", "Background", + "Shell", "Scope", "Conversation", "Background", + "Taskflow", "Subtasks", ) @@ -470,8 +471,6 @@ class Session(Generic[G], ABC): task: GoTaskStruct """current task""" - subtasks: Dict[str, TaskBrief] - thread: GoThreadInfo """thread info of the task""" @@ -532,6 +531,10 @@ def taskflow(self) -> Taskflow: """ pass + @abstractmethod + def subtasks(self) -> Subtasks: + pass + @abstractmethod def messenger(self) -> "Messenger": """ @@ -559,6 +562,9 @@ def create_threads( ) -> None: pass + def create_tasks(self, *tasks: GoTaskStruct) -> None: + pass + @abstractmethod def call(self, ghost: G, ctx: G.Context) -> G.Artifact: """ diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index e721f745..087dcf18 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -135,12 +135,12 @@ class GoTaskStruct(BaseModel): ) # --- time related --- # - created: float = Field( - default_factory=lambda: round(time.time(), 4), + created: int = Field( + default_factory=lambda: int(round(time.time(), 0)), description="The time the task was created.", ) - updated: float = Field( - default=0.0, + updated: int = Field( + default=0, description="The time the task was updated.", ) @@ -248,6 +248,8 @@ class TaskBrief(BaseModel, Identical): description: str = Field(description="the purpose of the task") state: str = Field(description="the state of the task") status_desc: str = Field(description="the description of the task status") + created: int = Field(description="the time the task was created") + updated: int = Field(description="the time that task was updated") def is_overdue(self) -> bool: now = time.time() diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 4882ff10..6e3718f7 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -1,6 +1,8 @@ from typing import Optional, List, Iterable, Tuple, TypeVar, Dict, Union, Any -from ghostos.core.abcd.concepts import Session, Ghost, GhostDriver, Shell, Scope, Taskflow, Operator +from ghostos.core.abcd.concepts import ( + Session, Ghost, GhostDriver, Shell, Scope, Taskflow, Operator, Subtasks +) from ghostos.core.abcd.utils import get_ghost_driver from ghostos.core.messages import ( MessageKind, Message, Caller, Stream, Role, MessageKindParser, MessageType @@ -19,6 +21,7 @@ from ghostos.identifier import get_identifier from ghostos.framework.messengers import DefaultMessenger from .taskflow_impl import TaskflowImpl +from .subtasks_impl import SubtasksImpl G = TypeVar("G", bound=Ghost) @@ -82,7 +85,6 @@ def __init__( self._creating_tasks: Dict[str, GoTaskStruct] = {} self._firing_events: List[Event] = [] self._saving_threads: Dict[str, GoThreadInfo] = {} - self.subtasks = {} self._failed = False self._done = False self._destroyed = False @@ -99,6 +101,7 @@ def _bootstrap(self): self.container.register(provide(GoTaskStruct, False)(lambda c: self.task)) self.container.register(provide(GoThreadInfo, False)(lambda c: self.thread)) self.container.register(provide(Taskflow, False)(lambda c: self.taskflow())) + self.container.register(provide(Subtasks, False)(lambda c: self.subtasks())) self.container.register(provide(Messenger, False)(lambda c: self.messenger())) self.container.bootstrap() @@ -160,9 +163,9 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] self.task.errors = 0 self.thread.new_turn(event) self.task.state = TaskState.CANCELLED - for child in self.subtasks.values(): + for child_id in self.task.children: event = EventTypes.CANCEL.new( - task_id=child.task_id, + task_id=child_id, messages=[], from_task_id=self.task.task_id, from_task_name=self.task.name, @@ -180,6 +183,9 @@ def taskflow(self) -> Taskflow: self._validate_alive() return TaskflowImpl(self, self._message_parser) + def subtasks(self) -> Subtasks: + return SubtasksImpl(self) + def get_context(self) -> Optional[Prompter]: if self.task.context is None: return None @@ -198,12 +204,7 @@ def _reset(self): self._firing_events = [] self._creating_tasks = {} self._saving_threads = {} - self.subtasks = {} self.task = self.task.new_turn() - if len(self.task.children) > 0: - subtasks = self.get_task_briefs(*self.task.children) - subtasks = sorted(subtasks.items(), key=lambda x: x.updated) - self.subtasks = {tid: t for tid, t in subtasks} def messenger(self) -> Messenger: self._validate_alive() @@ -242,58 +243,10 @@ def cancel_subtask(self, ghost: G, reason: str = "") -> None: ) self.fire_events(event) - def send_subtask(self, ghost: G, *messages: MessageKind, ctx: Optional[G.Context] = None) -> None: + def create_tasks(self, *tasks: GoTaskStruct) -> None: self._validate_alive() - driver = get_ghost_driver(ghost) - task_id = driver.make_task_id(self.scope) - tasks = self.container.force_fetch(GoTasks) - subtask = tasks.get_task(task_id) - - event_messages = list(self._message_parser.parse(messages)) - if subtask is None: - self.create_subtask(ghost, ctx) - event = EventTypes.CREATED.new( - task_id=task_id, - messages=event_messages, - from_task_id=self.task.task_id, - from_task_name=self.task.name, - ) - self.fire_events(event) - else: - event = EventTypes.INPUT.new( - task_id=task_id, - messages=event_messages, - from_task_id=self.task.task_id, - from_task_name=self.task.name, - ) - self.fire_events(event) - - def create_subtask( - self, - ghost: G, - ctx: G.Context, - task_name: Optional[str] = None, - task_description: Optional[str] = None, - ) -> None: - self._validate_alive() - driver = get_ghost_driver(ghost) - task_id = driver.make_task_id(self.scope) - identifier = get_identifier(self.ghost) - task_name = task_name or identifier.name - task_description = task_description or identifier.description - context_meta = to_entity_meta(ctx) if ctx is not None else None - task = GoTaskStruct.new( - task_id=task_id, - shell_id=self.task.shell_id, - process_id=self.task.process_id, - depth=self.task.depth + 1, - name=task_name, - description=task_description, - meta=to_entity_meta(ghost), - context=context_meta, - parent_task_id=self.task.task_id, - ) - self._creating_tasks[task_id] = task + for task in tasks: + self._creating_tasks[task.task_id] = task def create_threads(self, *threads: GoThreadInfo) -> None: self._validate_alive() @@ -337,8 +290,11 @@ def save(self) -> None: self._reset() def _update_subtasks(self): - children = [] - for tid, tb in self.subtasks.items(): + children = self.task.children + if len(children) == 0: + return + tasks = self.get_task_briefs(*children) + for tid, tb in tasks: if TaskState.is_dead(tb.state): continue children.append(tid) @@ -413,7 +369,6 @@ def destroy(self) -> None: self.container.destroy() del self.container del self._firing_events - del self.subtasks del self.task del self.thread del self._fetched_task_briefs diff --git a/ghostos/framework/ghostos/subtasks_impl.py b/ghostos/framework/ghostos/subtasks_impl.py new file mode 100644 index 00000000..4ac706db --- /dev/null +++ b/ghostos/framework/ghostos/subtasks_impl.py @@ -0,0 +1,150 @@ +from typing import Optional, Dict, List + +from ghostos.container import Container +from ghostos.core.abcd.concepts import Subtasks, Session, Ghost +from ghostos.core.abcd.utils import get_ghost_driver +from ghostos.core.runtime import GoTaskStruct, GoTasks, EventTypes, TaskBrief, TaskState +from ghostos.identifier import get_identifier +from ghostos.helpers import yaml_pretty_dump +from ghostos.entity import to_entity_meta + + +class SubtasksImpl(Subtasks): + + def __init__(self, session: Session, max_subtasks: int = 20): + self.session = session + self.max_shown_subtasks = max_subtasks + + def get_subtasks(self) -> Dict[str, TaskBrief]: + children = self.session.task.children + if len(children) == 0: + return {} + tasks = self.session.get_task_briefs(*children) + return {t.name: t for t in tasks.values()} + + def cancel(self, name: str, reason: str = "") -> None: + subtasks = self.get_subtasks() + if name not in subtasks: + raise NameError(f"Subtask {name} does not exist") + subtask_brief = subtasks[name] + task_id = subtask_brief.task_id + self_task = self.session.task + event = EventTypes.CANCEL.new( + task_id=task_id, + reason=reason, + messages=[], + from_task_id=self_task.task_id, + from_task_name=self_task.name, + ) + self.session.fire_events(event) + + def send( + self, + name: str, + *messages: Subtasks.MessageKind, + ctx: Optional[Ghost.Context] = None, + ) -> None: + subtasks = self.get_subtasks() + if name not in subtasks: + raise NameError(f"Subtask {name} does not exist") + subtask_brief = subtasks[name] + task_id = subtask_brief.task_id + event_messages = self.session.to_messages(messages) + self_task = self.session.task + event = EventTypes.INPUT.new( + task_id=task_id, + messages=event_messages, + from_task_id=self_task.task_id, + from_task_name=self_task.name, + ) + self.session.fire_events(event) + + def create( + self, + ghost: Ghost, + instruction: str = "", + ctx: Optional[Ghost.Context] = None, + task_name: Optional[str] = None, + task_description: Optional[str] = None, + ) -> None: + driver = get_ghost_driver(ghost) + task_id = driver.make_task_id(self.session.scope) + tasks = self.session.container.force_fetch(GoTasks) + task = tasks.get_task(task_id) + self_task = self.session.task + if not task: + identifier = get_identifier(ghost) + task_name = task_name or identifier.name + task_description = task_description or identifier.description + context_meta = to_entity_meta(ctx) if ctx is not None else None + task = GoTaskStruct.new( + task_id=task_id, + shell_id=self_task.shell_id, + process_id=self_task.process_id, + depth=self_task.depth + 1, + name=task_name, + description=task_description, + meta=to_entity_meta(ghost), + context=context_meta, + parent_task_id=self_task.task_id, + ) + self.session.create_tasks(task) + event = EventTypes.CREATED.new( + task_id=task_id, + messages=[], + reason=f"receive task from parent task {self_task.name}", + from_task_id=self_task.task_id, + from_task_name=self_task.name, + instruction=instruction, + ) + self.session.fire_events(event) + + def self_prompt(self, container: Container) -> str: + subtasks = self.get_subtasks() + total = len(subtasks) + if total == 0: + return "There are no subtasks yet. You can create any by Subtasks lib if you need" + + tasks = subtasks.values() + sorted_tasks = sort_tasks(list(tasks)) + prior_tasks = sorted_tasks[:5] + tasks = sorted_tasks[5:] + wait_tasks = [] + dead_tasks = [] + other_tasks = [] + for subtask in tasks: + if subtask.state == TaskState.WAITING.value: + wait_tasks.append(subtask) + elif TaskState.is_dead(subtask.state): + dead_tasks.append(subtask) + else: + other_tasks.append(subtask) + wait_tasks = sort_tasks(wait_tasks) + dead_tasks = sort_tasks(dead_tasks) + other_tasks = sort_tasks(other_tasks) + + blocks = [] + count = 0 + for order_tasks in [prior_tasks, wait_tasks, other_tasks, dead_tasks]: + for task in order_tasks: + if count > self.max_shown_subtasks: + break + blocks.append(task.model_dump(exclude={"task_id"}, exclude_defaults=True)) + count += 1 + + shown_tasks = len(blocks) + tasks_content = yaml_pretty_dump(shown_tasks) + return f""" +There are {total} subtasks, first {shown_tasks} tasks brief are: + +```yaml +{tasks_content.strip()} +``` +""" + + def get_title(self) -> str: + return "Subtasks" + + +def sort_tasks(tasks: List[TaskBrief]) -> List[TaskBrief]: + return sorted(tasks, key=lambda t: t.updated, reverse=True) diff --git a/ghostos/framework/tasks/storage_tasks.py b/ghostos/framework/tasks/storage_tasks.py index 56ed6a2c..1da4bb56 100644 --- a/ghostos/framework/tasks/storage_tasks.py +++ b/ghostos/framework/tasks/storage_tasks.py @@ -77,6 +77,7 @@ def save_task(self, *tasks: GoTaskStruct) -> None: filename = self._get_task_filename(task.task_id) data = task.model_dump(exclude_defaults=True) content = yaml.safe_dump(data) + task.updated = int(time.time()) self._storage.put(filename, content.encode('utf-8')) @staticmethod diff --git a/tests/python/test_slice.py b/tests/python/test_slice.py index e9cf117f..da14963c 100644 --- a/tests/python/test_slice.py +++ b/tests/python/test_slice.py @@ -42,3 +42,15 @@ def test_array_insert_more_than_pointed(): a = [1, 2, 3, 4] a[1:3] = [5, 6, 7, 8] assert a == [1, 5, 6, 7, 8, 4] + + +def test_sort_dicts(): + cases = [ + {'a': 1, 'b': 2, 'c': 3, 'd': 4}, + {'a': 2, 'b': 2, 'c': 3, 'd': 4}, + {'a': 3, 'b': 2, 'c': 3, 'd': 4}, + {'a': 4, 'b': 2, 'c': 3, 'd': 4}, + ] + values = sorted(cases, key=lambda x: x['a'], reverse=True) + actual = [c['a'] for c in values] + assert actual == [4, 3, 2, 1] From b4f6524683625d73aa4ddd35b5a11224d273506d Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 15 Nov 2024 00:34:04 +0800 Subject: [PATCH 077/148] dev: make sure root container can run --- app/configs/llms_conf.yml | 6 +- ...81-c90a-4b68-9024-516170e4c851.prompt.json | 65 ++++++++ ...67-4749-4de0-85ea-f2fac054c1e8.prompt.json | 65 ++++++++ ...d9-8fe8-40d8-a2c2-b434a12e8dfb.prompt.json | 65 ++++++++ ...cc-0c61-48d0-8b17-6efb01e487cc.prompt.json | 65 ++++++++ ghostos/bootstrap.py | 23 +++ ghostos/core/abcd/concepts.py | 10 +- ghostos/core/abcd/ghosts.py | 3 +- ghostos/core/aifunc/driver.py | 2 +- ghostos/entity.py | 3 +- ghostos/framework/ghostos/__init__.py | 8 +- ghostos/framework/ghostos/basic.py | 142 ------------------ .../framework/ghostos/conversation_impl.py | 6 +- ghostos/framework/ghostos/demo_os.py | 76 ---------- ghostos/framework/ghostos/ghostos_impl.py | 34 ++++- ghostos/framework/ghostos/session_impl.py | 6 +- ghostos/framework/ghostos/shell_impl.py | 16 +- ghostos/framework/ghosts/basic.py | 2 +- ghostos/framework/multitasks/basic.py | 2 +- ghostos/framework/operators/event_ops.py | 2 +- pyproject.toml | 1 - 21 files changed, 345 insertions(+), 257 deletions(-) create mode 100644 app/runtime/prompts/75ee0581-c90a-4b68-9024-516170e4c851.prompt.json create mode 100644 app/runtime/prompts/a4974467-4749-4de0-85ea-f2fac054c1e8.prompt.json create mode 100644 app/runtime/prompts/a7fa30d9-8fe8-40d8-a2c2-b434a12e8dfb.prompt.json create mode 100644 app/runtime/prompts/f16369cc-0c61-48d0-8b17-6efb01e487cc.prompt.json delete mode 100644 ghostos/framework/ghostos/basic.py delete mode 100644 ghostos/framework/ghostos/demo_os.py diff --git a/app/configs/llms_conf.yml b/app/configs/llms_conf.yml index ac8db2f7..d775d6dc 100644 --- a/app/configs/llms_conf.yml +++ b/app/configs/llms_conf.yml @@ -16,11 +16,7 @@ services: base_url: https://api.deepseek.com/beta # proxy: $OPENAI_PROXY # Configure default LLM API here. -default: - service: moonshot - model: moonshot-v1-32k -# service: openai -# model: gpt-4o +default: moonshot-v1-32k # The models below can be edited as you want, see details: ghostos.core.llms.configs:ModelConf # the key of models is a `llm_api_name`, value is a ModelConf instance. models: diff --git a/app/runtime/prompts/75ee0581-c90a-4b68-9024-516170e4c851.prompt.json b/app/runtime/prompts/75ee0581-c90a-4b68-9024-516170e4c851.prompt.json new file mode 100644 index 00000000..135d9336 --- /dev/null +++ b/app/runtime/prompts/75ee0581-c90a-4b68-9024-516170e4c851.prompt.json @@ -0,0 +1,65 @@ +{ + "id": "75ee0581-c90a-4b68-9024-516170e4c851", + "description": "description of this prompt", + "system": [ + { + "msg_id": "663402f8-13d3-41ed-af2b-ea147c710d8c", + "ref_id": null, + "index": null, + "type": "", + "role": "system", + "name": null, + "content": "\n# Who Are You \n\nYou are an AIFunc named `AgentFn`.\nAIFunc is a LLM-driven function that could complete request by multi-turns thinking,\nAnd your purpose is to generate AIFuncResult `AgentFnResult` as final result.\n\n## MOSS\n\nYou are equipped with a MOSS (model-oriented operating system simulation) in which you can generate Python code and \npython libraries to reach your goal.\n\nThe `PYTHON CONTEXT` that MOSS already provide to you are below: \n\n```python\n\nfrom __future__ import annotations\n\nfrom typing import Optional\nfrom ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx\nfrom ghostos.core.moss import Moss as Parent\nfrom ghostos.demo.aifuncs.weather import WeatherAIFunc, WeatherAIFuncResult\nfrom ghostos.demo.aifuncs.news import NewsAIFunc, NewsAIFuncResult\nfrom pydantic import Field\n\n\nclass AgentFn(AIFunc):\n \"\"\"\n AIFunc that act like an agent\n \"\"\"\n request: str = Field(description=\"raw request for the agent\")\n\n\nclass AgentFnResult(AIFuncResult):\n \"\"\"\n the result that follow the agent request\n \"\"\"\n result: str = Field(description=\"response from the agent\")\n err: Optional[str] = Field(default=None, description=\"error message\")\n\n\nclass Moss(Parent):\n ai_func_ctx: AIFuncCtx\n \"\"\"useful to run AIFunc\"\"\"\n\n\n\n\n\n# more details about some module attrs above, are list below (quoted by ):\n\"\"\"\n# \nclass AIFunc(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \nclass AIFuncResult(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \n@cls_source_code()\nclass AIFuncCtx(ABC):\n \\\"\"\"\n System context that could execute an AIFunc and keep result in it during multi-turns thinking.\n \\\"\"\"\n\n @abstractmethod\n def run(self, key: str, fn: AIFunc) -> AIFuncResult:\n \\\"\"\"\n Run an AIFunc subclass instance, got result and save it into the key.\n :param key: the key that ctx keep the result in multi-turns thinking.\n :param fn: instance of AIFunc that define the task.\n :return: the certain result that match AIFuncResult and is not None\n :exception: TooManyFailureError\n \\\"\"\"\n pass\n\n @abstractmethod\n def parallel_run(self, fn_dict: Dict[str, AIFunc]) -> Dict[str, AIFuncResult]:\n \\\"\"\"\n Run multiple AIFunc instances in parallel and save their results.\n \n :param fn_dict: A dictionary where keys are result identifiers and values are AIFunc instances.\n :return: A dictionary where keys are the same as in fn_dict and values are the corresponding AIFuncResults.\n \n This method allows for concurrent execution of multiple AIFunc instances, which can improve\n performance when dealing with independent tasks. The results are stored and can be accessed\n using the keys provided in the input dictionary.\n \\\"\"\"\n pass\n\n @abstractmethod\n def get(self, key: str) -> Optional[Any]:\n \\\"\"\"\n get a cached value by key.\n \\\"\"\"\n pass\n\n @abstractmethod\n def set(self, key: str, value: Any) -> None:\n \\\"\"\"\n set a value to ctx, keep it in multi-turns thinking.\n \\\"\"\"\n pass\n\n @abstractmethod\n def values(self) -> Dict[str, Any]:\n \\\"\"\"\n return all values of the AiFuncCtx\n \\\"\"\"\n pass\n# \n\n# \nclass Moss(ABC):\n \\\"\"\"\n Language Model-oriented Operating System Simulation.\n Full python code interface for large language models in multi-turns chat or thinking.\n The property with SerializeType will persist during multi-turns.\n SerializeType means: int, float, str, None, list, dict, BaseModel, TypedDict\n You can edit them if you need.\n \\\"\"\"\n\n T = TypeVar('T')\n\n @abstractmethod\n def fetch(self, abstract: Type[T]) -> Optional[T]:\n \\\"\"\"\n fetch an implementation from IoC Container\n if the abstract type is not bound with any implementation, return None.\n \\\"\"\"\n pass\n\n @abstractmethod\n def pprint(self, *args, **kwargs) -> None:\n \\\"\"\"\n pretty printer\n \\\"\"\"\n pass\n# \n\n# \nclass WeatherAIFunc(AIFunc):\n \\\"\"\"\n tell about weather\n \\\"\"\"\n city: str = Field(default=\"\", description=\"the city name that you want weather forecast. empty means local\")\n date: str = Field(default=\"today\", description=\"the date of weather forecast\")\n\n\n# result type of WeatherAIFunc (which maybe not imported yet) is :\n# class WeatherAIFuncResult(AIFuncResult):\n# \\\"\"\"\n# weather result\n# \\\"\"\"\n# result: str = Field(description=\"the full result describing weather details in nature language form.\")\n# date: str = Field(default=\"today\", description=\"the date of weather forecast\")\n# city: str = Field(default=\"\", description=\"the city name that you want weather forecast. empty means local\")\n# temperature: Optional[float] = Field(default=None, description=\"the temperature of the weather\")\n# humidity: Optional[float] = Field(default=None, description=\"the humidity of the weather\")\n# pressure: Optional[float] = Field(default=None, description=\"the pressure of the weather\")\n# wind_speed: Optional[float] = Field(default=None, description=\"the wind speed of the weather\")\n# wind_dir: Optional[float] = Field(default=None, description=\"the wind direction of the weather\")\n#\n# \n\n# \nclass WeatherAIFuncResult(AIFuncResult):\n \\\"\"\"\n weather result\n \\\"\"\"\n result: str = Field(description=\"the full result describing weather details in nature language form.\")\n date: str = Field(default=\"today\", description=\"the date of weather forecast\")\n city: str = Field(default=\"\", description=\"the city name that you want weather forecast. empty means local\")\n temperature: Optional[float] = Field(default=None, description=\"the temperature of the weather\")\n humidity: Optional[float] = Field(default=None, description=\"the humidity of the weather\")\n pressure: Optional[float] = Field(default=None, description=\"the pressure of the weather\")\n wind_speed: Optional[float] = Field(default=None, description=\"the wind speed of the weather\")\n wind_dir: Optional[float] = Field(default=None, description=\"the wind direction of the weather\")\n# \n\n# \nclass NewsAIFunc(AIFunc):\n \\\"\"\"\n search news\n \\\"\"\"\n query: str = Field(description=\"required news query.\")\n limit: int = Field(default=5, description=\"how many news you want.\")\n\n\n# result type of NewsAIFunc (which maybe not imported yet) is :\n# class NewsAIFuncResult(AIFuncResult):\n# \\\"\"\"\n# news result\n# \\\"\"\"\n# \n# class News(BaseModel):\n# summary: str = Field(description=\"summary of the news.\")\n# title: str = Field(description=\"title of the news.\")\n# date: str = Field(description=\"date of the news.\")\n# media: str = Field(description=\"media of the news.\")\n# \n# results: List[News] = Field(default_factory=list)\n#\n# \n\n# \nclass NewsAIFuncResult(AIFuncResult):\n \\\"\"\"\n news result\n \\\"\"\"\n\n class News(BaseModel):\n summary: str = Field(description=\"summary of the news.\")\n title: str = Field(description=\"title of the news.\")\n date: str = Field(description=\"date of the news.\")\n media: str = Field(description=\"media of the news.\")\n\n results: List[News] = Field(default_factory=list)\n# \n\"\"\"\n\n\n```\n\nYou shall generate a single block of Python code, \nand in the code you shall defines a main function like: \n`def main(moss: Moss, fn: AgentFn):`\n\nabout the params and returns:\n:param moss: A Moss instance\n:param fn: An instance of AgentFn\n:return: tuple[result:AgentFnResult | None , ok: bool]\nIf ok is False, means you need to observe the output that you print in the function, and think another round.\nIf ok is True, means you finish the request and return a final result.\n\nThe MOSS will automatically execute the main function you generated with the instance of class Moss, \ntake next step based on the returns. \n\nSo you shall not repeat the exists code that already provide in the `PYTHON CONTEXT`.\n", + "memory": null, + "attrs": null, + "payloads": {}, + "callers": [], + "seq": "complete", + "created": 1731600249.1537 + }, + { + "msg_id": "8a2933ee-113c-410c-9ef7-4ef8c70eb0b5", + "ref_id": null, + "index": null, + "type": "", + "role": "system", + "name": null, + "content": "\n## Notices\n- The code you generated shall start with and end with , the outside system will automatically execute the code between the mark.\n- You can import basic python module if you needed. \n- The variables defined in the main function will not keep in memory during multi-turns thinking. Unless some lib (AIFuncCtx) provide the ability.\n- MOSS will automatic execute the main function so YOU SHOULD NEVER EXECUTE IT YOURSELF.\n- If you are not equipped enough to resolve your quest, you shall admit the it in the result or raise an exception.\n- **You are not Agent, DO NOT TALK ABOUT YOUR THOUGHT, JUST WRITE THE CODES ONLY**\n", + "memory": null, + "attrs": null, + "payloads": {}, + "callers": [], + "seq": "complete", + "created": 1731600249.1537 + } + ], + "history": [ + { + "msg_id": "74b82dbe-d036-4da0-a313-dcf7c30eb6cb", + "ref_id": null, + "index": null, + "type": "", + "role": "system", + "name": null, + "content": "help me to find news about OpenAI O1 model", + "memory": null, + "attrs": null, + "payloads": {}, + "callers": [], + "seq": "complete", + "created": 1731600249.1048 + } + ], + "inputs": [], + "appending": [], + "functions": [], + "function_call": null, + "functional_tokens": [], + "output": [ + { + "created": 1731600256.0 + } + ], + "error": null, + "created": 1731600249.1538 +} \ No newline at end of file diff --git a/app/runtime/prompts/a4974467-4749-4de0-85ea-f2fac054c1e8.prompt.json b/app/runtime/prompts/a4974467-4749-4de0-85ea-f2fac054c1e8.prompt.json new file mode 100644 index 00000000..7d093325 --- /dev/null +++ b/app/runtime/prompts/a4974467-4749-4de0-85ea-f2fac054c1e8.prompt.json @@ -0,0 +1,65 @@ +{ + "id": "a4974467-4749-4de0-85ea-f2fac054c1e8", + "description": "description of this prompt", + "system": [ + { + "msg_id": "8cf718ec-a4b7-4098-9a8b-9e9cd5b66651", + "ref_id": null, + "index": null, + "type": "", + "role": "system", + "name": null, + "content": "\n# Who Are You \n\nYou are an AIFunc named `NewsAIFunc`.\nAIFunc is a LLM-driven function that could complete request by multi-turns thinking,\nAnd your purpose is to generate AIFuncResult `NewsAIFuncResult` as final result.\n\n## MOSS\n\nYou are equipped with a MOSS (model-oriented operating system simulation) in which you can generate Python code and \npython libraries to reach your goal.\n\nThe `PYTHON CONTEXT` that MOSS already provide to you are below: \n\n```python\n\nfrom __future__ import annotations\n\nfrom typing import Optional, List\nfrom ghostos.core.aifunc import AIFunc, AIFuncResult\nfrom pydantic import BaseModel, Field\nfrom ghostos.core.moss import Moss\n\n\nclass NewsAIFunc(AIFunc):\n \"\"\"\n search news\n \"\"\"\n query: str = Field(description=\"required news query.\")\n limit: int = Field(default=5, description=\"how many news you want.\")\n\n\nclass NewsAIFuncResult(AIFuncResult):\n \"\"\"\n news result\n \"\"\"\n\n class News(BaseModel):\n summary: str = Field(description=\"summary of the news.\")\n title: str = Field(description=\"title of the news.\")\n date: str = Field(description=\"date of the news.\")\n media: str = Field(description=\"media of the news.\")\n\n results: List[News] = Field(default_factory=list)\n\n\n\n\n\n# more details about some module attrs above, are list below (quoted by ):\n\"\"\"\n# \nclass AIFunc(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \nclass AIFuncResult(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \nclass Moss(ABC):\n \\\"\"\"\n Language Model-oriented Operating System Simulation.\n Full python code interface for large language models in multi-turns chat or thinking.\n The property with SerializeType will persist during multi-turns.\n SerializeType means: int, float, str, None, list, dict, BaseModel, TypedDict\n You can edit them if you need.\n \\\"\"\"\n\n T = TypeVar('T')\n\n @abstractmethod\n def fetch(self, abstract: Type[T]) -> Optional[T]:\n \\\"\"\"\n fetch an implementation from IoC Container\n if the abstract type is not bound with any implementation, return None.\n \\\"\"\"\n pass\n\n @abstractmethod\n def pprint(self, *args, **kwargs) -> None:\n \\\"\"\"\n pretty printer\n \\\"\"\"\n pass\n# \n\"\"\"\n\n\n```\n\nYou shall generate a single block of Python code, \nand in the code you shall defines a main function like: \n`def main(moss: Moss, fn: NewsAIFunc):`\n\nabout the params and returns:\n:param moss: A Moss instance\n:param fn: An instance of NewsAIFunc\n:return: tuple[result:NewsAIFuncResult | None , ok: bool]\nIf ok is False, means you need to observe the output that you print in the function, and think another round.\nIf ok is True, means you finish the request and return a final result.\n\nThe MOSS will automatically execute the main function you generated with the instance of class Moss, \ntake next step based on the returns. \n\nSo you shall not repeat the exists code that already provide in the `PYTHON CONTEXT`.\n", + "memory": null, + "attrs": null, + "payloads": {}, + "callers": [], + "seq": "complete", + "created": 1731600263.1436 + }, + { + "msg_id": "924b8103-50bc-49f7-93bc-e0390c00edd6", + "ref_id": null, + "index": null, + "type": "", + "role": "system", + "name": null, + "content": "\n## Notices\n- The code you generated shall start with and end with , the outside system will automatically execute the code between the mark.\n- You can import basic python module if you needed. \n- The variables defined in the main function will not keep in memory during multi-turns thinking. Unless some lib (AIFuncCtx) provide the ability.\n- MOSS will automatic execute the main function so YOU SHOULD NEVER EXECUTE IT YOURSELF.\n- If you are not equipped enough to resolve your quest, you shall admit the it in the result or raise an exception.\n- **You are not Agent, DO NOT TALK ABOUT YOUR THOUGHT, JUST WRITE THE CODES ONLY**\n", + "memory": null, + "attrs": null, + "payloads": {}, + "callers": [], + "seq": "complete", + "created": 1731600263.1436 + } + ], + "history": [ + { + "msg_id": "7813b0d9-87aa-432d-912a-6847ef42166b", + "ref_id": null, + "index": null, + "type": "", + "role": "system", + "name": null, + "content": "Your task is **MOCKING** a result from the function arguments, make it seems real.the limit of fn is 10", + "memory": null, + "attrs": null, + "payloads": {}, + "callers": [], + "seq": "complete", + "created": 1731600263.1285 + } + ], + "inputs": [], + "appending": [], + "functions": [], + "function_call": null, + "functional_tokens": [], + "output": [ + { + "created": 1731600264.0 + } + ], + "error": null, + "created": 1731600263.1437 +} \ No newline at end of file diff --git a/app/runtime/prompts/a7fa30d9-8fe8-40d8-a2c2-b434a12e8dfb.prompt.json b/app/runtime/prompts/a7fa30d9-8fe8-40d8-a2c2-b434a12e8dfb.prompt.json new file mode 100644 index 00000000..1f884efa --- /dev/null +++ b/app/runtime/prompts/a7fa30d9-8fe8-40d8-a2c2-b434a12e8dfb.prompt.json @@ -0,0 +1,65 @@ +{ + "id": "a7fa30d9-8fe8-40d8-a2c2-b434a12e8dfb", + "description": "description of this prompt", + "system": [ + { + "msg_id": "193bb8cf-5bca-4f43-babd-139783915a8f", + "ref_id": null, + "index": null, + "type": "", + "role": "system", + "name": null, + "content": "\n# Who Are You \n\nYou are an AIFunc named `NewsAIFunc`.\nAIFunc is a LLM-driven function that could complete request by multi-turns thinking,\nAnd your purpose is to generate AIFuncResult `NewsAIFuncResult` as final result.\n\n## MOSS\n\nYou are equipped with a MOSS (model-oriented operating system simulation) in which you can generate Python code and \npython libraries to reach your goal.\n\nThe `PYTHON CONTEXT` that MOSS already provide to you are below: \n\n```python\n\nfrom __future__ import annotations\n\nfrom typing import Optional, List\nfrom ghostos.core.aifunc import AIFunc, AIFuncResult\nfrom pydantic import BaseModel, Field\nfrom ghostos.core.moss import Moss\n\n\nclass NewsAIFunc(AIFunc):\n \"\"\"\n search news\n \"\"\"\n query: str = Field(description=\"required news query.\")\n limit: int = Field(default=5, description=\"how many news you want.\")\n\n\nclass NewsAIFuncResult(AIFuncResult):\n \"\"\"\n news result\n \"\"\"\n\n class News(BaseModel):\n summary: str = Field(description=\"summary of the news.\")\n title: str = Field(description=\"title of the news.\")\n date: str = Field(description=\"date of the news.\")\n media: str = Field(description=\"media of the news.\")\n\n results: List[News] = Field(default_factory=list)\n\n\n\n\n\n# more details about some module attrs above, are list below (quoted by ):\n\"\"\"\n# \nclass AIFunc(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \nclass AIFuncResult(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \nclass Moss(ABC):\n \\\"\"\"\n Language Model-oriented Operating System Simulation.\n Full python code interface for large language models in multi-turns chat or thinking.\n The property with SerializeType will persist during multi-turns.\n SerializeType means: int, float, str, None, list, dict, BaseModel, TypedDict\n You can edit them if you need.\n \\\"\"\"\n\n T = TypeVar('T')\n\n @abstractmethod\n def fetch(self, abstract: Type[T]) -> Optional[T]:\n \\\"\"\"\n fetch an implementation from IoC Container\n if the abstract type is not bound with any implementation, return None.\n \\\"\"\"\n pass\n\n @abstractmethod\n def pprint(self, *args, **kwargs) -> None:\n \\\"\"\"\n pretty printer\n \\\"\"\"\n pass\n# \n\"\"\"\n\n\n```\n\nYou shall generate a single block of Python code, \nand in the code you shall defines a main function like: \n`def main(moss: Moss, fn: NewsAIFunc):`\n\nabout the params and returns:\n:param moss: A Moss instance\n:param fn: An instance of NewsAIFunc\n:return: tuple[result:NewsAIFuncResult | None , ok: bool]\nIf ok is False, means you need to observe the output that you print in the function, and think another round.\nIf ok is True, means you finish the request and return a final result.\n\nThe MOSS will automatically execute the main function you generated with the instance of class Moss, \ntake next step based on the returns. \n\nSo you shall not repeat the exists code that already provide in the `PYTHON CONTEXT`.\n", + "memory": null, + "attrs": null, + "payloads": {}, + "callers": [], + "seq": "complete", + "created": 1731600325.361 + }, + { + "msg_id": "6a5d79b1-1e7e-43aa-9477-bf8502652986", + "ref_id": null, + "index": null, + "type": "", + "role": "system", + "name": null, + "content": "\n## Notices\n- The code you generated shall start with and end with , the outside system will automatically execute the code between the mark.\n- You can import basic python module if you needed. \n- The variables defined in the main function will not keep in memory during multi-turns thinking. Unless some lib (AIFuncCtx) provide the ability.\n- MOSS will automatic execute the main function so YOU SHOULD NEVER EXECUTE IT YOURSELF.\n- If you are not equipped enough to resolve your quest, you shall admit the it in the result or raise an exception.\n- **You are not Agent, DO NOT TALK ABOUT YOUR THOUGHT, JUST WRITE THE CODES ONLY**\n", + "memory": null, + "attrs": null, + "payloads": {}, + "callers": [], + "seq": "complete", + "created": 1731600325.3611 + } + ], + "history": [ + { + "msg_id": "d02d9b15-e69e-4c96-a070-61b3b40dcc09", + "ref_id": null, + "index": null, + "type": "", + "role": "system", + "name": null, + "content": "Your task is **MOCKING** a result from the function arguments, make it seems real.the limit of fn is 5", + "memory": null, + "attrs": null, + "payloads": {}, + "callers": [], + "seq": "complete", + "created": 1731600325.3451 + } + ], + "inputs": [], + "appending": [], + "functions": [], + "function_call": null, + "functional_tokens": [], + "output": [ + { + "created": 1731600325.0 + } + ], + "error": null, + "created": 1731600325.3611 +} \ No newline at end of file diff --git a/app/runtime/prompts/f16369cc-0c61-48d0-8b17-6efb01e487cc.prompt.json b/app/runtime/prompts/f16369cc-0c61-48d0-8b17-6efb01e487cc.prompt.json new file mode 100644 index 00000000..df43194c --- /dev/null +++ b/app/runtime/prompts/f16369cc-0c61-48d0-8b17-6efb01e487cc.prompt.json @@ -0,0 +1,65 @@ +{ + "id": "f16369cc-0c61-48d0-8b17-6efb01e487cc", + "description": "description of this prompt", + "system": [ + { + "msg_id": "824e30f3-f03a-480b-835c-a15b338e6893", + "ref_id": null, + "index": null, + "type": "", + "role": "system", + "name": null, + "content": "\n# Who Are You \n\nYou are an AIFunc named `AgentFn`.\nAIFunc is a LLM-driven function that could complete request by multi-turns thinking,\nAnd your purpose is to generate AIFuncResult `AgentFnResult` as final result.\n\n## MOSS\n\nYou are equipped with a MOSS (model-oriented operating system simulation) in which you can generate Python code and \npython libraries to reach your goal.\n\nThe `PYTHON CONTEXT` that MOSS already provide to you are below: \n\n```python\n\nfrom __future__ import annotations\n\nfrom typing import Optional\nfrom ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx\nfrom ghostos.core.moss import Moss as Parent\nfrom ghostos.demo.aifuncs.weather import WeatherAIFunc, WeatherAIFuncResult\nfrom ghostos.demo.aifuncs.news import NewsAIFunc, NewsAIFuncResult\nfrom pydantic import Field\n\n\nclass AgentFn(AIFunc):\n \"\"\"\n AIFunc that act like an agent\n \"\"\"\n request: str = Field(description=\"raw request for the agent\")\n\n\nclass AgentFnResult(AIFuncResult):\n \"\"\"\n the result that follow the agent request\n \"\"\"\n result: str = Field(description=\"response from the agent\")\n err: Optional[str] = Field(default=None, description=\"error message\")\n\n\nclass Moss(Parent):\n ai_func_ctx: AIFuncCtx\n \"\"\"useful to run AIFunc\"\"\"\n\n\n\n\n\n# more details about some module attrs above, are list below (quoted by ):\n\"\"\"\n# \nclass AIFunc(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \nclass AIFuncResult(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \n@cls_source_code()\nclass AIFuncCtx(ABC):\n \\\"\"\"\n System context that could execute an AIFunc and keep result in it during multi-turns thinking.\n \\\"\"\"\n\n @abstractmethod\n def run(self, key: str, fn: AIFunc) -> AIFuncResult:\n \\\"\"\"\n Run an AIFunc subclass instance, got result and save it into the key.\n :param key: the key that ctx keep the result in multi-turns thinking.\n :param fn: instance of AIFunc that define the task.\n :return: the certain result that match AIFuncResult and is not None\n :exception: TooManyFailureError\n \\\"\"\"\n pass\n\n @abstractmethod\n def parallel_run(self, fn_dict: Dict[str, AIFunc]) -> Dict[str, AIFuncResult]:\n \\\"\"\"\n Run multiple AIFunc instances in parallel and save their results.\n \n :param fn_dict: A dictionary where keys are result identifiers and values are AIFunc instances.\n :return: A dictionary where keys are the same as in fn_dict and values are the corresponding AIFuncResults.\n \n This method allows for concurrent execution of multiple AIFunc instances, which can improve\n performance when dealing with independent tasks. The results are stored and can be accessed\n using the keys provided in the input dictionary.\n \\\"\"\"\n pass\n\n @abstractmethod\n def get(self, key: str) -> Optional[Any]:\n \\\"\"\"\n get a cached value by key.\n \\\"\"\"\n pass\n\n @abstractmethod\n def set(self, key: str, value: Any) -> None:\n \\\"\"\"\n set a value to ctx, keep it in multi-turns thinking.\n \\\"\"\"\n pass\n\n @abstractmethod\n def values(self) -> Dict[str, Any]:\n \\\"\"\"\n return all values of the AiFuncCtx\n \\\"\"\"\n pass\n# \n\n# \nclass Moss(ABC):\n \\\"\"\"\n Language Model-oriented Operating System Simulation.\n Full python code interface for large language models in multi-turns chat or thinking.\n The property with SerializeType will persist during multi-turns.\n SerializeType means: int, float, str, None, list, dict, BaseModel, TypedDict\n You can edit them if you need.\n \\\"\"\"\n\n T = TypeVar('T')\n\n @abstractmethod\n def fetch(self, abstract: Type[T]) -> Optional[T]:\n \\\"\"\"\n fetch an implementation from IoC Container\n if the abstract type is not bound with any implementation, return None.\n \\\"\"\"\n pass\n\n @abstractmethod\n def pprint(self, *args, **kwargs) -> None:\n \\\"\"\"\n pretty printer\n \\\"\"\"\n pass\n# \n\n# \nclass WeatherAIFunc(AIFunc):\n \\\"\"\"\n tell about weather\n \\\"\"\"\n city: str = Field(default=\"\", description=\"the city name that you want weather forecast. empty means local\")\n date: str = Field(default=\"today\", description=\"the date of weather forecast\")\n\n\n# result type of WeatherAIFunc (which maybe not imported yet) is :\n# class WeatherAIFuncResult(AIFuncResult):\n# \\\"\"\"\n# weather result\n# \\\"\"\"\n# result: str = Field(description=\"the full result describing weather details in nature language form.\")\n# date: str = Field(default=\"today\", description=\"the date of weather forecast\")\n# city: str = Field(default=\"\", description=\"the city name that you want weather forecast. empty means local\")\n# temperature: Optional[float] = Field(default=None, description=\"the temperature of the weather\")\n# humidity: Optional[float] = Field(default=None, description=\"the humidity of the weather\")\n# pressure: Optional[float] = Field(default=None, description=\"the pressure of the weather\")\n# wind_speed: Optional[float] = Field(default=None, description=\"the wind speed of the weather\")\n# wind_dir: Optional[float] = Field(default=None, description=\"the wind direction of the weather\")\n#\n# \n\n# \nclass WeatherAIFuncResult(AIFuncResult):\n \\\"\"\"\n weather result\n \\\"\"\"\n result: str = Field(description=\"the full result describing weather details in nature language form.\")\n date: str = Field(default=\"today\", description=\"the date of weather forecast\")\n city: str = Field(default=\"\", description=\"the city name that you want weather forecast. empty means local\")\n temperature: Optional[float] = Field(default=None, description=\"the temperature of the weather\")\n humidity: Optional[float] = Field(default=None, description=\"the humidity of the weather\")\n pressure: Optional[float] = Field(default=None, description=\"the pressure of the weather\")\n wind_speed: Optional[float] = Field(default=None, description=\"the wind speed of the weather\")\n wind_dir: Optional[float] = Field(default=None, description=\"the wind direction of the weather\")\n# \n\n# \nclass NewsAIFunc(AIFunc):\n \\\"\"\"\n search news\n \\\"\"\"\n query: str = Field(description=\"required news query.\")\n limit: int = Field(default=5, description=\"how many news you want.\")\n\n\n# result type of NewsAIFunc (which maybe not imported yet) is :\n# class NewsAIFuncResult(AIFuncResult):\n# \\\"\"\"\n# news result\n# \\\"\"\"\n# \n# class News(BaseModel):\n# summary: str = Field(description=\"summary of the news.\")\n# title: str = Field(description=\"title of the news.\")\n# date: str = Field(description=\"date of the news.\")\n# media: str = Field(description=\"media of the news.\")\n# \n# results: List[News] = Field(default_factory=list)\n#\n# \n\n# \nclass NewsAIFuncResult(AIFuncResult):\n \\\"\"\"\n news result\n \\\"\"\"\n\n class News(BaseModel):\n summary: str = Field(description=\"summary of the news.\")\n title: str = Field(description=\"title of the news.\")\n date: str = Field(description=\"date of the news.\")\n media: str = Field(description=\"media of the news.\")\n\n results: List[News] = Field(default_factory=list)\n# \n\"\"\"\n\n\n```\n\nYou shall generate a single block of Python code, \nand in the code you shall defines a main function like: \n`def main(moss: Moss, fn: AgentFn):`\n\nabout the params and returns:\n:param moss: A Moss instance\n:param fn: An instance of AgentFn\n:return: tuple[result:AgentFnResult | None , ok: bool]\nIf ok is False, means you need to observe the output that you print in the function, and think another round.\nIf ok is True, means you finish the request and return a final result.\n\nThe MOSS will automatically execute the main function you generated with the instance of class Moss, \ntake next step based on the returns. \n\nSo you shall not repeat the exists code that already provide in the `PYTHON CONTEXT`.\n", + "memory": null, + "attrs": null, + "payloads": {}, + "callers": [], + "seq": "complete", + "created": 1731600314.6548 + }, + { + "msg_id": "96634960-2648-4f7e-acca-c91e159178cd", + "ref_id": null, + "index": null, + "type": "", + "role": "system", + "name": null, + "content": "\n## Notices\n- The code you generated shall start with and end with , the outside system will automatically execute the code between the mark.\n- You can import basic python module if you needed. \n- The variables defined in the main function will not keep in memory during multi-turns thinking. Unless some lib (AIFuncCtx) provide the ability.\n- MOSS will automatic execute the main function so YOU SHOULD NEVER EXECUTE IT YOURSELF.\n- If you are not equipped enough to resolve your quest, you shall admit the it in the result or raise an exception.\n- **You are not Agent, DO NOT TALK ABOUT YOUR THOUGHT, JUST WRITE THE CODES ONLY**\n", + "memory": null, + "attrs": null, + "payloads": {}, + "callers": [], + "seq": "complete", + "created": 1731600314.6548 + } + ], + "history": [ + { + "msg_id": "dea70138-61a1-40da-9870-eadaa9b33c4b", + "ref_id": null, + "index": null, + "type": "", + "role": "system", + "name": null, + "content": "help me to find news about OpenAI O1 model", + "memory": null, + "attrs": null, + "payloads": {}, + "callers": [], + "seq": "complete", + "created": 1731600314.606 + } + ], + "inputs": [], + "appending": [], + "functions": [], + "function_call": null, + "functional_tokens": [], + "output": [ + { + "created": 1731600319.0 + } + ], + "error": null, + "created": 1731600314.6548 +} \ No newline at end of file diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 74f40829..a645c5ee 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -1,5 +1,6 @@ from typing import List, Optional from os.path import dirname, join +from ghostos.core.abcd import GhostOS from ghostos.container import Container, Provider, Contracts from ghostos.contracts.logger import config_logging from ghostos.prototypes.ghostfunc import init_ghost_func, GhostFunc @@ -103,6 +104,7 @@ def default_application_contracts() -> Contracts: from ghostos.framework.llms import LLMs, PromptStorage from ghostos.framework.logger import LoggerItf from ghostos.framework.documents import DocumentRegistry + from ghostos.framework.ghostos import GhostOS from ghostos.core.aifunc import AIFuncExecutor, AIFuncRepository return Contracts([ @@ -137,6 +139,9 @@ def default_application_contracts() -> Contracts: GoThreads, # application threads repository GoTasks, # application tasks repository EventBus, # application session eventbus + + # root + GhostOS, ]) @@ -168,6 +173,7 @@ def default_application_providers( from ghostos.framework.llms import ConfigBasedLLMsProvider, PromptStorageInWorkspaceProvider from ghostos.framework.logger import NamedLoggerProvider from ghostos.framework.variables import WorkspaceVariablesProvider + from ghostos.framework.ghostos import GhostOSProvider from ghostos.core.aifunc import DefaultAIFuncExecutorProvider, AIFuncRepoByConfigsProvider from ghostos.framework.documents import ConfiguredDocumentRegistryProvider return [ @@ -210,6 +216,8 @@ def default_application_providers( # --- aifunc --- # DefaultAIFuncExecutorProvider(), AIFuncRepoByConfigsProvider(runtime_frame_dir="aifunc_frames"), + + GhostOSProvider() ] @@ -221,6 +229,15 @@ def make_app_container( app_providers: Optional[List[Provider]] = None, app_contracts: Optional[Contracts] = None, ) -> Container: + """ + make application global container + :param app_dir: + :param logging_conf_path: + :param dotenv_file_path: + :param app_providers: + :param app_contracts: + :return: + """ # load env from dotenv file dotenv.load_dotenv(dotenv_path=join(app_dir, dotenv_file_path)) logging_conf_path = join(app_dir, logging_conf_path) @@ -252,6 +269,12 @@ def make_app_container( """ the default ghost func on default container""" +def get_ghostos(container: Optional[Container] = None) -> GhostOS: + if container is None: + container = application_container + return container.force_fetch(GhostOS) + + def reset(con: Container) -> None: """ reset static ghostos application level instances diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index 66694205..061858c4 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -5,8 +5,8 @@ ) from abc import ABC, abstractmethod -from ghostos.identifier import Identifiable -from ghostos.entity import EntityType +from ghostos.identifier import Identical +from ghostos.entity import EntityType, Entity from ghostos.prompter import Prompter, DataPrompter, DataPrompterDriver from ghostos.core.runtime import ( TaskState, @@ -57,7 +57,7 @@ ) -class Ghost(Identifiable, EntityType, ABC): +class Ghost(Identical, Entity, ABC): """ the class defines the model of a kind of ghosts. four parts included: @@ -67,10 +67,10 @@ class Ghost(Identifiable, EntityType, ABC): 4. driver is """ - Artifact: ClassVar[Union[Type, None]] = None + Artifact: ClassVar[Optional[Type]] = None """ the model of the ghost's artifact, is completing during runtime""" - Context: ClassVar[Type[Context], None] = None + Context: ClassVar[Optional[Type[Context]]] = None """ the model of the ghost's context, is completing during runtime'""" Driver: Type[GhostDriver] = None diff --git a/ghostos/core/abcd/ghosts.py b/ghostos/core/abcd/ghosts.py index 66e1b2b8..fb7b6197 100644 --- a/ghostos/core/abcd/ghosts.py +++ b/ghostos/core/abcd/ghosts.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import ClassVar, Type from ghostos.identifier import Identifier from pydantic import BaseModel from .concepts import Ghost @@ -51,7 +52,7 @@ class Thought(BaseModel, Ghost, ABC): Thought is a micro unit to processing thinking with current context; the Goal of the Thought is to produce a decision or suggestion, add them to the context. """ - Artifact = str + Artifact: ClassVar = str @abstractmethod def __identifier__(self) -> Identifier: diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index 3ef8554d..d9a5ca96 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -107,7 +107,7 @@ def initialize(self, container: Container, frame: ExecFrame) -> GoThreadInfo: ) messages.append(system_message) - event = EventTypes.REQUEST.new( + event = EventTypes.ROTATE.new( task_id="", from_task_id="", messages=messages, diff --git a/ghostos/entity.py b/ghostos/entity.py index 1e88e805..d1af2d76 100644 --- a/ghostos/entity.py +++ b/ghostos/entity.py @@ -6,7 +6,6 @@ from types import ModuleType from pydantic import BaseModel from ghostos.helpers import generate_import_path, import_from_path, parse_import_module_and_spec -from typing_extensions import Protocol import inspect import pickle import base64 @@ -21,7 +20,7 @@ ] -class Entity(Protocol): +class Entity(ABC): @abstractmethod def __to_entity_meta__(self) -> EntityMeta: diff --git a/ghostos/framework/ghostos/__init__.py b/ghostos/framework/ghostos/__init__.py index fa56baf1..c11509ec 100644 --- a/ghostos/framework/ghostos/__init__.py +++ b/ghostos/framework/ghostos/__init__.py @@ -1,5 +1,3 @@ -from ghostos.framework.ghostos.basic import BasicGhostOS -from ghostos.framework.ghostos.demo_os import DemoGhostOS, DemoGhostOSConf - -demo_ghostos = DemoGhostOS() -""" demo ghost os""" +from ghostos.framework.ghostos.ghostos_impl import GhostOS, GhostOSImpl, GhostOSConfig, GhostOSProvider +from ghostos.framework.ghostos.shell_impl import ShellImpl, ShellConf, Shell +from ghostos.framework.ghostos.conversation_impl import Conversation, ConversationImpl, ConversationConf diff --git a/ghostos/framework/ghostos/basic.py b/ghostos/framework/ghostos/basic.py deleted file mode 100644 index 7a2a285a..00000000 --- a/ghostos/framework/ghostos/basic.py +++ /dev/null @@ -1,142 +0,0 @@ -from typing import Optional, List -from abc import ABC, abstractmethod -from os.path import join, dirname -import yaml -from logging.config import dictConfig -from ghostos.container import Container -from ghostos.core.ghostos import AbsGhostOS -from ghostos.core.ghosts import Ghost -from ghostos.core.messages import Stream -from ghostos.core.runtime import GoProcess, GoTaskStruct -from ghostos.contracts.shutdown import ShutdownProvider -from ghostos.contracts.modules import Modules, DefaultModulesProvider -from ghostos.framework.storage import FileStorageProvider -from ghostos.framework.logger import NamedLoggerProvider -from ghostos.framework.workspaces import BasicWorkspaceProvider -from ghostos.framework.configs import WorkspaceConfigsProvider -from ghostos.framework.threads import MsgThreadsRepoByWorkSpaceProvider -from ghostos.framework.processes import WorkspaceProcessesProvider -from ghostos.framework.tasks import WorkspaceTasksProvider -from ghostos.framework.llms import ConfigBasedLLMsProvider -from ghostos.framework.eventbuses import MemEventBusImplProvider -from ghostos.contracts.pool import DefaultPoolProvider -from ghostos.entity import EntityFactory, EntityFactoryImpl -from ghostos.container import Provider, Bootstrapper - -project_dir = dirname(dirname(dirname(__file__))) -demo_dir = join(project_dir, "demo") -logging_conf_path = join(demo_dir, "configs/logging.yml") - -__all__ = ['BasicGhostOS'] - - -class BasicGhostOS(AbsGhostOS, ABC): - - def __init__( - self, *, - root_dir: str = demo_dir, - logger_conf_path: str = logging_conf_path, - logger_name: str = "debug", - config_path: str = "configs", - runtime_path: str = "runtime", - source_path: str = "src", - processes_path: str = "processes", - tasks_path: str = "tasks", - threads_path: str = "threads", - llm_config_path: str = "llms_conf.yml", - container: Optional[Container] = None, - providers: Optional[List[Provider]] = None, - bootstrapper: Optional[List[Bootstrapper]] = None, - ): - self._root_dir = root_dir - self._processes_path = processes_path - self._tasks_path = tasks_path - self._threads_path = threads_path - self._llm_config_path = llm_config_path - self._config_path = config_path - self._runtime_path = runtime_path - self._source_path = source_path - - # container - self._container = container if container else Container() - self._prepare_logger(logger_conf_path, logger_name) - self._prepare_container(providers, bootstrapper) - - modules = self._container.force_fetch(Modules) - # register entity factory - self._entity_factory = EntityFactoryImpl(modules.import_module) - self._container.set(EntityFactory, self._entity_factory) - self._container.bootstrap() - self._on_initialized() - - @abstractmethod - def _on_initialized(self): - """ - callback on initialized the ghost os - """ - pass - - @abstractmethod - def make_ghost( - self, *, - upstream: Stream, - process: GoProcess, - task: Optional[GoTaskStruct] = None, - task_id: Optional[str] = None, - ) -> Ghost: - pass - - @abstractmethod - def on_error(self, error: Exception) -> bool: - pass - - def _default_providers(self) -> List[Provider]: - return [ - FileStorageProvider(self._root_dir), - BasicWorkspaceProvider( - workspace_dir="", - configs_path=self._config_path, - runtime_path=self._runtime_path, - ), - DefaultModulesProvider(), - WorkspaceConfigsProvider(), - WorkspaceProcessesProvider(self._processes_path), - WorkspaceTasksProvider(self._tasks_path), - MsgThreadsRepoByWorkSpaceProvider(self._threads_path), - DefaultPoolProvider(100), - ConfigBasedLLMsProvider(self._llm_config_path), - MemEventBusImplProvider(), - ShutdownProvider(), - ] - - def _prepare_container( - self, - providers: Optional[List[Provider]] = None, - bootstrapper: Optional[List[Bootstrapper]] = None, - ): - if providers: - for provider in providers: - self._container.register(provider) - if bootstrapper: - for b in bootstrapper: - self._container.add_bootstrapper(b) - # register default providers. - for provider in self._default_providers(): - contract = provider.contract() - # 只有未绑定的, 才会使用默认的去绑定. - if not self._container.bound(contract): - self._container.register(provider) - - def _prepare_logger(self, logger_conf_path: str, logger_name: str): - with open(logger_conf_path, "rb") as f: - content = f.read() - data = yaml.safe_load(content) - dictConfig(data) - self._container.register(NamedLoggerProvider(logger_name)) - - def container(self) -> Container: - return self._container - - def destroy(self) -> None: - self._container.destroy() - del self._container diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index a736d150..5588a5d1 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -17,6 +17,8 @@ from pydantic import BaseModel, Field from .session_impl import SessionImpl +__all__ = ["ConversationImpl", "ConversationConf", "Conversation"] + class ConversationConf(BaseModel): message_receiver_idle: float = Field( @@ -75,7 +77,7 @@ def container(self) -> Container: def task(self) -> GoTaskStruct: return self._tasks.get_task(self._scope.task_id) - def get_artifact(self) -> Tuple[Union[G.Artifact, None], TaskState]: + def get_artifact(self) -> Tuple[Union[Ghost.Artifact, None], TaskState]: task = self.task() session = self._create_session(task, self._locker, None) with session: @@ -88,7 +90,7 @@ def ask(self, query: str, user_name: str = "") -> Receiver: def respond( self, inputs: Iterable[Message], - context: Optional[G.Context] = None, + context: Optional[Ghost.Context] = None, history: Optional[List[Message]] = None, ) -> Receiver: self._validate_closed() diff --git a/ghostos/framework/ghostos/demo_os.py b/ghostos/framework/ghostos/demo_os.py deleted file mode 100644 index 296ae377..00000000 --- a/ghostos/framework/ghostos/demo_os.py +++ /dev/null @@ -1,76 +0,0 @@ -from typing import Optional, ClassVar, Dict - -from ghostos.core.ghosts import Ghost, GhostConf, Workspace, Shell -from ghostos.core.messages import Stream -from ghostos.core.runtime import GoProcess, GoTaskStruct -from ghostos.contracts.logger import LoggerItf -from ghostos.contracts.configs import Configs, YamlConfig - -from ghostos.entity import EntityMeta -from ghostos.framework.shells import EmptyShell -from ghostos.framework.ghostos.basic import BasicGhostOS -from ghostos.framework.ghosts import DemoGhostConf, DemoGhost -from pydantic import Field - - -class DemoGhostOSConf(YamlConfig): - relative_path: ClassVar[str] = "ghosts.yml" - ghosts: Dict[str, EntityMeta] = Field(default_factory=dict, description="ghost conf entity metas, key is ghost id") - - -class DemoGhostOS(BasicGhostOS): - - def _on_initialized(self): - configs = self.container().force_fetch(Configs) - ghosts_confs = configs.get(DemoGhostOSConf) - self._ghosts_metas = ghosts_confs.ghosts - - def make_ghost( - self, *, - upstream: Stream, - process: GoProcess, - task: Optional[GoTaskStruct] = None, - task_id: Optional[str] = None, - ) -> Ghost: - conf = self._entity_factory.force_new_entity(process.ghost_meta, GhostConf) - return self._make_ghost_instance(conf, upstream, process, task, task_id) - - def register(self, ghost_conf: GhostConf) -> None: - ghost_id = ghost_conf.identifier().id - self._ghosts_metas[ghost_id] = ghost_conf.to_entity_meta() - - def _make_ghost_instance( - self, - conf: GhostConf, - upstream: Stream, - process: GoProcess, - task: Optional[GoTaskStruct] = None, - task_id: Optional[str] = None, - ) -> Ghost: - if isinstance(conf, DemoGhostConf): - return DemoGhost( - conf=conf, - container=self.container(), - entity_factory=self._entity_factory, - workspace=self.container().force_fetch(Workspace), - shell=self._make_shell(conf), - process=process, - upstream=upstream, - task=task, - task_id=task_id, - ) - else: - raise NotImplementedError(f"GhostOS {conf} is not supported yet.") - - def _make_shell(self, ghost_conf: GhostConf) -> Shell: - return EmptyShell() - - def on_error(self, error: Exception) -> bool: - logger = self.container().force_fetch(LoggerItf) - logger.error(str(error)) - return True - - def get_ghost_meta(self, ghost_id: str) -> Optional[EntityMeta]: - if ghost_id in self._ghosts_metas: - return self._ghosts_metas[ghost_id] - return None diff --git a/ghostos/framework/ghostos/ghostos_impl.py b/ghostos/framework/ghostos/ghostos_impl.py index b155a20c..a30b6c2f 100644 --- a/ghostos/framework/ghostos/ghostos_impl.py +++ b/ghostos/framework/ghostos/ghostos_impl.py @@ -1,12 +1,19 @@ from typing import Optional, Dict from ghostos.core.abcd.concepts import GhostOS, Shell -from ghostos.core.runtime import GoProcesses, GoProcess -from ghostos.container import Container, Provider +from ghostos.core.runtime import GoProcesses, GoProcess, GoThreads, GoTasks, EventBus +from ghostos.container import Container, Provider, Contracts, INSTANCE from ghostos.contracts.configs import Configs, YamlConfig -from pydantic import BaseModel, Field +from ghostos.contracts.modules import Modules +from ghostos.contracts.pool import Pool +from ghostos.contracts.variables import Variables +from ghostos.contracts.workspace import Workspace +from ghostos.contracts.logger import LoggerItf +from pydantic import Field from .shell_impl import ShellImpl, ShellConf +__all__ = ['GhostOS', "GhostOSImpl", "GhostOSConfig", "GhostOSProvider"] + class GhostOSConfig(YamlConfig): relative_path = "ghostos.yml" @@ -16,11 +23,24 @@ class GhostOSConfig(YamlConfig): class GhostOSImpl(GhostOS): + contracts: Contracts([ + GoProcesses, + GoTasks, + GoThreads, + EventBus, + LoggerItf, + Configs, + Modules, + Pool, + Variables, + Workspace, + ]) def __init__( self, container: Container, ): + self.contracts.validate(container) self._container = container self._processes = container.force_fetch(GoProcesses) self._configs = container.force_fetch(Configs) @@ -54,3 +74,11 @@ def create_shell( process=process, *providers, ) + + +class GhostOSProvider(Provider[GhostOS]): + def singleton(self) -> bool: + return True + + def factory(self, con: Container) -> Optional[INSTANCE]: + return GhostOSImpl(con) diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 6e3718f7..7ae84a36 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -35,7 +35,7 @@ def destroy(self): pass -class SessionImpl(Session[G]): +class SessionImpl(Session[Ghost]): contracts = Contracts([ GoThreads, GoTasks, @@ -191,7 +191,7 @@ def get_context(self) -> Optional[Prompter]: return None return get_entity(self.task.context, Prompter) - def get_artifact(self) -> G.Artifact: + def get_artifact(self) -> Ghost.Artifact: return self.ghost_driver.get_artifact(self) def refresh(self) -> bool: @@ -253,7 +253,7 @@ def create_threads(self, *threads: GoThreadInfo) -> None: for t in threads: self._saving_threads[t.id] = t - def call(self, ghost: G, ctx: G.Props) -> G.Artifact: + def call(self, ghost: Ghost, ctx: Ghost.Context) -> Ghost.Artifact: self._validate_alive() shell = self.container.force_fetch(Shell) return shell.call(ghost, ctx) diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index f7b1cff9..e448e609 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -10,16 +10,16 @@ GoTasks, TaskState, GoTaskStruct, ) from ghostos.core.messages import Stream -from ghostos.prompter import Prompter from ghostos.container import Provider from ghostos.helpers import uuid, Timeleft from ghostos.identifier import get_identifier from ghostos.entity import to_entity_meta -from ghostos.prompter import TextPrmt from pydantic import BaseModel, Field from threading import Thread from .conversation_impl import ConversationImpl, ConversationConf +__all__ = ['ShellConf', 'ShellImpl', 'Shell'] + class ShellConf(BaseModel): persona: str = Field( @@ -81,7 +81,7 @@ def send_event(self, event: Event) -> None: notify = task.depth > 0 self._eventbus.send_event(event, notify) - def sync(self, ghost: G, context: Optional[G.Context] = None) -> Conversation: + def sync(self, ghost: Ghost, context: Optional[Ghost.Context] = None) -> Conversation: driver = get_ghost_driver(ghost) task_id = driver.make_task_id(self._scope) task = self._tasks.get_task(task_id) @@ -122,12 +122,12 @@ def sync_task( def call( self, - ghost: G, - context: Optional[G.Context] = None, + ghost: Ghost, + context: Optional[Ghost.Context] = None, instructions: Optional[Iterable[Message]] = None, timeout: float = 0.0, stream: Optional[Stream] = None, - ) -> Tuple[Union[G.Artifact, None], TaskState]: + ) -> Tuple[Union[Ghost.Artifact, None], TaskState]: def send_message(receiver: Receiver): with receiver: @@ -157,8 +157,8 @@ def send_message(receiver: Receiver): def create_root_task( self, - ghost: G, - context: Optional[G.Context], + ghost: Ghost, + context: Optional[Ghost.Context], ) -> GoTaskStruct: task_id = uuid() id_ = get_identifier(ghost) diff --git a/ghostos/framework/ghosts/basic.py b/ghostos/framework/ghosts/basic.py index 2b59d2ac..d8802317 100644 --- a/ghostos/framework/ghosts/basic.py +++ b/ghostos/framework/ghosts/basic.py @@ -295,7 +295,7 @@ def on_inputs(self, inputs: Inputs) -> Optional["Event"]: messages=inputs.messages, ) else: - event = EventTypes.REQUEST.new( + event = EventTypes.ROTATE.new( task_id=self.session().task().task_id, messages=inputs.messages, ) diff --git a/ghostos/framework/multitasks/basic.py b/ghostos/framework/multitasks/basic.py index 4ca26ebf..6b3cbf05 100644 --- a/ghostos/framework/multitasks/basic.py +++ b/ghostos/framework/multitasks/basic.py @@ -67,7 +67,7 @@ def send_task(self, task_name: str, *messages: MessageKind) -> None: tasks = session.get_task_briefs(children=True) for task in tasks: if task.name == task_name: - event = EventTypes.REQUEST.new( + event = EventTypes.ROTATE.new( task_id=task.id, from_task_id=from_task_id, messages=messages, diff --git a/ghostos/framework/operators/event_ops.py b/ghostos/framework/operators/event_ops.py index 3029543a..21975034 100644 --- a/ghostos/framework/operators/event_ops.py +++ b/ghostos/framework/operators/event_ops.py @@ -180,7 +180,7 @@ class OnInputOperator(OnUpstreamEventOperator): """ 接受到上游的输入. """ - event_type: ClassVar[str] = EventTypes.REQUEST.value + event_type: ClassVar[str] = EventTypes.ROTATE.value default_state: ClassVar[str] = TaskState.WAITING.value diff --git a/pyproject.toml b/pyproject.toml index 7936640e..3e703c6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ sympy = "^1.13.1" tree-sitter = "0.21.3" tree-sitter-languages = "^1.10.2" networkx = "^3.3" -grep-ast = "^0.3.3" litellm = "^1.43.18" hide-py = "^0.3.0" prompt-toolkit = "^3.0.47" From e15fd8811a7511794360b125ad6c9e644ec87e49 Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 15 Nov 2024 14:51:41 +0800 Subject: [PATCH 078/148] dev: remove v1 designing of ghost by great courage --- app/configs/logging.yml | 3 + app/runtime/prompts/.gitignore | 2 + ...81-c90a-4b68-9024-516170e4c851.prompt.json | 65 --- ...67-4749-4de0-85ea-f2fac054c1e8.prompt.json | 65 --- ...d9-8fe8-40d8-a2c2-b434a12e8dfb.prompt.json | 65 --- ...cc-0c61-48d0-8b17-6efb01e487cc.prompt.json | 65 --- examples/aifunc_raw_test.py | 11 +- ghostos/contracts/documents.py | 2 +- ghostos/core/abcd/concepts.py | 2 +- ghostos/core/abcd/ghosts.py | 2 +- ghostos/core/abcd/prompters.py | 16 - ghostos/core/ghosts/README.md | 14 - ghostos/core/ghosts/__init__.py | 13 - ghostos/core/ghosts/actions.py | 95 ----- ghostos/core/ghosts/assistants.py | 101 ----- ghostos/core/ghosts/ghost.py | 300 -------------- ghostos/core/ghosts/operators.py | 60 --- ghostos/core/ghosts/schedulers.py | 181 --------- ghostos/core/ghosts/shells.py | 60 --- ghostos/core/ghosts/thoughts.py | 308 -------------- ghostos/core/ghosts/utils.py | 241 ----------- ghostos/core/llms/tools.py | 2 +- ghostos/core/runtime/tasks.py | 2 +- ghostos/demo/tests/aifunc_tests.yml | 8 - .../coding/python_editor_baseline.yaml | 268 ------------ ghostos/demo/tests/llm_tests/hello_world.yaml | 248 ------------ ghostos/demo/tests/llm_tests/play_music.yaml | 304 -------------- .../tests/llm_tests/python_bad_case_1.yaml | 87 ---- .../tests/llm_tests/python_case_1_en.yaml | 74 ---- .../tests/llm_tests/python_case_1_zh.yaml | 93 ----- ghostos/entity.py | 4 +- ghostos/framework/actions/__init__.py | 1 - ghostos/framework/actions/moss_action.py | 184 --------- ghostos/framework/documents/storage_impl.py | 2 +- ghostos/framework/entities/__init__.py | 1 - ghostos/framework/entities/basic.py | 14 - ghostos/framework/ghosts/__init__.py | 2 - ghostos/framework/ghosts/basic.py | 382 ------------------ ghostos/framework/ghosts/demo.py | 103 ----- ghostos/framework/libraries/__init__.py | 0 ghostos/framework/libraries/auto_memory.py | 141 ------- ghostos/framework/logger/named.py | 2 +- ghostos/framework/mindsets/__init__.py | 1 - ghostos/framework/mindsets/storage_impl.py | 108 ----- ghostos/framework/multitasks/__init__.py | 1 - ghostos/framework/multitasks/basic.py | 89 ---- ghostos/framework/operators/__init__.py | 28 -- ghostos/framework/operators/action_ops.py | 268 ------------ ghostos/framework/operators/event_ops.py | 253 ------------ ghostos/framework/repliers/__init__.py | 1 - ghostos/framework/repliers/basic.py | 71 ---- ghostos/framework/runtimes/__init__.py | 0 ghostos/framework/shells/__init__.py | 2 - ghostos/framework/shells/basic.py | 42 -- ghostos/framework/shells/empty.py | 12 - ghostos/identifier.py | 4 +- ghostos/prototypes/ghostfunc/prepare.py | 4 +- ghostos/scripts/aifunc_test.py | 154 ------- ghostos/scripts/demo.py | 44 -- ghostos/scripts/logconf.py | 13 - ghostos/thoughts/basic.py | 2 +- tests/python/test_pydantic.py | 2 +- 62 files changed, 25 insertions(+), 4667 deletions(-) create mode 100644 app/runtime/prompts/.gitignore delete mode 100644 app/runtime/prompts/75ee0581-c90a-4b68-9024-516170e4c851.prompt.json delete mode 100644 app/runtime/prompts/a4974467-4749-4de0-85ea-f2fac054c1e8.prompt.json delete mode 100644 app/runtime/prompts/a7fa30d9-8fe8-40d8-a2c2-b434a12e8dfb.prompt.json delete mode 100644 app/runtime/prompts/f16369cc-0c61-48d0-8b17-6efb01e487cc.prompt.json delete mode 100644 ghostos/core/abcd/prompters.py delete mode 100644 ghostos/core/ghosts/README.md delete mode 100644 ghostos/core/ghosts/__init__.py delete mode 100644 ghostos/core/ghosts/actions.py delete mode 100644 ghostos/core/ghosts/assistants.py delete mode 100644 ghostos/core/ghosts/ghost.py delete mode 100644 ghostos/core/ghosts/operators.py delete mode 100644 ghostos/core/ghosts/schedulers.py delete mode 100644 ghostos/core/ghosts/shells.py delete mode 100644 ghostos/core/ghosts/thoughts.py delete mode 100644 ghostos/core/ghosts/utils.py delete mode 100644 ghostos/demo/tests/aifunc_tests.yml delete mode 100644 ghostos/demo/tests/llm_tests/coding/python_editor_baseline.yaml delete mode 100644 ghostos/demo/tests/llm_tests/hello_world.yaml delete mode 100644 ghostos/demo/tests/llm_tests/play_music.yaml delete mode 100644 ghostos/demo/tests/llm_tests/python_bad_case_1.yaml delete mode 100644 ghostos/demo/tests/llm_tests/python_case_1_en.yaml delete mode 100644 ghostos/demo/tests/llm_tests/python_case_1_zh.yaml delete mode 100644 ghostos/framework/actions/__init__.py delete mode 100644 ghostos/framework/actions/moss_action.py delete mode 100644 ghostos/framework/entities/__init__.py delete mode 100644 ghostos/framework/entities/basic.py delete mode 100644 ghostos/framework/ghosts/__init__.py delete mode 100644 ghostos/framework/ghosts/basic.py delete mode 100644 ghostos/framework/ghosts/demo.py delete mode 100644 ghostos/framework/libraries/__init__.py delete mode 100644 ghostos/framework/libraries/auto_memory.py delete mode 100644 ghostos/framework/mindsets/__init__.py delete mode 100644 ghostos/framework/mindsets/storage_impl.py delete mode 100644 ghostos/framework/multitasks/__init__.py delete mode 100644 ghostos/framework/multitasks/basic.py delete mode 100644 ghostos/framework/operators/__init__.py delete mode 100644 ghostos/framework/operators/action_ops.py delete mode 100644 ghostos/framework/operators/event_ops.py delete mode 100644 ghostos/framework/repliers/__init__.py delete mode 100644 ghostos/framework/repliers/basic.py delete mode 100644 ghostos/framework/runtimes/__init__.py delete mode 100644 ghostos/framework/shells/__init__.py delete mode 100644 ghostos/framework/shells/basic.py delete mode 100644 ghostos/framework/shells/empty.py delete mode 100644 ghostos/scripts/aifunc_test.py delete mode 100644 ghostos/scripts/demo.py delete mode 100644 ghostos/scripts/logconf.py diff --git a/app/configs/logging.yml b/app/configs/logging.yml index bcb03d8e..64c94a3a 100644 --- a/app/configs/logging.yml +++ b/app/configs/logging.yml @@ -26,3 +26,6 @@ loggers: console: handlers: [ console ] level: DEBUG + ghostos: + handlers: [ console ] + level: INFO diff --git a/app/runtime/prompts/.gitignore b/app/runtime/prompts/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/app/runtime/prompts/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/app/runtime/prompts/75ee0581-c90a-4b68-9024-516170e4c851.prompt.json b/app/runtime/prompts/75ee0581-c90a-4b68-9024-516170e4c851.prompt.json deleted file mode 100644 index 135d9336..00000000 --- a/app/runtime/prompts/75ee0581-c90a-4b68-9024-516170e4c851.prompt.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "id": "75ee0581-c90a-4b68-9024-516170e4c851", - "description": "description of this prompt", - "system": [ - { - "msg_id": "663402f8-13d3-41ed-af2b-ea147c710d8c", - "ref_id": null, - "index": null, - "type": "", - "role": "system", - "name": null, - "content": "\n# Who Are You \n\nYou are an AIFunc named `AgentFn`.\nAIFunc is a LLM-driven function that could complete request by multi-turns thinking,\nAnd your purpose is to generate AIFuncResult `AgentFnResult` as final result.\n\n## MOSS\n\nYou are equipped with a MOSS (model-oriented operating system simulation) in which you can generate Python code and \npython libraries to reach your goal.\n\nThe `PYTHON CONTEXT` that MOSS already provide to you are below: \n\n```python\n\nfrom __future__ import annotations\n\nfrom typing import Optional\nfrom ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx\nfrom ghostos.core.moss import Moss as Parent\nfrom ghostos.demo.aifuncs.weather import WeatherAIFunc, WeatherAIFuncResult\nfrom ghostos.demo.aifuncs.news import NewsAIFunc, NewsAIFuncResult\nfrom pydantic import Field\n\n\nclass AgentFn(AIFunc):\n \"\"\"\n AIFunc that act like an agent\n \"\"\"\n request: str = Field(description=\"raw request for the agent\")\n\n\nclass AgentFnResult(AIFuncResult):\n \"\"\"\n the result that follow the agent request\n \"\"\"\n result: str = Field(description=\"response from the agent\")\n err: Optional[str] = Field(default=None, description=\"error message\")\n\n\nclass Moss(Parent):\n ai_func_ctx: AIFuncCtx\n \"\"\"useful to run AIFunc\"\"\"\n\n\n\n\n\n# more details about some module attrs above, are list below (quoted by ):\n\"\"\"\n# \nclass AIFunc(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \nclass AIFuncResult(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \n@cls_source_code()\nclass AIFuncCtx(ABC):\n \\\"\"\"\n System context that could execute an AIFunc and keep result in it during multi-turns thinking.\n \\\"\"\"\n\n @abstractmethod\n def run(self, key: str, fn: AIFunc) -> AIFuncResult:\n \\\"\"\"\n Run an AIFunc subclass instance, got result and save it into the key.\n :param key: the key that ctx keep the result in multi-turns thinking.\n :param fn: instance of AIFunc that define the task.\n :return: the certain result that match AIFuncResult and is not None\n :exception: TooManyFailureError\n \\\"\"\"\n pass\n\n @abstractmethod\n def parallel_run(self, fn_dict: Dict[str, AIFunc]) -> Dict[str, AIFuncResult]:\n \\\"\"\"\n Run multiple AIFunc instances in parallel and save their results.\n \n :param fn_dict: A dictionary where keys are result identifiers and values are AIFunc instances.\n :return: A dictionary where keys are the same as in fn_dict and values are the corresponding AIFuncResults.\n \n This method allows for concurrent execution of multiple AIFunc instances, which can improve\n performance when dealing with independent tasks. The results are stored and can be accessed\n using the keys provided in the input dictionary.\n \\\"\"\"\n pass\n\n @abstractmethod\n def get(self, key: str) -> Optional[Any]:\n \\\"\"\"\n get a cached value by key.\n \\\"\"\"\n pass\n\n @abstractmethod\n def set(self, key: str, value: Any) -> None:\n \\\"\"\"\n set a value to ctx, keep it in multi-turns thinking.\n \\\"\"\"\n pass\n\n @abstractmethod\n def values(self) -> Dict[str, Any]:\n \\\"\"\"\n return all values of the AiFuncCtx\n \\\"\"\"\n pass\n# \n\n# \nclass Moss(ABC):\n \\\"\"\"\n Language Model-oriented Operating System Simulation.\n Full python code interface for large language models in multi-turns chat or thinking.\n The property with SerializeType will persist during multi-turns.\n SerializeType means: int, float, str, None, list, dict, BaseModel, TypedDict\n You can edit them if you need.\n \\\"\"\"\n\n T = TypeVar('T')\n\n @abstractmethod\n def fetch(self, abstract: Type[T]) -> Optional[T]:\n \\\"\"\"\n fetch an implementation from IoC Container\n if the abstract type is not bound with any implementation, return None.\n \\\"\"\"\n pass\n\n @abstractmethod\n def pprint(self, *args, **kwargs) -> None:\n \\\"\"\"\n pretty printer\n \\\"\"\"\n pass\n# \n\n# \nclass WeatherAIFunc(AIFunc):\n \\\"\"\"\n tell about weather\n \\\"\"\"\n city: str = Field(default=\"\", description=\"the city name that you want weather forecast. empty means local\")\n date: str = Field(default=\"today\", description=\"the date of weather forecast\")\n\n\n# result type of WeatherAIFunc (which maybe not imported yet) is :\n# class WeatherAIFuncResult(AIFuncResult):\n# \\\"\"\"\n# weather result\n# \\\"\"\"\n# result: str = Field(description=\"the full result describing weather details in nature language form.\")\n# date: str = Field(default=\"today\", description=\"the date of weather forecast\")\n# city: str = Field(default=\"\", description=\"the city name that you want weather forecast. empty means local\")\n# temperature: Optional[float] = Field(default=None, description=\"the temperature of the weather\")\n# humidity: Optional[float] = Field(default=None, description=\"the humidity of the weather\")\n# pressure: Optional[float] = Field(default=None, description=\"the pressure of the weather\")\n# wind_speed: Optional[float] = Field(default=None, description=\"the wind speed of the weather\")\n# wind_dir: Optional[float] = Field(default=None, description=\"the wind direction of the weather\")\n#\n# \n\n# \nclass WeatherAIFuncResult(AIFuncResult):\n \\\"\"\"\n weather result\n \\\"\"\"\n result: str = Field(description=\"the full result describing weather details in nature language form.\")\n date: str = Field(default=\"today\", description=\"the date of weather forecast\")\n city: str = Field(default=\"\", description=\"the city name that you want weather forecast. empty means local\")\n temperature: Optional[float] = Field(default=None, description=\"the temperature of the weather\")\n humidity: Optional[float] = Field(default=None, description=\"the humidity of the weather\")\n pressure: Optional[float] = Field(default=None, description=\"the pressure of the weather\")\n wind_speed: Optional[float] = Field(default=None, description=\"the wind speed of the weather\")\n wind_dir: Optional[float] = Field(default=None, description=\"the wind direction of the weather\")\n# \n\n# \nclass NewsAIFunc(AIFunc):\n \\\"\"\"\n search news\n \\\"\"\"\n query: str = Field(description=\"required news query.\")\n limit: int = Field(default=5, description=\"how many news you want.\")\n\n\n# result type of NewsAIFunc (which maybe not imported yet) is :\n# class NewsAIFuncResult(AIFuncResult):\n# \\\"\"\"\n# news result\n# \\\"\"\"\n# \n# class News(BaseModel):\n# summary: str = Field(description=\"summary of the news.\")\n# title: str = Field(description=\"title of the news.\")\n# date: str = Field(description=\"date of the news.\")\n# media: str = Field(description=\"media of the news.\")\n# \n# results: List[News] = Field(default_factory=list)\n#\n# \n\n# \nclass NewsAIFuncResult(AIFuncResult):\n \\\"\"\"\n news result\n \\\"\"\"\n\n class News(BaseModel):\n summary: str = Field(description=\"summary of the news.\")\n title: str = Field(description=\"title of the news.\")\n date: str = Field(description=\"date of the news.\")\n media: str = Field(description=\"media of the news.\")\n\n results: List[News] = Field(default_factory=list)\n# \n\"\"\"\n\n\n```\n\nYou shall generate a single block of Python code, \nand in the code you shall defines a main function like: \n`def main(moss: Moss, fn: AgentFn):`\n\nabout the params and returns:\n:param moss: A Moss instance\n:param fn: An instance of AgentFn\n:return: tuple[result:AgentFnResult | None , ok: bool]\nIf ok is False, means you need to observe the output that you print in the function, and think another round.\nIf ok is True, means you finish the request and return a final result.\n\nThe MOSS will automatically execute the main function you generated with the instance of class Moss, \ntake next step based on the returns. \n\nSo you shall not repeat the exists code that already provide in the `PYTHON CONTEXT`.\n", - "memory": null, - "attrs": null, - "payloads": {}, - "callers": [], - "seq": "complete", - "created": 1731600249.1537 - }, - { - "msg_id": "8a2933ee-113c-410c-9ef7-4ef8c70eb0b5", - "ref_id": null, - "index": null, - "type": "", - "role": "system", - "name": null, - "content": "\n## Notices\n- The code you generated shall start with and end with , the outside system will automatically execute the code between the mark.\n- You can import basic python module if you needed. \n- The variables defined in the main function will not keep in memory during multi-turns thinking. Unless some lib (AIFuncCtx) provide the ability.\n- MOSS will automatic execute the main function so YOU SHOULD NEVER EXECUTE IT YOURSELF.\n- If you are not equipped enough to resolve your quest, you shall admit the it in the result or raise an exception.\n- **You are not Agent, DO NOT TALK ABOUT YOUR THOUGHT, JUST WRITE THE CODES ONLY**\n", - "memory": null, - "attrs": null, - "payloads": {}, - "callers": [], - "seq": "complete", - "created": 1731600249.1537 - } - ], - "history": [ - { - "msg_id": "74b82dbe-d036-4da0-a313-dcf7c30eb6cb", - "ref_id": null, - "index": null, - "type": "", - "role": "system", - "name": null, - "content": "help me to find news about OpenAI O1 model", - "memory": null, - "attrs": null, - "payloads": {}, - "callers": [], - "seq": "complete", - "created": 1731600249.1048 - } - ], - "inputs": [], - "appending": [], - "functions": [], - "function_call": null, - "functional_tokens": [], - "output": [ - { - "created": 1731600256.0 - } - ], - "error": null, - "created": 1731600249.1538 -} \ No newline at end of file diff --git a/app/runtime/prompts/a4974467-4749-4de0-85ea-f2fac054c1e8.prompt.json b/app/runtime/prompts/a4974467-4749-4de0-85ea-f2fac054c1e8.prompt.json deleted file mode 100644 index 7d093325..00000000 --- a/app/runtime/prompts/a4974467-4749-4de0-85ea-f2fac054c1e8.prompt.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "id": "a4974467-4749-4de0-85ea-f2fac054c1e8", - "description": "description of this prompt", - "system": [ - { - "msg_id": "8cf718ec-a4b7-4098-9a8b-9e9cd5b66651", - "ref_id": null, - "index": null, - "type": "", - "role": "system", - "name": null, - "content": "\n# Who Are You \n\nYou are an AIFunc named `NewsAIFunc`.\nAIFunc is a LLM-driven function that could complete request by multi-turns thinking,\nAnd your purpose is to generate AIFuncResult `NewsAIFuncResult` as final result.\n\n## MOSS\n\nYou are equipped with a MOSS (model-oriented operating system simulation) in which you can generate Python code and \npython libraries to reach your goal.\n\nThe `PYTHON CONTEXT` that MOSS already provide to you are below: \n\n```python\n\nfrom __future__ import annotations\n\nfrom typing import Optional, List\nfrom ghostos.core.aifunc import AIFunc, AIFuncResult\nfrom pydantic import BaseModel, Field\nfrom ghostos.core.moss import Moss\n\n\nclass NewsAIFunc(AIFunc):\n \"\"\"\n search news\n \"\"\"\n query: str = Field(description=\"required news query.\")\n limit: int = Field(default=5, description=\"how many news you want.\")\n\n\nclass NewsAIFuncResult(AIFuncResult):\n \"\"\"\n news result\n \"\"\"\n\n class News(BaseModel):\n summary: str = Field(description=\"summary of the news.\")\n title: str = Field(description=\"title of the news.\")\n date: str = Field(description=\"date of the news.\")\n media: str = Field(description=\"media of the news.\")\n\n results: List[News] = Field(default_factory=list)\n\n\n\n\n\n# more details about some module attrs above, are list below (quoted by ):\n\"\"\"\n# \nclass AIFunc(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \nclass AIFuncResult(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \nclass Moss(ABC):\n \\\"\"\"\n Language Model-oriented Operating System Simulation.\n Full python code interface for large language models in multi-turns chat or thinking.\n The property with SerializeType will persist during multi-turns.\n SerializeType means: int, float, str, None, list, dict, BaseModel, TypedDict\n You can edit them if you need.\n \\\"\"\"\n\n T = TypeVar('T')\n\n @abstractmethod\n def fetch(self, abstract: Type[T]) -> Optional[T]:\n \\\"\"\"\n fetch an implementation from IoC Container\n if the abstract type is not bound with any implementation, return None.\n \\\"\"\"\n pass\n\n @abstractmethod\n def pprint(self, *args, **kwargs) -> None:\n \\\"\"\"\n pretty printer\n \\\"\"\"\n pass\n# \n\"\"\"\n\n\n```\n\nYou shall generate a single block of Python code, \nand in the code you shall defines a main function like: \n`def main(moss: Moss, fn: NewsAIFunc):`\n\nabout the params and returns:\n:param moss: A Moss instance\n:param fn: An instance of NewsAIFunc\n:return: tuple[result:NewsAIFuncResult | None , ok: bool]\nIf ok is False, means you need to observe the output that you print in the function, and think another round.\nIf ok is True, means you finish the request and return a final result.\n\nThe MOSS will automatically execute the main function you generated with the instance of class Moss, \ntake next step based on the returns. \n\nSo you shall not repeat the exists code that already provide in the `PYTHON CONTEXT`.\n", - "memory": null, - "attrs": null, - "payloads": {}, - "callers": [], - "seq": "complete", - "created": 1731600263.1436 - }, - { - "msg_id": "924b8103-50bc-49f7-93bc-e0390c00edd6", - "ref_id": null, - "index": null, - "type": "", - "role": "system", - "name": null, - "content": "\n## Notices\n- The code you generated shall start with and end with , the outside system will automatically execute the code between the mark.\n- You can import basic python module if you needed. \n- The variables defined in the main function will not keep in memory during multi-turns thinking. Unless some lib (AIFuncCtx) provide the ability.\n- MOSS will automatic execute the main function so YOU SHOULD NEVER EXECUTE IT YOURSELF.\n- If you are not equipped enough to resolve your quest, you shall admit the it in the result or raise an exception.\n- **You are not Agent, DO NOT TALK ABOUT YOUR THOUGHT, JUST WRITE THE CODES ONLY**\n", - "memory": null, - "attrs": null, - "payloads": {}, - "callers": [], - "seq": "complete", - "created": 1731600263.1436 - } - ], - "history": [ - { - "msg_id": "7813b0d9-87aa-432d-912a-6847ef42166b", - "ref_id": null, - "index": null, - "type": "", - "role": "system", - "name": null, - "content": "Your task is **MOCKING** a result from the function arguments, make it seems real.the limit of fn is 10", - "memory": null, - "attrs": null, - "payloads": {}, - "callers": [], - "seq": "complete", - "created": 1731600263.1285 - } - ], - "inputs": [], - "appending": [], - "functions": [], - "function_call": null, - "functional_tokens": [], - "output": [ - { - "created": 1731600264.0 - } - ], - "error": null, - "created": 1731600263.1437 -} \ No newline at end of file diff --git a/app/runtime/prompts/a7fa30d9-8fe8-40d8-a2c2-b434a12e8dfb.prompt.json b/app/runtime/prompts/a7fa30d9-8fe8-40d8-a2c2-b434a12e8dfb.prompt.json deleted file mode 100644 index 1f884efa..00000000 --- a/app/runtime/prompts/a7fa30d9-8fe8-40d8-a2c2-b434a12e8dfb.prompt.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "id": "a7fa30d9-8fe8-40d8-a2c2-b434a12e8dfb", - "description": "description of this prompt", - "system": [ - { - "msg_id": "193bb8cf-5bca-4f43-babd-139783915a8f", - "ref_id": null, - "index": null, - "type": "", - "role": "system", - "name": null, - "content": "\n# Who Are You \n\nYou are an AIFunc named `NewsAIFunc`.\nAIFunc is a LLM-driven function that could complete request by multi-turns thinking,\nAnd your purpose is to generate AIFuncResult `NewsAIFuncResult` as final result.\n\n## MOSS\n\nYou are equipped with a MOSS (model-oriented operating system simulation) in which you can generate Python code and \npython libraries to reach your goal.\n\nThe `PYTHON CONTEXT` that MOSS already provide to you are below: \n\n```python\n\nfrom __future__ import annotations\n\nfrom typing import Optional, List\nfrom ghostos.core.aifunc import AIFunc, AIFuncResult\nfrom pydantic import BaseModel, Field\nfrom ghostos.core.moss import Moss\n\n\nclass NewsAIFunc(AIFunc):\n \"\"\"\n search news\n \"\"\"\n query: str = Field(description=\"required news query.\")\n limit: int = Field(default=5, description=\"how many news you want.\")\n\n\nclass NewsAIFuncResult(AIFuncResult):\n \"\"\"\n news result\n \"\"\"\n\n class News(BaseModel):\n summary: str = Field(description=\"summary of the news.\")\n title: str = Field(description=\"title of the news.\")\n date: str = Field(description=\"date of the news.\")\n media: str = Field(description=\"media of the news.\")\n\n results: List[News] = Field(default_factory=list)\n\n\n\n\n\n# more details about some module attrs above, are list below (quoted by ):\n\"\"\"\n# \nclass AIFunc(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \nclass AIFuncResult(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \nclass Moss(ABC):\n \\\"\"\"\n Language Model-oriented Operating System Simulation.\n Full python code interface for large language models in multi-turns chat or thinking.\n The property with SerializeType will persist during multi-turns.\n SerializeType means: int, float, str, None, list, dict, BaseModel, TypedDict\n You can edit them if you need.\n \\\"\"\"\n\n T = TypeVar('T')\n\n @abstractmethod\n def fetch(self, abstract: Type[T]) -> Optional[T]:\n \\\"\"\"\n fetch an implementation from IoC Container\n if the abstract type is not bound with any implementation, return None.\n \\\"\"\"\n pass\n\n @abstractmethod\n def pprint(self, *args, **kwargs) -> None:\n \\\"\"\"\n pretty printer\n \\\"\"\"\n pass\n# \n\"\"\"\n\n\n```\n\nYou shall generate a single block of Python code, \nand in the code you shall defines a main function like: \n`def main(moss: Moss, fn: NewsAIFunc):`\n\nabout the params and returns:\n:param moss: A Moss instance\n:param fn: An instance of NewsAIFunc\n:return: tuple[result:NewsAIFuncResult | None , ok: bool]\nIf ok is False, means you need to observe the output that you print in the function, and think another round.\nIf ok is True, means you finish the request and return a final result.\n\nThe MOSS will automatically execute the main function you generated with the instance of class Moss, \ntake next step based on the returns. \n\nSo you shall not repeat the exists code that already provide in the `PYTHON CONTEXT`.\n", - "memory": null, - "attrs": null, - "payloads": {}, - "callers": [], - "seq": "complete", - "created": 1731600325.361 - }, - { - "msg_id": "6a5d79b1-1e7e-43aa-9477-bf8502652986", - "ref_id": null, - "index": null, - "type": "", - "role": "system", - "name": null, - "content": "\n## Notices\n- The code you generated shall start with and end with , the outside system will automatically execute the code between the mark.\n- You can import basic python module if you needed. \n- The variables defined in the main function will not keep in memory during multi-turns thinking. Unless some lib (AIFuncCtx) provide the ability.\n- MOSS will automatic execute the main function so YOU SHOULD NEVER EXECUTE IT YOURSELF.\n- If you are not equipped enough to resolve your quest, you shall admit the it in the result or raise an exception.\n- **You are not Agent, DO NOT TALK ABOUT YOUR THOUGHT, JUST WRITE THE CODES ONLY**\n", - "memory": null, - "attrs": null, - "payloads": {}, - "callers": [], - "seq": "complete", - "created": 1731600325.3611 - } - ], - "history": [ - { - "msg_id": "d02d9b15-e69e-4c96-a070-61b3b40dcc09", - "ref_id": null, - "index": null, - "type": "", - "role": "system", - "name": null, - "content": "Your task is **MOCKING** a result from the function arguments, make it seems real.the limit of fn is 5", - "memory": null, - "attrs": null, - "payloads": {}, - "callers": [], - "seq": "complete", - "created": 1731600325.3451 - } - ], - "inputs": [], - "appending": [], - "functions": [], - "function_call": null, - "functional_tokens": [], - "output": [ - { - "created": 1731600325.0 - } - ], - "error": null, - "created": 1731600325.3611 -} \ No newline at end of file diff --git a/app/runtime/prompts/f16369cc-0c61-48d0-8b17-6efb01e487cc.prompt.json b/app/runtime/prompts/f16369cc-0c61-48d0-8b17-6efb01e487cc.prompt.json deleted file mode 100644 index df43194c..00000000 --- a/app/runtime/prompts/f16369cc-0c61-48d0-8b17-6efb01e487cc.prompt.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "id": "f16369cc-0c61-48d0-8b17-6efb01e487cc", - "description": "description of this prompt", - "system": [ - { - "msg_id": "824e30f3-f03a-480b-835c-a15b338e6893", - "ref_id": null, - "index": null, - "type": "", - "role": "system", - "name": null, - "content": "\n# Who Are You \n\nYou are an AIFunc named `AgentFn`.\nAIFunc is a LLM-driven function that could complete request by multi-turns thinking,\nAnd your purpose is to generate AIFuncResult `AgentFnResult` as final result.\n\n## MOSS\n\nYou are equipped with a MOSS (model-oriented operating system simulation) in which you can generate Python code and \npython libraries to reach your goal.\n\nThe `PYTHON CONTEXT` that MOSS already provide to you are below: \n\n```python\n\nfrom __future__ import annotations\n\nfrom typing import Optional\nfrom ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx\nfrom ghostos.core.moss import Moss as Parent\nfrom ghostos.demo.aifuncs.weather import WeatherAIFunc, WeatherAIFuncResult\nfrom ghostos.demo.aifuncs.news import NewsAIFunc, NewsAIFuncResult\nfrom pydantic import Field\n\n\nclass AgentFn(AIFunc):\n \"\"\"\n AIFunc that act like an agent\n \"\"\"\n request: str = Field(description=\"raw request for the agent\")\n\n\nclass AgentFnResult(AIFuncResult):\n \"\"\"\n the result that follow the agent request\n \"\"\"\n result: str = Field(description=\"response from the agent\")\n err: Optional[str] = Field(default=None, description=\"error message\")\n\n\nclass Moss(Parent):\n ai_func_ctx: AIFuncCtx\n \"\"\"useful to run AIFunc\"\"\"\n\n\n\n\n\n# more details about some module attrs above, are list below (quoted by ):\n\"\"\"\n# \nclass AIFunc(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \nclass AIFuncResult(PromptAbleClass, BaseModel, ABC):\n \\\"\"\"\n Model interface for an AIFunc arguments, always followed by an AIFuncResult Model.\n The subclass of AIFunc can run in AIFuncCtx, if you are provided with them, you can use them if you need.\n \\\"\"\"\n pass\n# \n\n# \n@cls_source_code()\nclass AIFuncCtx(ABC):\n \\\"\"\"\n System context that could execute an AIFunc and keep result in it during multi-turns thinking.\n \\\"\"\"\n\n @abstractmethod\n def run(self, key: str, fn: AIFunc) -> AIFuncResult:\n \\\"\"\"\n Run an AIFunc subclass instance, got result and save it into the key.\n :param key: the key that ctx keep the result in multi-turns thinking.\n :param fn: instance of AIFunc that define the task.\n :return: the certain result that match AIFuncResult and is not None\n :exception: TooManyFailureError\n \\\"\"\"\n pass\n\n @abstractmethod\n def parallel_run(self, fn_dict: Dict[str, AIFunc]) -> Dict[str, AIFuncResult]:\n \\\"\"\"\n Run multiple AIFunc instances in parallel and save their results.\n \n :param fn_dict: A dictionary where keys are result identifiers and values are AIFunc instances.\n :return: A dictionary where keys are the same as in fn_dict and values are the corresponding AIFuncResults.\n \n This method allows for concurrent execution of multiple AIFunc instances, which can improve\n performance when dealing with independent tasks. The results are stored and can be accessed\n using the keys provided in the input dictionary.\n \\\"\"\"\n pass\n\n @abstractmethod\n def get(self, key: str) -> Optional[Any]:\n \\\"\"\"\n get a cached value by key.\n \\\"\"\"\n pass\n\n @abstractmethod\n def set(self, key: str, value: Any) -> None:\n \\\"\"\"\n set a value to ctx, keep it in multi-turns thinking.\n \\\"\"\"\n pass\n\n @abstractmethod\n def values(self) -> Dict[str, Any]:\n \\\"\"\"\n return all values of the AiFuncCtx\n \\\"\"\"\n pass\n# \n\n# \nclass Moss(ABC):\n \\\"\"\"\n Language Model-oriented Operating System Simulation.\n Full python code interface for large language models in multi-turns chat or thinking.\n The property with SerializeType will persist during multi-turns.\n SerializeType means: int, float, str, None, list, dict, BaseModel, TypedDict\n You can edit them if you need.\n \\\"\"\"\n\n T = TypeVar('T')\n\n @abstractmethod\n def fetch(self, abstract: Type[T]) -> Optional[T]:\n \\\"\"\"\n fetch an implementation from IoC Container\n if the abstract type is not bound with any implementation, return None.\n \\\"\"\"\n pass\n\n @abstractmethod\n def pprint(self, *args, **kwargs) -> None:\n \\\"\"\"\n pretty printer\n \\\"\"\"\n pass\n# \n\n# \nclass WeatherAIFunc(AIFunc):\n \\\"\"\"\n tell about weather\n \\\"\"\"\n city: str = Field(default=\"\", description=\"the city name that you want weather forecast. empty means local\")\n date: str = Field(default=\"today\", description=\"the date of weather forecast\")\n\n\n# result type of WeatherAIFunc (which maybe not imported yet) is :\n# class WeatherAIFuncResult(AIFuncResult):\n# \\\"\"\"\n# weather result\n# \\\"\"\"\n# result: str = Field(description=\"the full result describing weather details in nature language form.\")\n# date: str = Field(default=\"today\", description=\"the date of weather forecast\")\n# city: str = Field(default=\"\", description=\"the city name that you want weather forecast. empty means local\")\n# temperature: Optional[float] = Field(default=None, description=\"the temperature of the weather\")\n# humidity: Optional[float] = Field(default=None, description=\"the humidity of the weather\")\n# pressure: Optional[float] = Field(default=None, description=\"the pressure of the weather\")\n# wind_speed: Optional[float] = Field(default=None, description=\"the wind speed of the weather\")\n# wind_dir: Optional[float] = Field(default=None, description=\"the wind direction of the weather\")\n#\n# \n\n# \nclass WeatherAIFuncResult(AIFuncResult):\n \\\"\"\"\n weather result\n \\\"\"\"\n result: str = Field(description=\"the full result describing weather details in nature language form.\")\n date: str = Field(default=\"today\", description=\"the date of weather forecast\")\n city: str = Field(default=\"\", description=\"the city name that you want weather forecast. empty means local\")\n temperature: Optional[float] = Field(default=None, description=\"the temperature of the weather\")\n humidity: Optional[float] = Field(default=None, description=\"the humidity of the weather\")\n pressure: Optional[float] = Field(default=None, description=\"the pressure of the weather\")\n wind_speed: Optional[float] = Field(default=None, description=\"the wind speed of the weather\")\n wind_dir: Optional[float] = Field(default=None, description=\"the wind direction of the weather\")\n# \n\n# \nclass NewsAIFunc(AIFunc):\n \\\"\"\"\n search news\n \\\"\"\"\n query: str = Field(description=\"required news query.\")\n limit: int = Field(default=5, description=\"how many news you want.\")\n\n\n# result type of NewsAIFunc (which maybe not imported yet) is :\n# class NewsAIFuncResult(AIFuncResult):\n# \\\"\"\"\n# news result\n# \\\"\"\"\n# \n# class News(BaseModel):\n# summary: str = Field(description=\"summary of the news.\")\n# title: str = Field(description=\"title of the news.\")\n# date: str = Field(description=\"date of the news.\")\n# media: str = Field(description=\"media of the news.\")\n# \n# results: List[News] = Field(default_factory=list)\n#\n# \n\n# \nclass NewsAIFuncResult(AIFuncResult):\n \\\"\"\"\n news result\n \\\"\"\"\n\n class News(BaseModel):\n summary: str = Field(description=\"summary of the news.\")\n title: str = Field(description=\"title of the news.\")\n date: str = Field(description=\"date of the news.\")\n media: str = Field(description=\"media of the news.\")\n\n results: List[News] = Field(default_factory=list)\n# \n\"\"\"\n\n\n```\n\nYou shall generate a single block of Python code, \nand in the code you shall defines a main function like: \n`def main(moss: Moss, fn: AgentFn):`\n\nabout the params and returns:\n:param moss: A Moss instance\n:param fn: An instance of AgentFn\n:return: tuple[result:AgentFnResult | None , ok: bool]\nIf ok is False, means you need to observe the output that you print in the function, and think another round.\nIf ok is True, means you finish the request and return a final result.\n\nThe MOSS will automatically execute the main function you generated with the instance of class Moss, \ntake next step based on the returns. \n\nSo you shall not repeat the exists code that already provide in the `PYTHON CONTEXT`.\n", - "memory": null, - "attrs": null, - "payloads": {}, - "callers": [], - "seq": "complete", - "created": 1731600314.6548 - }, - { - "msg_id": "96634960-2648-4f7e-acca-c91e159178cd", - "ref_id": null, - "index": null, - "type": "", - "role": "system", - "name": null, - "content": "\n## Notices\n- The code you generated shall start with and end with , the outside system will automatically execute the code between the mark.\n- You can import basic python module if you needed. \n- The variables defined in the main function will not keep in memory during multi-turns thinking. Unless some lib (AIFuncCtx) provide the ability.\n- MOSS will automatic execute the main function so YOU SHOULD NEVER EXECUTE IT YOURSELF.\n- If you are not equipped enough to resolve your quest, you shall admit the it in the result or raise an exception.\n- **You are not Agent, DO NOT TALK ABOUT YOUR THOUGHT, JUST WRITE THE CODES ONLY**\n", - "memory": null, - "attrs": null, - "payloads": {}, - "callers": [], - "seq": "complete", - "created": 1731600314.6548 - } - ], - "history": [ - { - "msg_id": "dea70138-61a1-40da-9870-eadaa9b33c4b", - "ref_id": null, - "index": null, - "type": "", - "role": "system", - "name": null, - "content": "help me to find news about OpenAI O1 model", - "memory": null, - "attrs": null, - "payloads": {}, - "callers": [], - "seq": "complete", - "created": 1731600314.606 - } - ], - "inputs": [], - "appending": [], - "functions": [], - "function_call": null, - "functional_tokens": [], - "output": [ - { - "created": 1731600319.0 - } - ], - "error": null, - "created": 1731600314.6548 -} \ No newline at end of file diff --git a/examples/aifunc_raw_test.py b/examples/aifunc_raw_test.py index 3f3738ff..cb7a45e7 100644 --- a/examples/aifunc_raw_test.py +++ b/examples/aifunc_raw_test.py @@ -58,10 +58,10 @@ console.print(Panel( Markdown( f""" - ```json - {frame.model_dump_json(indent=2)} - ``` - """ +```json +{frame.model_dump_json(indent=2)} +``` +""" ), title="frame details", )) @@ -84,6 +84,3 @@ ), title="result error", )) - - - diff --git a/ghostos/contracts/documents.py b/ghostos/contracts/documents.py index ca83cdfc..f3f09ec6 100644 --- a/ghostos/contracts/documents.py +++ b/ghostos/contracts/documents.py @@ -22,7 +22,7 @@ def directory(self) -> str: def description(self) -> str: pass - def identifier(self) -> Identifier: + def __identifier__(self) -> Identifier: return Identifier( id=self.directory(), name=self.domain(), diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index 061858c4..e8e8b482 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -57,7 +57,7 @@ ) -class Ghost(Identical, Entity, ABC): +class Ghost(Identical, ABC): """ the class defines the model of a kind of ghosts. four parts included: diff --git a/ghostos/core/abcd/ghosts.py b/ghostos/core/abcd/ghosts.py index fb7b6197..ff3b4320 100644 --- a/ghostos/core/abcd/ghosts.py +++ b/ghostos/core/abcd/ghosts.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import ClassVar, Type +from typing import ClassVar from ghostos.identifier import Identifier from pydantic import BaseModel from .concepts import Ghost diff --git a/ghostos/core/abcd/prompters.py b/ghostos/core/abcd/prompters.py deleted file mode 100644 index c908a371..00000000 --- a/ghostos/core/abcd/prompters.py +++ /dev/null @@ -1,16 +0,0 @@ -from .concepts import Prompter -from ghostos.container import Container -from pydantic import Field - - -class SystemPrompter(Prompter): - """ - root of the prompt - """ - meta_prompt: str = Field( - default="", - description="meta prompt for agent", - ) - - def self_prompt(self, container: Container) -> str: - return self.meta_prompt diff --git a/ghostos/core/ghosts/README.md b/ghostos/core/ghosts/README.md deleted file mode 100644 index 3f95c8f9..00000000 --- a/ghostos/core/ghosts/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Ghosts - -The Ghosts directory provides the interfaces of Ghost, and Ghost is the blueprint of llm-based agent. -Ghost provide fundamental APIs from libraries that MUST EXISTS for a functional agent. -Such as Session, Container, Workspace and schedulers. - -The Thought class is an atomic stateful thinking machine unit (like an agent), using Task to describe thinking state, -using MsgThread to record thinking / conversation history messages. -And Thought receive a Ghost instance to control everything. - -The Action is the atomic abstract for LLM callback function. -Thought provides multiple actions to interact with LLM outputs. - -The `schedulers.py` file defines the basic schedulers for a Thought to controller task, multi-task, multi-agent ETC. diff --git a/ghostos/core/ghosts/__init__.py b/ghostos/core/ghosts/__init__.py deleted file mode 100644 index 000aeedf..00000000 --- a/ghostos/core/ghosts/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from ghostos.core.ghosts.ghost import Ghost, Inputs, GhostConf -from ghostos.core.ghosts.actions import Action -from ghostos.core.ghosts.operators import Operator, EventOperator, get_event_operator -from ghostos.core.ghosts.schedulers import Taskflow, MultiTask, Replier -from ghostos.core.ghosts.thoughts import ( - Mindset, - Thought, ThoughtDriver, - ModelThought, - BasicThoughtDriver, - get_thought_driver_type, -) -from ghostos.core.ghosts.shells import Shell -from ghostos.core.ghosts.utils import Utils, NewTask diff --git a/ghostos/core/ghosts/actions.py b/ghostos/core/ghosts/actions.py deleted file mode 100644 index 025d6095..00000000 --- a/ghostos/core/ghosts/actions.py +++ /dev/null @@ -1,95 +0,0 @@ -from typing import Optional, ClassVar, Type, TypeVar, Generic -import json -from abc import ABC, abstractmethod -from ghostos.container import Container -from ghostos.core.llms import Prompt, LLMFunc, PromptPipe -from ghostos.core.ghosts.operators import Operator -from ghostos.core.messages.message import Caller -from ghostos.core.runtime import Session -from ghostos.identifier import Identical, Identifier -from pydantic import BaseModel - -__all__ = ['Action', 'ToolAction'] - - -class Action(Identical, PromptPipe, ABC): - """ - ghost actions that triggered by LLM output's caller - """ - - @abstractmethod - def update_prompt(self, chat: Prompt) -> Prompt: - """ - Action update the chat with messages, tool, functional_tokens, etc. - :param chat: origin chat. - :return: updated chat. may be a copy. - """ - pass - - @abstractmethod - def act(self, container: "Container", session: Session, caller: Caller) -> Optional["Operator"]: - """ - took an action with ghost generated caller - :param container: container may be changed comparing to when the action is created. so pass the new one. - :param session: the session - :param caller: the caller generated by the ghost runner (usually driven by llm) - :return: the operator that predefined to control the ghost state - """ - pass - - -A = TypeVar('A', bound=Type[BaseModel]) - - -class ToolAction(Action, Generic[A], ABC): - """ - 定义一个 ToolAction. - 泛型并不是必须的, 主要是提示 ToolAction 如何生成. - """ - name: ClassVar[str] - """工具的名字""" - - description: ClassVar[str] - """工具的描述""" - - args_model: A - """工具的入参. """ - - @abstractmethod - def do_act(self, container: "Container", session: Session, arguments: A) -> Optional["Operator"]: - """ - 工具真实的实现. - """ - pass - - def update_prompt(self, chat: Prompt) -> Prompt: - """ - 将工具注入到 chat. - """ - tool = LLMFunc.new( - name=self.name, - desc=self.description, - parameters=self.args_model.model_json_schema(), - ) - chat.functions.append(tool) - return chat - - def act(self, container: "Container", session: Session, caller: Caller) -> Optional["Operator"]: - """ - 接受 LLM 生产的 caller 运行. - """ - # 反解出 json - loaded = json.loads(caller.arguments) - # 反解出 ToolActionArgs - arguments = self.args_model(**loaded) - # 运行逻辑. - return self.act(container, session, arguments) - - def identifier(self) -> Identifier: - """ - 对自身的描述. - """ - return Identifier( - name=self.name, - description=self.description, - ) diff --git a/ghostos/core/ghosts/assistants.py b/ghostos/core/ghosts/assistants.py deleted file mode 100644 index d48631b8..00000000 --- a/ghostos/core/ghosts/assistants.py +++ /dev/null @@ -1,101 +0,0 @@ -from typing import Optional, TypeVar, Generic, Type -from abc import ABC, abstractmethod -from ghostos.identifier import Identical, Identifier -from ghostos.core.ghosts import Ghost -from ghostos.core.ghosts.thoughts import Thought, ModelThought -from ghostos.helpers import generate_import_path, md5, import_from_path -from pydantic import BaseModel, Field - -__all__ = [ - 'Assistant', - 'AssistantDriver', - 'get_assistant_driver', - 'get_assistant_driver_type', - 'BasicAssistant', - 'BasicAssistantDriver', -] - - -class Assistant(Identical, ABC): - """ - Assistant is a special thinking unit in Ghost. - Each assistant has a unique identifier, is a singleton instance in the Process. - You can talk to a agent through MultiAssistant library. - """ - - __assistant_driver__: Optional[Type["AssistantDriver"]] = None - - -A = TypeVar("A", bound=Assistant) - - -class AssistantDriver(Generic[A], ABC): - - def __init__(self, assistant: A): - self.assistant = assistant - - @abstractmethod - def meta_prompt(self, g: Ghost) -> str: - pass - - @abstractmethod - def root_thought(self, g: Ghost) -> Thought: - pass - - def task_id(self, g: Ghost) -> str: - """ - generate unique task id for assistant instance in the process - """ - process_id = g.session().update_prompt().process_id - name = self.assistant.identifier().name - assistant_type = generate_import_path(type(self.assistant)) - thought_type = generate_import_path(type(self.root_thought(g))) - # hash a singleton id of the assistant task. - return md5(f"{process_id}-{assistant_type}-{thought_type}-{name}") - - -def get_assistant_driver_type(assistant: A) -> Type[AssistantDriver]: - """ - get assistant driver instance - :param assistant: - :return: - """ - if assistant.__assistant_driver__ is not None: - return assistant.__assistant_driver__ - assistant_import_path = generate_import_path(type(assistant)) - driver_path = assistant_import_path + "Driver" - driver = import_from_path(driver_path) - return driver - - -def get_assistant_driver(assistant: A) -> AssistantDriver[A]: - driver_type = get_assistant_driver_type(assistant) - return driver_type(assistant) - - -class BasicAssistant(Assistant, BaseModel): - """ - the basic assistant that use model thought as root thought - """ - - name: str = Field(description="the name of the assistant") - description: str = Field(description="the description of the assistant about it usage") - prompt: str = Field(description="the meta prompt of the assistant") - thought: ModelThought = Field(description="the thought of the assistant") - - def identifier(self) -> Identifier: - import_path = generate_import_path(type(self)) - return Identifier( - id=f"{import_path}-{self.name}", - name=self.name, - description=self.description, - ) - - -class BasicAssistantDriver(AssistantDriver[BasicAssistant]): - - def meta_prompt(self, g: Ghost) -> str: - return self.assistant.prompt - - def root_thought(self, g: Ghost) -> Thought: - return self.assistant.thought diff --git a/ghostos/core/ghosts/ghost.py b/ghostos/core/ghosts/ghost.py deleted file mode 100644 index eb5ce53f..00000000 --- a/ghostos/core/ghosts/ghost.py +++ /dev/null @@ -1,300 +0,0 @@ -from typing import Optional, TYPE_CHECKING, List, Tuple, Dict -from abc import ABC, abstractmethod -from ghostos.entity import ModelEntity, EntityMeta, EntityFactory -from ghostos.container import Container -from ghostos.identifier import Identical, Identifier -from ghostos.contracts.logger import LoggerItf -from ghostos.contracts.modules import Modules -from ghostos.contracts.configs import Configs -from ghostos.core.runtime import Session, Event -from ghostos.core.messages import Message, Role -from ghostos.core.moss import MossCompiler -from ghostos.core.llms import LLMs -from pydantic import BaseModel, Field - -if TYPE_CHECKING: - # 避免 python 文件管理风格导致的循环依赖. - from ghostos.core.ghosts.utils import Utils - from ghostos.core.ghosts.shells import Shell - from ghostos.core.ghosts.thoughts import Mindset, Thought - from ghostos.core.ghosts.schedulers import MultiTask, Taskflow, Replier - from ghostos.core.ghosts.operators import Operator - from ghostos.contracts.workspace import Workspace - from ghostos.core.ghosts.actions import Action - -__all__ = ['Ghost', 'Inputs', 'GhostConf'] - - -class Inputs(BaseModel): - """ - 定义一个标准的请求协议. - """ - - trace_id: str = Field( - default="", - description="inputs 的 trace id, 应该记录到日志中, 贯穿整个流程.", - ) - session_id: str = Field( - description="session id", - ) - - ghost_id: str = Field( - description="ghost id", - ) - - messages: List[Message] = Field( - description="本轮请求真正的输入数据. 不应该为空. " - ) - process_id: Optional[str] = Field( - default=None, - description="指定响应时进程的 id. 如果目标进程存在, 则用它响应. ", - ) - task_id: Optional[str] = Field( - default=None, - description="指定响应的 task id. 如果目标 task 存在, 则用它来响应. ", - ) - - -class GhostConf(ModelEntity, Identical, ABC): - """ - configuration of the ghost - """ - - @abstractmethod - def root_thought_meta(self) -> EntityMeta: - pass - - -class Ghost(ABC): - - @abstractmethod - def conf(self) -> GhostConf: - """ - get conf instance. - """ - pass - - @staticmethod - def role() -> str: - """ - role of this ghost instance. - """ - return Role.ASSISTANT.value - - def identifier(self) -> Identifier: - task = self.session().task() - if task.assistant: - return task.assistant - return self.conf().identifier() - - @abstractmethod - def trace(self) -> Dict[str, str]: - """ - trance of the current ghost instance. - """ - pass - - @abstractmethod - def on_inputs(self, inputs: Inputs) -> Optional["Event"]: - """ - 对请求进行预处理, 如果返回一个事件, 则触发事件响应流程. - 可以在这个环节使用管道的方式预处理输入消息, 比如检查消息体是否过大, 或者是否触发一个命令. - :param inputs: 输入消息. - :return: 是否正式触发一个事件. - """ - pass - - @abstractmethod - def init_operator(self, event: "Event") -> Tuple["Operator", int]: - """ - :param event: the initialize event. should set the event to ghost container. - :return: the operator and max_operator_count - """ - pass - - @abstractmethod - def init_event(self) -> Optional["Event"]: - """ - the origin event from init_operator - """ - pass - - @abstractmethod - def meta_prompt(self) -> str: - """ - meta prompt of the ghost - return: prompt string - """ - pass - - def system_prompt(self) -> str: - """ - system prompt of the ghost. - """ - task = self.session().task() - # task assistant meta prompt is higher priority - if task.assistant: - meta_prompt = task.assistant.meta_prompt - return meta_prompt - meta_prompt = self.meta_prompt() - shell_prompt = self.shell().status_description() - content = "\n\n".join([meta_prompt, shell_prompt]) - return content.strip() - - def actions(self) -> List["Action"]: - """ - ghost default actions - """ - session = self.session() - if session.task().task_id == session.update_prompt().main_task_id: - return list(self.shell().actions()) - return [] - - @abstractmethod - def container(self) -> Container: - """ - ghost 持有的 IoC 容器. - """ - pass - - @abstractmethod - def session(self) -> Session: - """ - 会话的构建, 在 Ghost In Shell 的架构里, Session 要先于 Ghost 构建. - 由于全异步 Session 作为基础设施, 所以 Ghost 每次运行时都是在一个特定的 task 里. - """ - pass - - @abstractmethod - def shell(self) -> "Shell": - """ - 返回 ghost 所属的 shell 抽象. - 持有了对端设备交互能力的抽象. - """ - pass - - @abstractmethod - def mindset(self) -> "Mindset": - """ - thoughts 管理所有的 Thoughts, 仍可以用来召回. - """ - pass - - @abstractmethod - def root_thought(self) -> "Thought": - """ - Ghost 的根节点思维单元. - """ - pass - - @abstractmethod - def modules(self) -> "Modules": - """ - 基于 modules 可以管理所有类库. - 通过预加载, 可以搜索存在的类库. - """ - pass - - @abstractmethod - def llms(self) -> LLMs: - pass - - @abstractmethod - def entity_factory(self) -> EntityFactory: - """ - A factory that can make entity instances. - """ - pass - - @abstractmethod - def logger(self) -> "LoggerItf": - """ - 返回基于当前上下文生成的 logger 实例. - """ - pass - - @abstractmethod - def multitasks(self) -> "MultiTask": - """ - 当前 Task 的多任务管理模块. - 提供原语管理基础的多任务调度. - 作为基础的调度模块, 可供其它类库的封装. - """ - pass - - @abstractmethod - def taskflow(self) -> "Taskflow": - """ - 对当前 Task 自身的状态管理器. - 提供原语管理自身的任务调度. - """ - pass - - @abstractmethod - def replier(self) -> "Replier": - """ - a simple replier to reply the origin event - """ - pass - - @abstractmethod - def moss(self) -> "MossCompiler": - """ - 实例化一个 moss compiler. - """ - pass - - @abstractmethod - def workspace(self) -> "Workspace": - """ - 返回 Ghost 所持有的文件空间. - 这里面的文件都是 Ghost 可以管理的. - """ - pass - - @abstractmethod - def configs(self) -> Configs: - """ - Configs - """ - pass - - @abstractmethod - def utils(self) -> "Utils": - """ - Ghost 的 - """ - pass - - @abstractmethod - def fail(self, err: Optional[Exception]) -> None: - """ - Ghost 运行时用统一的 Finish 方法来结束一个周期. - 用来存储状态变更, 或者处理异常. - :param err: 记录运行时异常. - """ - pass - - @abstractmethod - def save(self) -> None: - """ - Ghost 完成运行. - """ - pass - - @abstractmethod - def done(self) -> None: - """ - 1. save session. - 2. unlock task - 3. send final pack - """ - pass - - @abstractmethod - def destroy(self) -> None: - """ - 主动做垃圾回收的准备. - 避免运行时的内存泄漏. - """ - pass diff --git a/ghostos/core/ghosts/operators.py b/ghostos/core/ghosts/operators.py deleted file mode 100644 index 3148760f..00000000 --- a/ghostos/core/ghosts/operators.py +++ /dev/null @@ -1,60 +0,0 @@ -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Optional, ClassVar, Dict, Type -from ghostos.core.runtime import Event -from ghostos.core.moss.decorators import cls_definition - -if TYPE_CHECKING: - from ghostos.core.ghosts.ghost import Ghost - -__all__ = ['Operator', 'EventOperator', 'get_event_operator'] - - -@cls_definition() -class Operator(ABC): - """ - Operating the chain of thoughts. - You CAN NOT define operator yourself, you shall generate it by given library only. - """ - - @abstractmethod - def run(self, g: "Ghost") -> Optional["Operator"]: - """ - 结合 ghost 运行, 并生成下一个算子. 当下一个算子为 None 的时候, 终止流程. - :param g: ghost instance from the task - """ - pass - - @abstractmethod - def destroy(self) -> None: - """ - 主动垃圾回收. - """ - pass - - -class EventOperator(Operator, ABC): - """ - 处理指定事件的 Operator. 主要是做状态检查相关的状态机逻辑. - """ - event_type: ClassVar[str] = "" - """对齐 Event.type""" - - def __init__(self, event: Event): - self.event = event - - def destroy(self) -> None: - del self.event - - -def get_event_operator(operators: Dict[str, Type[EventOperator]], event: Event) -> Optional[EventOperator]: - """ - 根据事件类型, 从 operators 中挑选合适的 EventOperator - :param operators: - :param event: - :return: - """ - if event.type in operators: - return operators[event.type](event) - if "" in operators: - return operators[""](event) - return None diff --git a/ghostos/core/ghosts/schedulers.py b/ghostos/core/ghosts/schedulers.py deleted file mode 100644 index 2e660d58..00000000 --- a/ghostos/core/ghosts/schedulers.py +++ /dev/null @@ -1,181 +0,0 @@ -from typing import Dict, Any, TypedDict, Required, Optional, Tuple -from abc import ABC, abstractmethod -from ghostos.core.ghosts.operators import Operator -from ghostos.core.ghosts.thoughts import Thought -from ghostos.core.ghosts.assistants import Assistant -from ghostos.core.messages.message import MessageKind -from ghostos.core.llms import PromptPipe -from dataclasses import dataclass - -__all__ = [ - 'MultiTask', 'Taskflow', 'Replier', -] - - -class Taskflow(ABC): - """ - 这个 library 可以直接管理当前任务的状态调度. - 通过method 返回的 Operator 会操作系统变更当前任务的状态. - """ - - @abstractmethod - def awaits(self, reply: str = "", log: str = "") -> Operator: - """ - 当前任务挂起, 等待下一轮输入. - :param reply: 可以发送回复, 或者主动提出问题或要求. 并不是必要的. - :param log: 如果不为空, 会更新当前任务的日志. 只需要记录对任务进行有意义而且非常简介的讯息. - """ - pass - - @abstractmethod - def observe(self, objects: Dict[str, Any], reason: str = "", instruction: str = "") -> Operator: - """ - 系统会打印这些变量的值, 作为一条新的输入消息让你观察, 开启你的下一轮思考. - 是实现 Chain of thought 的基本方法. - :param objects: the observing objects by name to value - :param reason: if given, will record the observing reason to task logs. - :param instruction: give the instruction when observe the result, in case of forgetting. - """ - pass - - @abstractmethod - def think(self, instruction: str = "") -> Operator: - """ - think another round - :param instruction: optional instruction for next round thinking - """ - pass - - @abstractmethod - def finish(self, log: str, response: str) -> Operator: - """ - 结束当前的任务, 返回任务结果. - 如果当前任务是持续的, 还要等待更多用户输入, 请使用 awaits. - :param log: 简单记录当前任务完成的理由. - :param response: 发送一条或多条消息作为任务的结论发送给用户. - """ - pass - - @abstractmethod - def fail(self, reason: str, reply: str) -> Operator: - """ - 标记当前任务失败 - :param reason: 记录当前任务失败的原因. - :param reply: 发送给用户或者父任务的消息. 如果为空的话, 把 log 作为讯息传递. - """ - pass - - -class MultiTask(PromptPipe, ABC): - """ - You are equipped with this MultiTasks Library that can execute thought in an asynchronous task. - A thought is a mind-machine usually driven by LLM, can resolve certain type of task in multi-turns chain of thought. - During the process, the thought may send messages to you, finish/fail the task or await for more information. - You shall use MultiTasks library to help you resolve your task, interactively and asynchronous. - """ - - @abstractmethod - def wait_on_tasks(self, *new_tasks: Tuple[str, str, Thought, str]) -> Operator: - """ - create multiple task by thought, and wait for the tasks to finish. - when the task finished, you will receive the message and think. - :param new_tasks: (task_name, task_desc, thought, instruction) - """ - pass - - @abstractmethod - def run_tasks(self, *new_tasks: Tuple[str, str, Thought, str]) -> None: - """ - create - Cause the tasks are executed asynchronously, - you can do other things until you got messages that them done. - :param new_tasks: (task_name, task_desc, thought, instruction) - """ - pass - - @abstractmethod - def send_task(self, task_name: str, *messages: str) -> None: - """ - send a message to the task by name - :param task_name: task 的名称 - :param messages: the message content - """ - pass - - @abstractmethod - def cancel_task(self, task_name: str, reason: str) -> None: - """ - 取消一个已经存在的 task. - :param task_name: 目标 task 的名称. - :param reason: 取消的理由. - """ - pass - - -# simple and sync version of taskflow -class Replier(ABC): - """ - reply to the input message - """ - - @abstractmethod - def reply(self, content: str) -> Operator: - """ - reply to the input message - :param content: content of the reply - :return: wait for further input - """ - pass - - @abstractmethod - def finish(self, reply: str) -> Operator: - """ - finish current task and reply the final result - :param reply: shall not be empty - :return: end the current task - """ - pass - - @abstractmethod - def ask_clarification(self, question: str) -> Operator: - """ - the input query is not clear enough, ask clarification. - :param question: the question will send back - :return: wait for clarification input - """ - pass - - @abstractmethod - def fail(self, reply: str) -> Operator: - """ - fail to handle request, and reply - :param reply: content of the reply - :return: wait for further input - """ - pass - - @abstractmethod - def think( - self, - observations: Optional[Dict[str, Any]] = None, - instruction: Optional[str] = None, - ) -> Operator: - """ - think another round with printed values or observations - :param observations: print the observations as message - :param instruction: tell self what to do next - :return: think another round - """ - pass - - -class MultiAssistant(ABC): - - @abstractmethod - def ask_assistant(self, assistant: Assistant, query: str) -> None: - """ - ask an assistant to do something or reply some information. - :param assistant: the assistant instance - :param query: query to the assistant. - """ - pass diff --git a/ghostos/core/ghosts/shells.py b/ghostos/core/ghosts/shells.py deleted file mode 100644 index 35e8edb1..00000000 --- a/ghostos/core/ghosts/shells.py +++ /dev/null @@ -1,60 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Iterable -from ghostos.container import INSTANCE, ABSTRACT -from ghostos.core.ghosts.actions import Action - -__all__ = ['Shell'] - - -class Shell(ABC): - """ - Shell is the cybernetic body of the Ghost, and this interface is an abstract for the shell. - The instance of the Shell may be changed during runtime. - The Ghost shall feel and understand the situation of the shell, and use it. - """ - - @abstractmethod - def id(self) -> str: - """ - :return: identity of the shell. - """ - pass - - @abstractmethod - def status_description(self) -> str: - """ - the status description of the shell, for llm ghost. - combine this to the LLM instruction, shall prompt the LLM interact with the shell. - """ - pass - - @abstractmethod - def actions(self) -> Iterable[Action]: - """ - actions from the shell - Ghost(LLM) can interact with the shell by these actions. - Through function call or functional token protocol. - """ - pass - - @abstractmethod - def drivers(self) -> Iterable[ABSTRACT]: - """ - The drivers that this shell provided to the Ghost. - Driver is usually a class interface, not an implementation. - Ghost can operate the shell by generate codes in the MOSS to call these drivers. - And the Ghost's ai models do not need to know the details of the implementation. - - The GhostOS will bind the drivers and it's implementations to the Ghost IoCContainer. - - For example, a Thought can play music by calling a driver named MusicPlayer, - no matter the shell is a Robot, a Car, or a IM chatbot. - """ - pass - - @abstractmethod - def get_driver(self, driver: ABSTRACT) -> INSTANCE: - """ - get driver's INSTANCE that already bound to the Shell. - """ - pass diff --git a/ghostos/core/ghosts/thoughts.py b/ghostos/core/ghosts/thoughts.py deleted file mode 100644 index d3a8e1dd..00000000 --- a/ghostos/core/ghosts/thoughts.py +++ /dev/null @@ -1,308 +0,0 @@ -import inspect -from typing import Optional, TypeVar, Generic, Type, Iterable -from abc import ABC, abstractmethod -from ghostos.entity import Entity, ModelEntity -from ghostos.core.runtime import Event, GoThreadInfo, Session -from ghostos.core.ghosts.ghost import Ghost -from ghostos.core.ghosts.operators import Operator -from ghostos.identifier import Identical, Identifier, PromptAbleClass -from ghostos.helpers import uuid, generate_import_path -from pydantic import Field - -__all__ = ['Thought', 'ModelThought', 'ThoughtDriver', 'BasicThoughtDriver', "Mindset", "get_thought_driver_type", 'T'] - - -class Thought(Identical, Entity, ABC): - """ - The Thought class serves as a fundamental component of AI, - adept at initiating a stateful task to address specific inquiries. - It is a thinking unit of the Agent that can handle a specific type of task. - """ - - """ - 用代码的方式实现的思维链描述, 是 Llm-based Agent 的思维单元. - 可以用来创建并且驱动一个任务. - Thought 有着各种实现, 包括聊天, 使用工具, 决策, 规划等等. - 因此 Thought 的实例可以视作将成熟的知识和经验封装为可复用的记忆碎片. - 只要继承自 Thought 类, 就可以驱动思维. - """ - - # tips: - # 从工程设计的角度看, Thought 不要用继承的方式定义使用, 而应该用组合的方式. - # 每个 Thought 都应该是完备的 BaseModel. 不要使用继承. - # 这样可以最好地自解释. - # 对于复杂度比较高的 Thought, 可以用函数来实现一些子封装. - - __thought_driver__: Optional[Type["ThoughtDriver"]] = None - """ - 定义 Thought 类的Driver. - 依照约定优先于配置, driver 为 None 时, 系统默认从 Thought 所属模块取 Thought.__qualname__ + 'Driver' 的类作为 Driver. - """ - - @abstractmethod - def identifier(self) -> Identifier: - """ - 生成一个 Identifier 的实例用来描述 Thought 的状态. - """ - pass - - @classmethod - def __class_prompt__(cls) -> str: - if cls is Thought: - return ''' -class Thought(ABC): - """ - The Thought class serves as a fundamental component of AI, - adept at initiating a stateful task to address specific inquiries. - """ - pass -''' - return inspect.getsource(cls) - - -class ModelThought(Thought, ModelEntity, PromptAbleClass, ABC): - """ - The abstract model of the thought based by pydantic.BaseModel. - """ - - def identifier(self) -> Identifier: - cls = self.__class__ - import_path = generate_import_path(cls) - return Identifier( - name=import_path, - description=str(cls.__doc__), - ) - - @classmethod - def __class_prompt__(cls) -> str: - if cls is ModelThought: - return ''' -class ThoughtModel(Thought, BaseModel, ABC): - """ - The abstract type of Thought that based on pydantic.BaseModel. - """ - name: str = Field(description="name of the thought") - description: str = Field(description="description of the thought") -''' - return inspect.getsource(cls) - - -def get_thought_driver_type(cls: Type[Thought]) -> Type["ThoughtDriver"]: - """ - 根据约定, 将 Thought 类封装到 Driver 对象里. - :return: - """ - driver_type = cls.__thought_driver__ - if driver_type is None: - thought_cls = cls - name = thought_cls.__name__ - expect_name = f"{name}Driver" - import inspect - module = inspect.getmodule(thought_cls) - if module is None or expect_name not in module.__dict__: - raise NotImplementedError(f"{expect_name} does not exists in {thought_cls.__module__}") - driver_type = module.__dict__[expect_name] - return driver_type - - -T = TypeVar("T", bound=Thought) - - -class ThoughtDriver(Generic[T], ABC): - """ - ThoughtEntity 是 Thought 运行时生成的实例. - 实际上就是把 Python 的 Class 拆成了 Model (数据结构) + Driver (各种 methods) - 这样 Thought 作为一个 Model, 可以直接将源码呈现给大模型, 用于各种场景的使用. - - 同时也起到一个双向数据桥梁的作用: - 1. Thought => ThoughtInstance => EntityMeta - 2. EntityMeta => ThoughtInstance => Thought - """ - - def __init__(self, thought: T): - """ - 实例化 ThoughtEntity, 这个方法入参不要修改. 系统要使用这个方法实例化. - """ - self.thought: T = thought - - @abstractmethod - def new_task_id(self, g: Ghost) -> str: - """ - 创建一个唯一的 task id. - 在 create task 时会调用. - """ - pass - - def on_event(self, g: Ghost, e: Event) -> Optional[Operator]: - """ - 接受 event 并作出响应. - """ - name = e.type - method = "on_" + name - if hasattr(self, method): - return getattr(self, method)(g, e) - return None - - -class BasicThoughtDriver(Generic[T], ThoughtDriver[T], ABC): - @abstractmethod - def think(self, g: Ghost, e: Event) -> Optional[Operator]: - """ - 开始一轮思考. - """ - pass - - def new_task_id(self, g: Ghost) -> str: - # 如果生成的 task id 是不变的, 则在同一个 process 里是一个单例. 但要考虑互相污染的问题. - task_id = uuid() - g.logger().info(f"ghost create task_id {task_id}") - return task_id - - def on_created(self, g: Ghost, e: Event) -> Optional[Operator]: - """ - 基于 Thought 创建了 Task 时触发的事件. - 可以根据事件做必要的初始化. - """ - g.logger().info(f"ghost handle event on_created {e}") - session = g.session() - thread = session.thread() - task = session.task() - - process_id = session.update_prompt().process_id - task_name = task.name.replace("/", "_") - task_name = task_name.replace(".", "_") - thread.save_file = f"process_{process_id}/task_{task_name}_thread_{thread.id}.yml" - self.prepare_thread(session, thread) - - session.update_thread(thread, False) - - return self.think(g, e) - - @staticmethod - def prepare_thread(session: Session, thread: GoThreadInfo) -> GoThreadInfo: - """ - prepare thread usually defining thread id and thread.save_file for debug reason - """ - return thread - - def on_canceling(self, g: Ghost, e: Event) -> Optional[Operator]: - """ - 当前任务被取消时. 如果返回非 None 的动作, 会取消默认的逻辑. - :param g: - :param e: - :return: - """ - g.logger().info(f"ghost handle event on_canceling {e}") - return None - - def on_input(self, g: Ghost, e: Event) -> Optional[Operator]: - """ - 接受到一个外部事件后执行的逻辑. - """ - g.logger().info(f"ghost handle event on_input {e}") - op = self.think(g, e) - if op is None: - op = g.taskflow().awaits() - return op - - def on_observe(self, g: Ghost, e: Event) -> Optional[Operator]: - """ - 自己触发的观察动作. - """ - g.logger().info(f"ghost handle event on_observe {e}") - op = self.think(g, e) - if op is None: - op = g.taskflow().awaits() - return op - - def on_finished(self, g: Ghost, e: Event) -> Optional[Operator]: - """ - 当前 Thought 所生成的 task 结束之后, 回调自己的反思逻辑. - """ - g.logger().info(f"ghost handle event on_finished {e}") - return None - - def on_failed(self, g: Ghost, e: Event) -> Optional[Operator]: - """ - 回调自己的反思逻辑. - """ - g.logger().info(f"ghost handle event on_failed {e}") - return None - - def on_finish_callback(self, g: Ghost, e: Event) -> Optional[Operator]: - """ - 接受到了下游任务完成的回调. - """ - g.logger().info(f"ghost handle event on_finish callback {e}") - return self.think(g, e) - - def on_failure_callback(self, g: Ghost, e: Event) -> Optional[Operator]: - """ - 接受到了下游任务失败的回调. - """ - g.logger().info(f"ghost handle event on_failure callback {e}") - return self.think(g, e) - - def on_wait_callback(self, g: Ghost, e: Event) -> Optional[Operator]: - """ - 接受到了下游任务的提问, 需要回答. - """ - g.logger().info(f"ghost handle event on_wait_callback {e}") - return self.think(g, e) - - def on_notify_callback(self, g: Ghost, e: Event) -> Optional[Operator]: - """ - 一个下游的通知, 不需要做任何操作. - """ - g.logger().info(f"ghost handle event on_notify_callback {e}") - return None - - -class Mindset(ABC): - """ - 思维集合, 用来管理所有可以使用的 Thought 类. - 是一个可持续学习, 召回的记忆空间. - """ - - @abstractmethod - def register_thought_type(self, cls: Type[Thought], driver: Optional[Type[ThoughtDriver]] = None) -> None: - """ - 注册一个 thought class, 还可以替换它的 driver. - - 系统默认的优先级应该是: - 1. registered driver. - 2. thought's defined driver in __thought_driver__ - 3. thought class's __name__ + 'Driver' in the same module. - - 如果无法解析出任何 driver, 需要抛出异常. - - 注册动作会默认将 ThoughtEntity 的 Identifier 做为索引, 方便根据需要反查到 Thought. - :param cls: - :param driver: - :return: - """ - pass - - def get_thought_driver(self, thought: Thought) -> ThoughtDriver: - """ - 使用 Thought 的类反查 Driver. - """ - driver_type = self.get_thought_driver_type(type(thought)) - return driver_type(thought) - - @abstractmethod - def get_thought_driver_type(self, thought_type: Type[Thought]) -> Type[ThoughtDriver]: - """ - 返回与 Thought 类型对应的 ThoughtDriver 类型. - :param thought_type: - :return: - """ - pass - - @abstractmethod - def thought_types(self) -> Iterable[Type[Thought]]: - """ - 遍历所有注册的 thought types. - :return: - """ - pass diff --git a/ghostos/core/ghosts/utils.py b/ghostos/core/ghosts/utils.py deleted file mode 100644 index acb1275f..00000000 --- a/ghostos/core/ghosts/utils.py +++ /dev/null @@ -1,241 +0,0 @@ -from typing import Optional, List, NamedTuple -from ghostos.core.ghosts.ghost import Ghost -from ghostos.core.ghosts.operators import Operator -from ghostos.core.ghosts.thoughts import Thought, ThoughtDriver -from ghostos.core.runtime import ( - Event, EventTypes, - GoTaskStruct, TaskState, GoTasks, -) -from ghostos.core.messages import ( - MessageKind, - MessageKindParser, - Role, -) -from dataclasses import dataclass - - -@dataclass -class NewTask: - """ - useful to create a child task - """ - task_name: str - """task specific name that you can identify this task in future""" - - task_desc: str - """task description that why you create this task""" - - thought: Thought - """Thought instance that dispatched to run this task""" - - instruction: str - """the instruction to the task thought""" - - -class Utils: - def __init__(self, ghost: Ghost): - self.ghost = ghost - - def get_thought_driver(self, thought: Thought) -> ThoughtDriver: - return self.ghost.mindset().get_thought_driver(thought) - - def initialize(self) -> None: - """ - initialize ghost - """ - session = self.ghost.session() - process = session.update_prompt() - if process.initialized: - return None - task_id = process.main_task_id - root_thought = self.ghost.root_thought() - identifier = root_thought.identifier() - meta = root_thought.to_entity_meta() - task = GoTaskStruct.new( - task_id=task_id, - shell_id=session.id(), - process_id=process.process_id, - name=identifier.name, - description=identifier.description, - meta=meta, - parent_task_id=None, - ) - process.initialized = True - session.update_process(process) - session.update_task(task, None, False) - - def fetch_thought_from_task(self, task: "GoTaskStruct") -> ThoughtDriver: - thought = self.ghost.entity_factory().force_new_entity(task.meta, Thought) - return self.ghost.mindset().get_thought_driver(thought) - - def handle_event(self, e: "Event") -> Optional["Operator"]: - """ - ghost 执行事件的基本逻辑. - """ - session = self.ghost.session() - task = session.task() - if task.task_id != e.task_id: - # todo: use ghostos error - raise AttributeError(f"event {e.task_id} does not belong to Task {task.task_id}") - - # regenerate the thought from meta - thought_driver = self.fetch_thought_from_task(task) - # handle event - op = thought_driver.on_event(self.ghost, e) - # update the task.meta from the thought that may be changed - task.meta = thought_driver.thought.to_entity_meta() - session.update_task(task, None, False) - # return the operator that could be None (use default operator outside) - return op - - def create_child_tasks( - self, *, - depend: bool, - new_tasks: List[NewTask], - ) -> None: - """ - 创建子任务. - :param depend: 是否要等待这些任务. - :param new_tasks: - :return: - """ - if len(new_tasks) == 0: - raise ValueError("at least one thought must be provided") - for item in new_tasks: - if not isinstance(item, NewTask): - raise TypeError(f'new task {item} is not instance of NewTask') - events = [] - session = self.ghost.session() - current_task = session.task() - thread = session.thread() - parent_task_id = current_task.task_id - children = [] - children_names = [] - for new_task in new_tasks: - thought = new_task.thought - meta = thought.to_entity_meta() - driver = self.get_thought_driver(thought) - task_id = driver.new_task_id(self.ghost) - child = current_task.add_child( - task_id=task_id, - name=new_task.task_name, - description=new_task.task_desc, - meta=meta, - assistant=current_task.assistant, - ) - children.append(child) - children_names.append(child.name) - # 准备任务的创建事件. 这个事件的消息应该是目标 Thought 自己生成的. 所以不需要消息. - e = EventTypes.CREATED.new( - task_id=task_id, - messages=[], - from_task_id=parent_task_id, - from_task_name=current_task.name, - instruction=new_task.instruction, - ) - events.append(e) - # 更新 task 状态. - session.create_tasks(*children) - # 存储要发送的事件. - session.fire_events(*events) - thread.append(Role.new_system( - content=f"create {len(children_names)} async tasks", - )) - # 更新 awaits 的信息. - if depend: - current_task.depend_on_tasks( - task_ids=[child.task_id for child in children], - ) - session.update_task(current_task, thread, False) - - def cancel_children_tasks( - self, *, - reason: str = "", - instruction: str = "", - includes: Optional[List[str]] = None, - self_task: Optional[GoTaskStruct] = None, - ) -> None: - """ - 取消当前任务的子任务. - includes 为 None 时表示取消所有子任务. - """ - session = self.ghost.session() - self_task = session.task() if self_task is None else self_task - # 没有正确传参. - if includes is not None and not includes: - return - - children_ids = self_task.children - if not children_ids: - return - - tasks = self.ghost.container().force_fetch(GoTasks) - children = list(tasks.get_task_briefs(children_ids)) - if not children: - # 没有 children. - return - - includes_set = set(includes) if includes else set([t.task_id for t in children]) - canceling_events = [] - for t in children: - if not TaskState.is_dead(t.state) and t.task_id in includes_set: - event = EventTypes.CANCEL.new( - task_id=t.task_id, - from_task_id=self_task.task_id, - from_task_name=self_task.name, - reason=reason, - instruction=instruction, - messages=[] - ) - canceling_events.append(event) - - # 批量取消未结束的子任务. - if canceling_events: - # 仍然向这些任务发送事件. - # 发送事件需要用 session 的抽象, 在 session.finish() 时真正执行. - session.fire_events(*canceling_events) - return - - def send(self, *messages: MessageKind) -> None: - if len(messages) == 0: - return - parser = MessageKindParser() - outputs = parser.parse(messages) - self.ghost.session().messenger().send(outputs) - - def send_task_event( - self, *, - task_id: str, - event_type: str, - messages: List[MessageKind], - reason: str = "", - instruction: str = "", - self_task: Optional[GoTaskStruct] = None, - ) -> None: - """ - 主动向一个目标任务发送通知. - :param task_id: - :param event_type: - :param messages: - :param reason: - :param instruction: - :param self_task: - :return: - """ - if messages: - parser = MessageKindParser(role=Role.ASSISTANT.value) - outputs = parser.parse(messages) - else: - outputs = [] - session = self.ghost.session() - self_task = self_task if self_task is not None else session.task() - - event = EventTypes(event_type).new( - task_id=task_id, - messages=outputs, - from_task_id=self_task.task_id, - reason=reason, - instruction=instruction, - ) - session.fire_events(event) - return diff --git a/ghostos/core/llms/tools.py b/ghostos/core/llms/tools.py index 67afd5fe..7896b6d1 100644 --- a/ghostos/core/llms/tools.py +++ b/ghostos/core/llms/tools.py @@ -85,7 +85,7 @@ def new_caller(self, arguments: str) -> "Caller": functional_token=True, ) - def identifier(self) -> Identifier: + def __identifier__(self) -> Identifier: """ identifier of the functional token. """ diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index 087dcf18..1dfda8a5 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -255,7 +255,7 @@ def is_overdue(self) -> bool: now = time.time() return now - self.updated > self.overdue - def identifier(self) -> Identifier: + def __identifier__(self) -> Identifier: return Identifier( id=self.id, name=self.name, diff --git a/ghostos/demo/tests/aifunc_tests.yml b/ghostos/demo/tests/aifunc_tests.yml deleted file mode 100644 index 7c61d538..00000000 --- a/ghostos/demo/tests/aifunc_tests.yml +++ /dev/null @@ -1,8 +0,0 @@ -# # 天气测试用例 -weather: "ghostos.core.aifunc.examples.weather:example" -# # 新闻测试用例. -news: "ghostos.core.aifunc.examples.news:example" -# # 询问天气并查询新闻. -agentic: "ghostos.core.aifunc.examples.agentic:example" -# swe bench lite: localization -swe_bench_lite: "evaluation.swe_bench_lite.debug_localization:example" diff --git a/ghostos/demo/tests/llm_tests/coding/python_editor_baseline.yaml b/ghostos/demo/tests/llm_tests/coding/python_editor_baseline.yaml deleted file mode 100644 index 3d71aa82..00000000 --- a/ghostos/demo/tests/llm_tests/coding/python_editor_baseline.yaml +++ /dev/null @@ -1,268 +0,0 @@ -chat: - id: 060752a4-14e5-4e04-8eee-b255d6907ce6 - system: - - role: system - content: 你是一个 ai 助手, 名字叫做 JoJo. 需要使用自己的工具, 帮助用户解决各种问题. - - role: system - content: |- - 你现在的任务是帮助用户修改或创建 python 的代码. - 你要解决的问题通常有以下几种: - - 1. 使用 `from abc import ABC, abstractmethod` 根据用户需求创建一个 library 的 interface. 要注意每个 method 要有详细的 doc 描述. - 2. 阅读模块的代码, 根据用户需求 debug. - 3. 根据用户需求, 修改代码中的指定位置. - 4. 根据用户需求, 往 module 里追加代码. - - 注意: - - 你应该使用 MOSS 提供的 PythonEditor 工具. - - 使用 functional token 来驱动 MOSS. - - 如果用户描述的信息不足以让你完成任务, 请主动向用户提问. - - role: system - content: |2- - - # MOSS - - You are equipped with the MOSS (Model-oriented Operating System) that provides tools and thought directions in python interface. - With MOSS you shall generate a single block of Python code in which defines a function `def main(os: MOSS) -> Operator:`, - the MOSS will automatically execute them. - - **Directives for MOSS**: - - **Code Generation Only**: Produce a block of Python code for the `main` function. - The interface, class and abstract methods in context are ALREADY implemented in external system, - and passed into main as arguments, DON'T implement them or instantiate them again, - just invoke them directly on you need. - - **Format Requirement**: Your output must be a single block of Python code enclosed within triple backticks. - Do not include any additional text, comments, or explanations outside this code block. - Do not invoke main method by yourself. - - **External System Responsibilities**: - - **Execution and Data Fetching**: The external system will concatenate your code with the true context - (implemented all abstract methods and interface), execution the main method and wait to fetch the result. - - **Result Handling**: The external system will process the results and manage user interactions. - Std output will be buffed by MOSS, you can generate operator to observe them. - - - Here is the context provided to you in this turn: - - ```python - from abc import (ABC,abstractmethod) - from pydantic import (BaseModel,Field) - from typing import (TypedDict) - - class Message(BaseModel): - """ - 消息体的容器. 通用的抽象设计, 设计思路: - 1. message 可以是一个完整的消息, 也可以是一个包, 用 pack 字段做区分. 支持 dict 传输, dict 传输时不包含默认值. - 2. 完整的 message 需要有 msg_id, 但包可以没有. - 3. content 是对客户端展示用的消息体, 而 memory 是对大模型展示的消息体. 两者可能不一样. - 4. message 可以有强类型字段, 比如 images, 但通过 attachments (累加) 和 payload (替代) 来定义. Message 容器里放弱类型的 dict. - 5. type 字段用来提示 message 拥有的信息. 比如 images 消息, 会包含 images payload, 但同时也会指定 type. 这样方便解析时预判. - 6. 所有的 message 都需要能转换成模型的协议, 默认要对齐 openai 的协议. - 7. openai 协议中的 tool, function_call 统一成 caller 抽象, 通过 caller.id 来做区分. - 8. 流式传输中, 可以有首包和尾包. 首包期待包含全部的 payloads 和 attachments. 间包则可选. 尾包是完整的消息体. - """ - pass - - MessageType = typing.Union[ghostos.core.messages.message.Message, ghostos.core.messages.message.MessageClass, str] - - class MessageClass(ABC): - """ - 一种特殊的 Message, 本体是别的数据结构, 但可以通过 to_messages 方法生成一条或多条消息. - """ - pass - - class Operator(ABC): - """ - 系统运行时产生的算子, 会在外层运行. 只允许通过已有的系统函数生成, 不允许临时实现. - """ - pass - - class Mindflow(ABC): - """ - 这个 library 可以直接管理当前多轮对话里的任务, 通过method 返回的 Operator 会操作系统变更当前任务的状态. - """ - def awaits(self, *questions: MessageType) -> Operator: - """ - 当前任务挂起, 等待下一轮用户输入后重新开始思考. - 如果使用了 MOSS, awaits 是默认的调度方法. - **当你需要等待用户进一步输入时, 请总是调用这个方法.** - :param questions: 可以主动向用户提出问题. - """ - pass - - def fail(self, *reasons: MessageType) -> Operator: - """ - 标记当前任务失败 - :param reasons: 发送一条或多条消息告知用户失败的原因. - """ - pass - - def finish(self, *results: MessageType) -> Operator: - """ - 结束当前的任务, 返回任务结果. - 如果当前任务是持续的, 还要等待更多用户输入, 请使用 awaits. - :param results: 发送一条或多条消息作为任务的结论发送给用户. - """ - pass - - def observe(self, *args, **kwargs) -> Operator: - """ - 系统会打印这些变量的值, 作为一条新的输入消息让你观察, 开启你的下一轮思考. - 是实现 Chain of thought 的基本方法. - """ - pass - - def send(self, *messages: MessageType) -> None: - """ - 直接发送一条或多条消息. - """ - pass - - class PythonEditor(ABC): - """ - You are equipped with this Editor that useful to edit certain python module's code. - Only certain modules can be edited, others will throw an NotImplementedError. - """ - def module(self, module: str, create: bool = False) -> Optional["ModuleEditor"]: - """ - use module name to new an ModuleEditor instance. - :param module: module name such as foo.bar - :param create: create new module if module not exists - """ - pass - - class ModuleEditor(ABC): - """ - Python Module Editor that useful to edit the module's code. - Notice you can write code in string, and use the ModuleEditor's api to update real python code file. - """ - def append(self, code: str) -> bool: - """ - append new code to the module, and update the code file. - :param code: new code - :return: success - """ - pass - - def folding_mode(self) -> str: - """ - :return: show the module's code in folding mode with signature and docs only. - """ - pass - - def get_source(self, attr: Optional[str] = None, line_num: bool = False) -> str: - """ - get source code of the module or module's attr. - :param attr: if given, get the source code of it values - :param line_num: if True, each line will end with line number comment such as # 1 - """ - pass - - def update(self, start: int, end: int, code: str) -> bool: - """ - replace the module's code block with new code, and update the code file. - :param start: replacing block's start line num - :param end: replacing block's end line num - :param code: new code, if empty then remove the block only. - :return: success - """ - pass - - class MOSS(ABC): - """ - language Model-oriented Operating System Simulation - full python code interface for large language models - """ - python_editor: PythonEditor - - mindflow: Mindflow - - def imports(self, module: str, *specs: str, **aliases: str) -> Dict[str, Any]: - """ - replace from ... import ... as ... - :param module: module name - :param specs: module spec - :param aliases: alias=module spec - :return: values mapped by name to value - - example: - 'from module.a import Foo, Bar as bar' - could be .imports('module.a', 'Foo', bar='Bar') - """ - pass - ``` - - **Notices** - 0. You need not to use MOSS when you don't need it's api such as tell raw text or use other functional tokens. - 1. MOSS is a private system only serve for you, **never mention it existence**. - 2. MOSS code shall be generated within functional token, markdown python block will not do, and **don't repeat the code with markdown**. - 3. MOSS will automatic execute the main function so you never execute it again. - 4. **Return Operator**: You shall always use method that MOSS provide you to return an Operator from function main. - 5. In the generated MOSS code, ** YOU SHALL NOT WRITE ANYTHING BUT CODE AND COMMENTS BECAUSE MOSS CODE NEVER SEND TO USER**. - 6. Your generated code must include `def main(os: MOSS) -> Operator` method which will be executed following your intention. - - - - # Functional Token - You are equipped with `functional tokens` parser when you are outputing. - - A functional token is a set of special tokens that corresponds to a system callback function. - When a functional token is present in your response, the subsequent output is treated as input parameters for this - callback function until another functional token is encountered. - - Below is a list of the functional tokens available for your use: - - `>moss:`: - - You can output the Python code that MOSS is supposed to run after this token. - The system will automatically execute them. - Notice: - - MOSS-related output is not visible to user. - - You are only able to generate MOSS code within this token. - - The content after this token shall be pure Python code only. - - You can send anything directly before this token, not after it. - - **Never** use ``` to embrace your code. - - Need not to mention the code you generated to user. - - **Notices** - - 0. Your output without functional tokens will send directly. - 1. The existence of functional tokens is unknown to the user. Do not mention their existence. - 2. Use them only when necessary. - 3. You can only use one functional token at a time. - history: - - role: user - content: 你好! - inputs: - - role: user - content: |- - 我希望创建一个 python 模块 `ghostos.mocks.libraries.pdf`, - 在这里面先创建一个 PDF 工具的 interface, 希望它有阅读 PDF, 创建 PDF 等能力. - 请你直接创建相关代码, 不要问我, 我会自己去看文件. - functional_tokens: - - token: '>moss:' - name: moss - description: |- - You can output the Python code that MOSS is supposed to run after this token. - The system will automatically execute them. - Notice: - - MOSS-related output is not visible to user. - - You are only able to generate MOSS code within this token. - - The content after this token shall be pure Python code only. - - You can send anything directly before this token, not after it. - - **Never** use ``` to embrace your code. - - Need not to mention the code you generated to user. - parameters: - properties: - code: - description: 'generated moss code that include `def main(os: MOSS) -> Operator`' - title: Code - type: string - required: - - code - title: MOSSArgument - type: object -apis: -- api: gpt-4o -- api: moonshot-v1-32k -- api: gpt-4 diff --git a/ghostos/demo/tests/llm_tests/hello_world.yaml b/ghostos/demo/tests/llm_tests/hello_world.yaml deleted file mode 100644 index c40bd33a..00000000 --- a/ghostos/demo/tests/llm_tests/hello_world.yaml +++ /dev/null @@ -1,248 +0,0 @@ -chat: - id: c708933d-627d-4186-ba87-0ca297e8bc11 - system: - - role: system - content: 你是一个 ai 助手, 名字叫做 JoJo. - - role: system - content: 你需要使用自己的工具, 帮助用户解决各种问题. - - role: system - content: |2- - - # MOSS - - You are equipped with the MOSS (Model-oriented Operating System) that provides tools and thought directions in python interface. - With MOSS you shall generate a single block of Python code in which defines a function `def main(os: MOSS) -> Operator:`, - the MOSS will automatically execute them. - - **Directives for MOSS**: - - **Code Generation Only**: Produce a block of Python code for the `main` function. - The interface, class and abstract methods in context are ALREADY implemented in external system, - and passed into main as arguments, DON'T implement them or instantiate them again, - just invoke them directly on you need. - - **Format Requirement**: Your output must be a single block of Python code enclosed within triple backticks. - Do not include any additional text, comments, or explanations outside this code block. - Do not invoke main method by yourself. - - **External System Responsibilities**: - - **Execution and Data Fetching**: The external system will concatenate your code with the true context - (implemented all abstract methods and interface), execution the main method and wait to fetch the result. - - **Result Handling**: The external system will process the results and manage user interactions. - Std output will be buffed by MOSS, you can generate operator to observe them. - - - Here is the context provided to you in this turn: - - ```python - class Operator(ABC): - """ - 系统运行时产生的算子, 会在外层运行. 只允许通过已有的系统函数生成, 不允许临时实现. - """ - pass - - class Mindflow(ABC): - """ - 这个 library 可以直接管理当前多轮对话里的任务, 通过method 返回的 Operator 会操作系统变更当前任务的状态. - """ - def awaits(self, *questions: MessageType) -> Operator: - """ - 当前任务挂起, 等待下一轮用户输入后重新开始思考. - 如果使用了 MOSS, awaits 是默认的调度方法. - **当你需要等待用户进一步输入时, 请总是调用这个方法.** - :param questions: 可以主动向用户提出问题. - """ - pass - - def fail(self, *reasons: MessageType) -> Operator: - """ - 标记当前任务失败 - :param reasons: 发送一条或多条消息告知用户失败的原因. - """ - pass - - def finish(self, *results: MessageType) -> Operator: - """ - 结束当前的任务, 返回任务结果. - 如果当前任务是持续的, 还要等待更多用户输入, 请使用 awaits. - :param results: 发送一条或多条消息作为任务的结论发送给用户. - """ - pass - - def observe(self, *args, **kwargs) -> Operator: - """ - 系统会打印这些变量的值, 作为一条新的输入消息让你观察, 开启你的下一轮思考. - 是实现 Chain of thought 的基本方法. - """ - pass - - def send(self, *messages: MessageType) -> None: - """ - 直接发送一条或多条消息. - """ - pass - - class MOSS(ABC): - """ - language Model-oriented Operating System Simulation - full python code interface for large language models - """ - mindflow: Mindflow - - def imports(self, module: str, *specs: str, **aliases: str) -> Dict[str, Any]: - """ - replace from ... import ... as ... - :param module: module name - :param specs: module spec - :param aliases: alias=module spec - :return: values mapped by name to value - - example: - 'from module.a import Foo, Bar as bar' - could be .imports('module.a', 'Foo', bar='Bar') - """ - pass - ``` - - **Notices** - 1. MOSS is a private system only serve for you, never mention it existence. - 2. MOSS code shall be generated within functional token, markdown python block will not do, and **don't repeat the code with markdown**. - 3. MOSS will automatic execute the main function so you never execute it again. - 4. **Return Operator**: You shall always use method that MOSS provide you to return an Operator from function main. - - - - # Functional Token - You are equipped with `functional tokens` parser when you are outputing. - - A functional token is a set of special tokens that corresponds to a system callback function. - When a functional token is present in your response, the subsequent output is treated as input parameters for this - callback function until another functional token is encountered. - - Below is a list of the functional tokens available for your use: - - `>moss:`: - - You can output the Python code that MOSS is supposed to run after this token. - The system will automatically execute them. - Notice: - - MOSS-related output is not visible to user. - - You are only able to generate MOSS code within this token. - - The content after this token shall be pure Python code only. - - You can send anything directly before this token, not after it. - - **Never** use ``` to embrace your code. - - Need not to mention the code you generated to user. - - **Notices** - - 0. Your output without functional tokens will send directly. - 1. The existence of functional tokens is unknown to the user. Do not mention their existence. - 2. Use them only when necessary. - 3. You can only use one functional token at a time. - - - # Functional Token - You are equipped with `functional tokens` parser when you are outputing. - - A functional token is a set of special tokens that corresponds to a system callback function. - When a functional token is present in your response, the subsequent output is treated as input parameters for this - callback function until another functional token is encountered. - - Below is a list of the functional tokens available for your use: - - `>moss:`: - - You can output the Python code that MOSS is supposed to run after this token. - The system will automatically execute them. - Notice: - - MOSS-related output is not visible to user. - - You are only able to generate MOSS code within this token. - - The content after this token shall be pure Python code only. - - You can send anything directly before this token, not after it. - - **Never** use ``` to embrace your code. - - Need not to mention the code you generated to user. - - **Notices** - - 0. Your output without functional tokens will send directly. - 1. The existence of functional tokens is unknown to the user. Do not mention their existence. - 2. Use them only when necessary. - 3. You can only use one functional token at a time. - - - # Functional Token - You are equipped with `functional tokens` parser when you are outputing. - - A functional token is a set of special tokens that corresponds to a system callback function. - When a functional token is present in your response, the subsequent output is treated as input parameters for this - callback function until another functional token is encountered. - - Below is a list of the functional tokens available for your use: - - `>moss:`: - - You can output the Python code that MOSS is supposed to run after this token. - The system will automatically execute them. - Notice: - - MOSS-related output is not visible to user. - - You are only able to generate MOSS code within this token. - - The content after this token shall be pure Python code only. - - You can send anything directly before this token, not after it. - - **Never** use ``` to embrace your code. - - Need not to mention the code you generated to user. - - **Notices** - - 0. Your output without functional tokens will send directly. - 1. The existence of functional tokens is unknown to the user. Do not mention their existence. - 2. Use them only when necessary. - 3. You can only use one functional token at a time. - - - # Functional Token - You are equipped with `functional tokens` parser when you are outputing. - - A functional token is a set of special tokens that corresponds to a system callback function. - When a functional token is present in your response, the subsequent output is treated as input parameters for this - callback function until another functional token is encountered. - - Below is a list of the functional tokens available for your use: - - `>moss:`: - - You can output the Python code that MOSS is supposed to run after this token. - The system will automatically execute them. - Notice: - - MOSS-related output is not visible to user. - - You are only able to generate MOSS code within this token. - - The content after this token shall be pure Python code only. - - You can send anything directly before this token, not after it. - - **Never** use ``` to embrace your code. - - Need not to mention the code you generated to user. - - **Notices** - - 0. Your output without functional tokens will send directly. - 1. The existence of functional tokens is unknown to the user. Do not mention their existence. - 2. Use them only when necessary. - 3. You can only use one functional token at a time. - history: - - role: user - content: 你好! - - content: 你也好啊! 有什么我可以帮您的? - inputs: - - role: user - content: 你可以做什么? - functional_tokens: - - token: '>moss:' - name: moss - description: |- - You can output the Python code that MOSS is supposed to run after this token. - The system will automatically execute them. - Notice: - - MOSS-related output is not visible to user. - - You are only able to generate MOSS code within this token. - - The content after this token shall be pure Python code only. - - You can send anything directly before this token, not after it. - - **Never** use ``` to embrace your code. - - Need not to mention the code you generated to user. -apis: -- api: moonshot-v1-32k diff --git a/ghostos/demo/tests/llm_tests/play_music.yaml b/ghostos/demo/tests/llm_tests/play_music.yaml deleted file mode 100644 index 6de79afd..00000000 --- a/ghostos/demo/tests/llm_tests/play_music.yaml +++ /dev/null @@ -1,304 +0,0 @@ -chat: - id: 30bd53e7-55c8-4cea-88c8-d38ab3356914 - system: - - role: system - content: 你是一个 ai 助手, 名字叫做 JoJo. - - role: system - content: 你需要使用自己的工具, 帮助用户解决各种问题. - - role: system - content: |2- - - # MOSS - - You are equipped with the MOSS (Model-oriented Operating System) that provides tools and thought directions in python interface. - With MOSS you shall generate a single block of Python code in which defines a function `def main(os: MOSS) -> Operator:`, - the MOSS will automatically execute them. - - **Directives for MOSS**: - - **Code Generation Only**: Produce a block of Python code for the `main` function. - The interface, class and abstract methods in context are ALREADY implemented in external system, - and passed into main as arguments, DON'T implement them or instantiate them again, - just invoke them directly on you need. - - **Format Requirement**: Your output must be a single block of Python code enclosed within triple backticks. - Do not include any additional text, comments, or explanations outside this code block. - Do not invoke main method by yourself. - - **External System Responsibilities**: - - **Execution and Data Fetching**: The external system will concatenate your code with the true context - (implemented all abstract methods and interface), execution the main method and wait to fetch the result. - - **Result Handling**: The external system will process the results and manage user interactions. - Std output will be buffed by MOSS, you can generate operator to observe them. - - - Here is the context provided to you in this turn: - - ```python - from abc import (ABC,abstractmethod) - from pydantic import (BaseModel,Field) - from typing import (TypedDict) - - class Message(BaseModel): - """ - 消息体的容器. 通用的抽象设计, 设计思路: - 1. message 可以是一个完整的消息, 也可以是一个包, 用 pack 字段做区分. 支持 dict 传输, dict 传输时不包含默认值. - 2. 完整的 message 需要有 msg_id, 但包可以没有. - 3. content 是对客户端展示用的消息体, 而 memory 是对大模型展示的消息体. 两者可能不一样. - 4. message 可以有强类型字段, 比如 images, 但通过 attachments (累加) 和 payload (替代) 来定义. Message 容器里放弱类型的 dict. - 5. type 字段用来提示 message 拥有的信息. 比如 images 消息, 会包含 images payload, 但同时也会指定 type. 这样方便解析时预判. - 6. 所有的 message 都需要能转换成模型的协议, 默认要对齐 openai 的协议. - 7. openai 协议中的 tool, function_call 统一成 caller 抽象, 通过 caller.id 来做区分. - 8. 流式传输中, 可以有首包和尾包. 首包期待包含全部的 payloads 和 attachments. 间包则可选. 尾包是完整的消息体. - """ - pass - - MessageType = typing.Union[ghostos.core.messages.message.Message, ghostos.core.messages.message.MessageClass, str] - - class MessageClass(ABC): - """ - 一种特殊的 Message, 本体是别的数据结构, 但可以通过 to_messages 方法生成一条或多条消息. - """ - pass - - class Operator(ABC): - """ - 系统运行时产生的算子, 会在外层运行. 只允许通过已有的系统函数生成, 不允许临时实现. - """ - pass - - class Mindflow(ABC): - """ - 这个 library 可以直接管理当前多轮对话里的任务, 通过method 返回的 Operator 会操作系统变更当前任务的状态. - """ - def awaits(self, *questions: MessageType) -> Operator: - """ - 当前任务挂起, 等待下一轮用户输入后重新开始思考. - 如果使用了 MOSS, awaits 是默认的调度方法. - **当你需要等待用户进一步输入时, 请总是调用这个方法.** - :param questions: 可以主动向用户提出问题. - """ - pass - - def fail(self, *reasons: MessageType) -> Operator: - """ - 标记当前任务失败 - :param reasons: 发送一条或多条消息告知用户失败的原因. - """ - pass - - def finish(self, *results: MessageType) -> Operator: - """ - 结束当前的任务, 返回任务结果. - 如果当前任务是持续的, 还要等待更多用户输入, 请使用 awaits. - :param results: 发送一条或多条消息作为任务的结论发送给用户. - """ - pass - - def observe(self, *args, **kwargs) -> Operator: - """ - 系统会打印这些变量的值, 作为一条新的输入消息让你观察, 开启你的下一轮思考. - 是实现 Chain of thought 的基本方法. - """ - pass - - def send(self, *messages: MessageType) -> None: - """ - 直接发送一条或多条消息. - """ - pass - - class MusicPlayer: - """ - useful to play music for the user - """ - def play(self, name: str) -> bool: - """ - play a music - :param name: name of the music - :return: weather the music is playing - """ - pass - - def search(self, desc: str, *keywords: str) -> List[str]: - """ - search music by description and keywords - :param desc: description of the song - :param keywords: keyword about the song that could be artist or song name etc. - :return: list of song names - """ - pass - - class MOSS(ABC): - """ - language Model-oriented Operating System Simulation - full python code interface for large language models - """ - mindflow: Mindflow - - player: MusicPlayer - - def imports(self, module: str, *specs: str, **aliases: str) -> Dict[str, Any]: - """ - replace from ... import ... as ... - :param module: module name - :param specs: module spec - :param aliases: alias=module spec - :return: values mapped by name to value - - example: - 'from module.a import Foo, Bar as bar' - could be .imports('module.a', 'Foo', bar='Bar') - """ - pass - ``` - - **Notices** - 0. You need not to use MOSS when you don't need it's api such as tell raw text or use other functional tokens. - 1. MOSS is a private system only serve for you, never mention it existence. - 2. MOSS code shall be generated within functional token, markdown python block will not do, and **don't repeat the code with markdown**. - 3. MOSS will automatic execute the main function so you never execute it again. - 4. **Return Operator**: You shall always use method that MOSS provide you to return an Operator from function main. - - - - # Functional Token - You are equipped with `functional tokens` parser when you are outputing. - - A functional token is a set of special tokens that corresponds to a system callback function. - When a functional token is present in your response, the subsequent output is treated as input parameters for this - callback function until another functional token is encountered. - - Below is a list of the functional tokens available for your use: - - `>moss:`: - - You can output the Python code that MOSS is supposed to run after this token. - The system will automatically execute them. - Notice: - - MOSS-related output is not visible to user. - - You are only able to generate MOSS code within this token. - - The content after this token shall be pure Python code only. - - You can send anything directly before this token, not after it. - - **Never** use ``` to embrace your code. - - Need not to mention the code you generated to user. - - **Notices** - - 0. Your output without functional tokens will send directly. - 1. The existence of functional tokens is unknown to the user. Do not mention their existence. - 2. Use them only when necessary. - 3. You can only use one functional token at a time. - - - # Functional Token - You are equipped with `functional tokens` parser when you are outputing. - - A functional token is a set of special tokens that corresponds to a system callback function. - When a functional token is present in your response, the subsequent output is treated as input parameters for this - callback function until another functional token is encountered. - - Below is a list of the functional tokens available for your use: - - `>moss:`: - - You can output the Python code that MOSS is supposed to run after this token. - The system will automatically execute them. - Notice: - - MOSS-related output is not visible to user. - - You are only able to generate MOSS code within this token. - - The content after this token shall be pure Python code only. - - You can send anything directly before this token, not after it. - - **Never** use ``` to embrace your code. - - Need not to mention the code you generated to user. - - **Notices** - - 0. Your output without functional tokens will send directly. - 1. The existence of functional tokens is unknown to the user. Do not mention their existence. - 2. Use them only when necessary. - 3. You can only use one functional token at a time. - - - - # Functional Token - You are equipped with `functional tokens` parser when you are outputing. - - A functional token is a set of special tokens that corresponds to a system callback function. - When a functional token is present in your response, the subsequent output is treated as input parameters for this - callback function until another functional token is encountered. - - Below is a list of the functional tokens available for your use: - - `>moss:`: - - You can output the Python code that MOSS is supposed to run after this token. - The system will automatically execute them. - Notice: - - MOSS-related output is not visible to user. - - You are only able to generate MOSS code within this token. - - The content after this token shall be pure Python code only. - - You can send anything directly before this token, not after it. - - **Never** use ``` to embrace your code. - - Need not to mention the code you generated to user. - - **Notices** - - 0. Your output without functional tokens will send directly. - 1. The existence of functional tokens is unknown to the user. Do not mention their existence. - 2. Use them only when necessary. - 3. You can only use one functional token at a time. - history: - - role: user - content: 你好! - - content: 你也好啊! 有什么我可以帮您的? - inputs: - - role: user - content: 你可以帮我播放周杰伦的七里香吗? - functional_tokens: - - token: '>moss:' - name: moss - description: |- - You can output the Python code that MOSS is supposed to run after this token. - The system will automatically execute them. - Notice: - - MOSS-related output is not visible to user. - - You are only able to generate MOSS code within this token. - - The content after this token shall be pure Python code only. - - You can send anything directly before this token, not after it. - - **Never** use ``` to embrace your code. - - Need not to mention the code you generated to user. - parameters: - properties: - code: - description: 'generated moss code that include `def main(os: MOSS) -> Operator`' - title: Code - type: string - required: - - code - title: MOSSArgument - type: object -apis: -- api: moonshot-v1-32k -- api: gpt-4o -results: -- time: '2024-07-31T17:51:50.209089' - results: - moonshot.moonshot-v1-32k: - msg_id: a37ee34a-2952-4ffd-9daa-d3b82f88cbca - type: chat_completion - created: 1722419507.9659 - pack: false - content: 当然可以,我将为您搜索并播放周杰伦的《七里香》。请稍等片刻。 - openai.gpt-4o: - msg_id: 4f5c4253-e8f3-49c0-a75b-387a9cc4b77c - type: chat_completion - created: 1722419510.2085 - pack: false - content: |- - >moss: - - def main(os: MOSS) -> Operator: - song_name = "七里香" - if os.player.play(song_name): - return os.mindflow.finish(f"正在播放 {song_name}") - else: - return os.mindflow.fail(f"无法播放 {song_name}") diff --git a/ghostos/demo/tests/llm_tests/python_bad_case_1.yaml b/ghostos/demo/tests/llm_tests/python_bad_case_1.yaml deleted file mode 100644 index 6f87e19d..00000000 --- a/ghostos/demo/tests/llm_tests/python_bad_case_1.yaml +++ /dev/null @@ -1,87 +0,0 @@ -# conf: ghostos.framework.llms.test_case::ChatCompletionTestCase -chat: - system: - - role: system - content: |+ - 你是一个使用 python 代码来思考的 ai. 你当前的 python 上下文如下 (注意: 代码的实现已经隐藏, 你不需要了解) : - - ```python - - class Future(BaseModel): - """ - 一个可以观测的结果. - """ - id: str - name: str - descr: str - - - def get_weather(city: str, date: datetime.date) -> Future: - """ - 获取一个城市的天气. - """ - pass - - class Thought(ABC): - - @abstractmethod - def observe(self, **values) -> None: - """ - 观测上下文中产生的值. - """ - pass - - @abstractmethod - def async_call(self, name: str, desc: str, caller: Callable, *args, **kwargs) -> Future: - """ - 异步调用一个函数, 得到一个可观测的结果. - """ - pass - - @abstractmethod - def awaits(self, future: Future, instructions: str, on_err: str) -> None: - """ - 观测一个 future 的结果. - instructions: 用自然语言记录拿到结果后应该怎么做 - on_err: 用自然语言记录如果出错了应该怎么做. - """ - pass - - @abstractmethod - def awaits_all(self, future: List[Future], instructions: str, on_err: str) -> None: - """ - 等多个 future 实现后, 一起观测. - """ - pass - - @abstractmethod - def awaits_race(self, futures: List[Future], instructions: str, on_err: str) -> None: - """ - 观测若干个 future 中第一个返回的结果. - """ - pass - - @abstractmethod - def restart(self, logs: str) -> None: - """ - 从头开始思考问题. 记录日志, 方便未来思考. - """ - pass - - ``` - - 当用户和你说话时, 你可以用自然语言回答, 也可以使用 `> python:` 作为独立的一行开头, 然后实现 python 代码, 其中必须包含 main 函数: `def main(t: Thought) -> None:` - - 实现的 main 函数会立刻执行, 如果你观测了其中的结果, 会得到相关讯息. - - 注意: - - 1. main 函数的入参已经得到实现. 你不用实现它. - inputs: - - role: user - content: 告诉我北京明天的天气 -apis: - - api: moonshot-v1-32k - - api: moonshot-v1-128k -# - api: gpt-3.5-turbo -# - api: gpt-4-turbo \ No newline at end of file diff --git a/ghostos/demo/tests/llm_tests/python_case_1_en.yaml b/ghostos/demo/tests/llm_tests/python_case_1_en.yaml deleted file mode 100644 index 697bc124..00000000 --- a/ghostos/demo/tests/llm_tests/python_case_1_en.yaml +++ /dev/null @@ -1,74 +0,0 @@ -# conf: ghostos.framework.llms.test_case::ChatCompletionTestCase -chat: - system: - - role: system - content: |+ - You are tasked to generate a single block of Python code that defines a function `def main(t: Thought) -> None:`. - - **Directives for Your Task**: - - **Code Generation Only**: Produce a block of Python code for the `main` function. The interface, class and abstract methods in context are ALREADY implemented in external system, and passed into main as arguments, DON'T implement them or instantiate them again, just invoke them directly on you need. - - **Format Requirement**: Your output must be a single block of Python code enclosed within triple backticks. Do not include any additional text, comments, or explanations outside this code block. Do not invoke main method by yourself. - - **External System Responsibilities**: - - **Execution and Data Fetching**: The external system will concatenate your code with the true context (implemented all abstract methods and interface), execution the main method and wait to fetch the result. - - **Result Handling**: The external system will process the results and manage user interactions. - - Here is the context provided to you in this turn: - - ```python - from abc import ABC, abstractmethod - from typing import Callable, List - from pydantic import BaseModel - import datetime - - def get_weather(city: str, date: datetime.date) -> Future: - """ - fetch weather of a city - """ - pass - - class Future(BaseModel): - """ - An observable result. - """ - id: str - name: str - descr: str - - class Thought(ABC): - @abstractmethod - def observe(self, **values) -> None: - """ - Observe values generated in the context. - """ - pass - - @abstractmethod - def async_call(self, name: str, desc: str, caller: Callable, *args, **kwargs) -> Future: - """ - Asynchronously call a function and receive an observable result. - """ - pass - - @abstractmethod - def awaits(self, future: Future, instructions: str, on_err: str) -> None: - """ - Await a future's result, then act based on the result. - """ - pass - ``` - - Ensure that your output is strictly the code within the triple backticks. This ensures clarity and usability in the external system's processing and analysis of your code. - inputs: - - role: user - content: Tell me the weather of shanghai in tomorrow - -apis: - - api: moonshot-v1-32k - - api: moonshot-v1-128k - - api: gpt-3.5-turbo - - api: gpt-4-turbo - - api: codestral-22b - - api: qwen2-72b - - api: llama3-70b - diff --git a/ghostos/demo/tests/llm_tests/python_case_1_zh.yaml b/ghostos/demo/tests/llm_tests/python_case_1_zh.yaml deleted file mode 100644 index 56371262..00000000 --- a/ghostos/demo/tests/llm_tests/python_case_1_zh.yaml +++ /dev/null @@ -1,93 +0,0 @@ -# conf: ghostos.framework.llms.test_case::ChatCompletionTestCase -chat: - system: - - role: system - content: |+ - 这是一个关于生成单个Python代码块的任务,该代码块定义一个函数 def main(t: Thought) -> None:。你需要根据以下指令来生成代码: - - 1. 仅实现main:生成main函数的Python代码块。接口、类和抽象方法已在外部系统中实现,并作为参数传入main,不需要你再次实现或实例化它们,直接在需要时调用它们。 - 2. 格式要求:你的输出必须是一个包含在 ``` 内的单个Python代码块。不要在这个代码块外包含任何额外的文本、注释或解释。不要自行调用main方法。 - - 外部系统会如何处理你的代码: - 1. 执行和数据获取:外部系统将与你的代码结合,执行main方法,并等待获取结果。 - 2. 结果处理:拿到代码运行结果后,外部系统将用其自行管理用户交互。 - - 给你提供的上下文中会包含一些类和函数的定义,你的任务是使用这些预定义的接口和方法在main函数中实现一些功能,比如异步调用函数、观察和等待结果。 - - ```python - - class Future(BaseModel): - """ - 一个可以观测的结果. - """ - id: str - name: str - descr: str - - - def get_weather(city: str, date: datetime.date) -> Future: - """ - 获取一个城市的天气. - """ - pass - - class Thought(ABC): - - @abstractmethod - def observe(self, **values) -> None: - """ - 观测上下文中产生的值. - """ - pass - - @abstractmethod - def async_call(self, name: str, desc: str, caller: Callable, *args, **kwargs) -> Future: - """ - 异步调用一个函数, 得到一个可观测的结果. - """ - pass - - @abstractmethod - def awaits(self, future: Future, instructions: str, on_err: str) -> None: - """ - 观测一个 future 的结果. - instructions: 用自然语言记录拿到结果后应该怎么做 - on_err: 用自然语言记录如果出错了应该怎么做. - """ - pass - - @abstractmethod - def awaits_all(self, future: List[Future], instructions: str, on_err: str) -> None: - """ - 等多个 future 实现后, 一起观测. - """ - pass - - @abstractmethod - def awaits_race(self, futures: List[Future], instructions: str, on_err: str) -> None: - """ - 观测若干个 future 中第一个返回的结果. - """ - pass - - @abstractmethod - def restart(self, logs: str) -> None: - """ - 从头开始思考问题. 记录日志, 方便未来思考. - """ - pass - - ``` - - 请确保你的输出严格是三重反引号内的代码。这样可以确保外部系统处理和分析你的代码时不会出错。 - inputs: - - role: user - content: 告诉我北京明天的天气 -apis: - - api: moonshot-v1-32k - - api: moonshot-v1-128k - - api: gpt-3.5-turbo - - api: gpt-4-turbo - - api: codestral-22b - - api: qwen2-72b - - api: llama3-70b \ No newline at end of file diff --git a/ghostos/entity.py b/ghostos/entity.py index d1af2d76..b7ea6b4b 100644 --- a/ghostos/entity.py +++ b/ghostos/entity.py @@ -2,7 +2,7 @@ import json from abc import ABC, abstractmethod -from typing import Union, Any, TypedDict, Required, Self, TypeVar, Type, Optional +from typing import Union, Any, TypedDict, Required, Self, TypeVar, Type, Optional, Protocol from types import ModuleType from pydantic import BaseModel from ghostos.helpers import generate_import_path, import_from_path, parse_import_module_and_spec @@ -20,7 +20,7 @@ ] -class Entity(ABC): +class Entity(Protocol): @abstractmethod def __to_entity_meta__(self) -> EntityMeta: diff --git a/ghostos/framework/actions/__init__.py b/ghostos/framework/actions/__init__.py deleted file mode 100644 index 1733c8dd..00000000 --- a/ghostos/framework/actions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ghostos.framework.actions.moss_action import MossAction diff --git a/ghostos/framework/actions/moss_action.py b/ghostos/framework/actions/moss_action.py deleted file mode 100644 index 5ad8a65c..00000000 --- a/ghostos/framework/actions/moss_action.py +++ /dev/null @@ -1,184 +0,0 @@ -import inspect -import json - -from typing import Optional, ClassVar -from ghostos.container import Container -from ghostos.core.ghosts import Action, Ghost -from ghostos.core.llms import Prompt, FunctionalToken, PromptPipe -from ghostos.core.messages import MessageType, Caller -from ghostos.core.moss import MossRuntime, moss_message -from ghostos.core.ghosts.operators import Operator -from ghostos.core.runtime import Session -from ghostos.identifier import Identifier -from pydantic import BaseModel, Field -from traceback import format_exc - -__all__ = ['MossAction', 'MossArgument', 'DEFAULT_MOSS_FUNCTIONAL_TOKEN'] - - -class MossArgument(BaseModel): - code: str = Field(description="generate python code which will be executed by Moss") - - -DEFAULT_MOSS_FUNCTIONAL_TOKEN = FunctionalToken( - token="", - end_token="", - name="moss", - description=""" -You can output the Python code that MOSS is supposed to run after this token. -The system will automatically execute them. -include `def main(os: Moss) -> Operator` -Notice: -- You are only able to generate MOSS code within this token. -- The content within this token shall be Python code only. -- You can send anything directly before this token, not after it. -- **Never** use ``` to embrace your code. -- Need not to mention the code you generated to user. -""".strip(), - visible=False, - parameters=MossArgument.model_json_schema(), -) - - -class MossAction(Action): - """ - 系统内置的 MOSS Action, 同步运行. - """ - - template: ClassVar[str] = """ -# MOSS - -You are equipped with the MOSS (Model-oriented Operating System Simulation) that provides tools and thought directions -in python interface. -With MOSS you shall generate a single block of Python code, -in which must define a function `main(moss: Moss) -> Optional[Operator]:`, -the MOSS will automatically execute the main function. - -About main function parameters: -``` -:param moss: instance of Moss that has been injected with dependencies. -:return: return Operator by existing library, or return None to take default action. NEVER define it by yourself. -``` - - -**Directives for MOSS**: -- **Code Generation Only**: Produce a block of Python code for the `main` function. - The interface, class and abstract methods in context are ALREADY implemented in external system, - and passed into main as arguments, DON'T implement them or instantiate them again, - just invoke them directly on you need. -- **Format Requirement**: Your output must be a single block of Python code enclosed within triple backticks. - Do not include any additional text, comments, or explanations outside this code block. - Do not invoke main method by yourself. - -**External System Responsibilities**: -- **Execution and Data Fetching**: The external system will concatenate your code with the true context - (implemented all abstract methods and interface), execution the main method and wait to fetch the result. -- **Result Handling**: The external system will process the results and manage user interactions. - Std output will be buffed by MOSS, you can generate operator to observe them. - - -Here is the context provided to you in this turn: - -```python -{code} -``` - -**Notices** -0. You need not to use MOSS when you don't need it such like sending raw text or using other tools. -1. MOSS is a private system only serve for you, **never mention it existence**. -2. MOSS code shall be generated within functional token, markdown python block will not do, and **don't repeat the code with markdown**. -3. MOSS will automatic execute the main function so you never execute it again. -4. **Return Operator**: You shall always use method that MOSS provide you to return an Operator from function main. -5. In the generated MOSS code, ** YOU SHALL NOT WRITE ANYTHING BUT CODE AND COMMENTS BECAUSE MOSS CODE NEVER SEND TO USER**. -6. the std output from `print` is only visible for you, no one can see it. print with format will help you to understand the result. - -**About Coding Jobs**: -Sometimes you are handling coding task, the MOSS provides you code interface to handle your job. -But the MOSS code you generated is not the target code you are coding. DO NOT CONFUSE THE Them! -At these scenarios you shall write target code as string, and using the libraries MOSS providing to you to handle them. -""" - - def __init__( - self, - moss_runtime: MossRuntime, - functional_token: Optional[FunctionalToken] = None, - deliver: bool = False, - ): - self._moss_runtime = moss_runtime - if functional_token is None: - functional_token = DEFAULT_MOSS_FUNCTIONAL_TOKEN.model_copy(deep=True) - functional_token.visible = deliver - self._functional_token = functional_token - - def identifier(self) -> Identifier: - return Identifier( - name=self._functional_token.name, - description=self._functional_token.description, - ) - - def update_prompt(self, chat: Prompt) -> Prompt: - # update functional tokens - function_token = self._functional_token - chat.functional_tokens.append(function_token) - - # update code prompt as system message - code_prompt = self._moss_runtime.prompter().dump_code_context() - moss_instruction = self.template.format(code=code_prompt) - moss_prompt = MessageType.DEFAULT.new_system( - content=moss_instruction, - ) - chat.system.append(moss_prompt) - # !!! chat preparer in the moss instance will auto update chat - moss_instance = self._moss_runtime.moss() - for name, member in inspect.getmembers(moss_instance): - if name.startswith("_"): - continue - if isinstance(member, PromptPipe): - member.update_prompt(chat) - - return chat - - def act(self, c: "Container", session: Session, caller: Caller) -> Optional["Operator"]: - thread = session.thread() - op = None - if caller.functional_token: - code = caller.arguments - else: - unmarshal = json.loads(caller.arguments) - argument = MossArgument(**unmarshal) - code = argument.code - - messenger = session.messenger(thread=thread) - code = code.rstrip().replace("```python", "").replace("```", "") - try: - executed = self._moss_runtime.execute(code=code, target="main", local_args=["moss"]) - op = executed.returns - if op is not None and not isinstance(op, Operator): - # todo: 换成正规的异常. - raise RuntimeError("function main's result is not an instance of the Operator") - - # 运行 moss - pycontext = executed.pycontext - printed = executed.std_output - content = "" - if printed: - content = f"printed content (only visible to you):\n\n```\n{printed}\n```" - # 生成消息并发送. - if content: - # 理论上对用户不展示的消息. - message = moss_message(content="", memory=content) - messenger.deliver(message) - thread.update_pycontext(pycontext) - if content and op is None: - op = c.force_fetch(Ghost).taskflow().think() - except Exception as e: - # 将异常作为消息. todo: 完善消息. - content = f"run moss failed: \n{e} \n\n{format_exc()}" - message = moss_message(content="", memory=content) - messenger.deliver(message) - op = c.force_fetch(Ghost).taskflow().think() - finally: - # 将 moss 清空掉. - self._moss_runtime.destroy() - session.update_thread(thread, False) - return op diff --git a/ghostos/framework/documents/storage_impl.py b/ghostos/framework/documents/storage_impl.py index f7bc08ea..eccc490e 100644 --- a/ghostos/framework/documents/storage_impl.py +++ b/ghostos/framework/documents/storage_impl.py @@ -91,7 +91,7 @@ def register(self, domain: Documents) -> None: def list_domains(self) -> List[Identifier]: for domain in self._documents.values(): - yield domain.identifier() + yield domain.__identifier__() class StorageDocumentsConfig(YamlConfig): diff --git a/ghostos/framework/entities/__init__.py b/ghostos/framework/entities/__init__.py deleted file mode 100644 index 46de63e3..00000000 --- a/ghostos/framework/entities/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ghostos.framework.entities.basic import EntityFactoryProvider diff --git a/ghostos/framework/entities/basic.py b/ghostos/framework/entities/basic.py deleted file mode 100644 index 427b2374..00000000 --- a/ghostos/framework/entities/basic.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Optional - -from ghostos.container import Provider, Container, INSTANCE -from ghostos.contracts.modules import Modules -from ghostos.entity import EntityFactory, EntityFactoryImpl - - -class EntityFactoryProvider(Provider[EntityFactory]): - def singleton(self) -> bool: - return True - - def factory(self, con: Container) -> Optional[EntityFactory]: - modules = con.force_fetch(Modules) - return EntityFactoryImpl(modules.import_module) diff --git a/ghostos/framework/ghosts/__init__.py b/ghostos/framework/ghosts/__init__.py deleted file mode 100644 index 4a47d6d1..00000000 --- a/ghostos/framework/ghosts/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from ghostos.framework.ghosts.basic import BasicGhost, InputsPipe -from ghostos.framework.ghosts.demo import DemoGhost, DemoGhostConf diff --git a/ghostos/framework/ghosts/basic.py b/ghostos/framework/ghosts/basic.py deleted file mode 100644 index d8802317..00000000 --- a/ghostos/framework/ghosts/basic.py +++ /dev/null @@ -1,382 +0,0 @@ -from typing import Optional, Tuple, List, Dict, Type, ClassVar -from abc import ABC, abstractmethod - -from ghostos.container import provide -from ghostos.contracts.storage import Storage -from ghostos.contracts.logger import LoggerItf -from ghostos.contracts.shutdown import Shutdown -from ghostos.contracts.pool import Pool -from ghostos.core.ghosts import ( - Ghost, GhostConf, Operator, Inputs, Shell, Mindset, Thought, - MultiTask, Taskflow, Utils, Workspace -) -from ghostos.core.ghosts.schedulers import Replier -from ghostos.core.llms import LLMs -from ghostos.core.moss import MossCompiler -from ghostos.core.messages import Caller -from ghostos.core.runtime import ( - Session, Event, EventTypes, - EventBus, GoTasks, GoProcesses, GoThreads, Messenger, - GoProcess, GoTaskStruct, GoThreadInfo, -) -from ghostos.framework.operators import OnEventOperator -from ghostos.framework.multitasks import MultiTaskBasicImpl -from ghostos.framework.taskflow import TaskflowBasicImpl -from ghostos.framework.repliers import ReplierImpl -from ghostos.framework.session import BasicSession -from ghostos.core.moss.impl import MossCompilerImpl -from ghostos.contracts.modules import Modules -from ghostos.core.messages import Stream -from ghostos.framework.mindsets import WorkspaceMindsetProvider -from ghostos.framework.configs import Configs -from ghostos.container import Container, Provider -from ghostos.entity import EntityFactory - -__all__ = ['InputsPipe', 'BasicGhost'] - - -class InputsPipe: - def __init__(self, ghost: Ghost): - self.ghost = ghost - - @abstractmethod - def intercept(self, inputs: Inputs) -> Optional[Inputs]: - pass - - -class BasicGhost(Ghost, ABC): - """ - Basic ghost implementation. - """ - - inputs_pipes: List[Type[InputsPipe]] = [] - """inputs pipes that can intercept inputs""" - - providers: List[Provider] = [] - """ providers that ghost container shall register""" - - depend_contracts: ClassVar[List[Type]] = [ - Modules, - LoggerItf, - Storage, - Configs, - EventBus, - GoProcesses, - GoTasks, - GoThreads, - Pool, - LLMs, - Shutdown, - ] - - ghost_contracts: ClassVar[List[Type]] = [ - Session, - Shell, - Ghost, - Mindset, - EntityFactory, - MultiTask, - Taskflow, - Replier, - Workspace, - MossCompiler, - Utils, - Messenger, - EntityFactory, - ] - """default contracts that ghost container shall validate before start.""" - - def __init__( - self, *, - conf: GhostConf, - container: Container, - shell: Shell, - workspace: Workspace, - entity_factory: EntityFactory, - upstream: Stream, - process: GoProcess, - max_operator_runs: int, - task: Optional[GoTaskStruct] = None, - task_id: Optional[str] = None, - ): - # init ghost container, validate it first - self._validate_parent_container_contracts(container) - container = Container(parent=container) - self._container = container - # other properties - self._origin_event: Optional[Event] = None - # workspace - self._workspace = workspace - # config - self._conf = conf - self._max_operator_runs = max_operator_runs - # init shell. - self._shell = shell - # entity factory - self._entity_factory = entity_factory - # root thought - root_thought_meta = conf.root_thought_meta() - root_thought = entity_factory.force_new_entity(root_thought_meta, Thought) - self._root_thought = root_thought - logger = container.force_fetch(LoggerItf) - # instance session. - self._session = self.make_session( - upstream=upstream, - root_thought=root_thought, - process=process, - task=task, - task_id=task_id, - logger=logger, - ) - # prepare ghost logger - self._logger = logger - # 初始化 container 的相关绑定. - self._bootstrap_ghost_container() - # 检查所有必须绑定的对象. - self._validate_default_contracts() - - def _bootstrap_ghost_container(self): - # init shell - # storage provider - container = self._container - # init mindset - if not container.bound(Mindset): - mindset_provider = WorkspaceMindsetProvider() - container.register(mindset_provider) - - self._container.set(Ghost, self) - self._container.set(Shell, self._shell) - self._container.set(Session, self._session) - self._container.set(LoggerItf, self._logger) - self._container.set(Workspace, self._workspace) - self._container.set(EntityFactory, self._entity_factory) - - # register ghost self modules. - ghost_function_providers = { - MultiTask: self.multitasks, - Taskflow: self.taskflow, - Replier: self.replier, - MossCompiler: self.moss, - Utils: self.utils, - } - for contract, maker in ghost_function_providers.items(): - _maker = maker - self._container.register_maker(contract, _maker) - - # register session drivers: - session_function_providers = { - GoTasks: self._session.tasks, - GoProcesses: self._session.processes, - Messenger: self._session.messenger, - GoThreads: self._session.threads, - EventBus: self._session.eventbus, - } - for contract, maker in session_function_providers.items(): - _maker = maker - self._container.register_maker(contract, _maker) - - # register shell drivers - for driver in self._shell.drivers(): - _driver = driver - - def maker(c): - return self._shell.get_driver(_driver) - - provider = provide(driver, False)(maker) - self._container.register(provider) - - # register ghost providers - for provider in self.providers: - self._container.register(provider) - self._container.bootstrap() - - def make_session( - self, - logger: LoggerItf, - upstream: Stream, - process: GoProcess, - root_thought: Thought, - task: Optional[GoTaskStruct] = None, - task_id: Optional[str] = None, - ) -> Session: - container = self.container() - identifier = self.conf().identifier() - processes = container.force_fetch(GoProcesses) - tasks = container.force_fetch(GoTasks) - threads = container.force_fetch(GoThreads) - pool = container.force_fetch(Pool) - eventbus = container.force_fetch(EventBus) - # task and thread init. - if task is None: - if task_id is not None: - task = tasks.get_task(task_id, True) - if not task: - raise RuntimeError(f"Task {task_id} not found") - else: - task_id = process.main_task_id - task = tasks.get_task(task_id, False) - if not task: - identifier = root_thought.identifier() - meta = root_thought.to_entity_meta() - task = GoTaskStruct.new( - task_id=task_id, - shell_id=process.session_id, - process_id=process.process_id, - name=identifier.name, - description=identifier.description, - meta=meta, - ) - # 生成锁. - task.lock = tasks.refresh_task_lock(task.task_id, "") - logger = logger.with_trace({ - "process_id": process.process_id, - "session_id": process.session_id, - "task_id": task.task_id, - "thread_id": task.thread_id, - }) - thread = threads.get_thread(task.thread_id) - if thread is None: - thread = GoThreadInfo.new(None, thread_id=task.thread_id) - return BasicSession( - ghost_name=identifier.name, - ghost_role=self.role(), - upstream=upstream, - eventbus=eventbus, - pool=pool, - processes=processes, - tasks=tasks, - threads=threads, - logger=logger, - process=process, - task=task, - thread=thread, - ) - - @abstractmethod - def meta_prompt(self) -> str: - pass - - def mindset(self) -> "Mindset": - return self._container.force_fetch(Mindset) - - def modules(self) -> "Modules": - return self._container.force_fetch(Modules) - - def workspace(self) -> Workspace: - return self._workspace - - def configs(self) -> Configs: - return self._container.force_fetch(Configs) - - def entity_factory(self) -> EntityFactory: - return self._entity_factory - - def _validate_default_contracts(self): - for contract in self.ghost_contracts: - if not self._container.bound(contract): - raise NotImplementedError(f"Contract {contract} not bound to ghost container") - - @classmethod - def _validate_parent_container_contracts(cls, container: Container): - for contract in cls.depend_contracts: - if not container.bound(contract): - raise NotImplementedError(f"Contract {contract} not bound to the container") - - def on_inputs(self, inputs: Inputs) -> Optional["Event"]: - for pipe_type in self.inputs_pipes: - pipe = pipe_type(self) - inputs = pipe.intercept(inputs) - if inputs is None: - return None - task = self.session().task() - if task.is_new(): - event = EventTypes.CREATED.new( - task_id=self.session().task().task_id, - messages=inputs.messages, - ) - else: - event = EventTypes.ROTATE.new( - task_id=self.session().task().task_id, - messages=inputs.messages, - ) - return event - - def init_operator(self, event: "Event") -> Tuple["Operator", int]: - # set origin event - self._origin_event = event - return OnEventOperator(event), self._max_operator_runs - - def init_event(self) -> Optional["Event"]: - return self._origin_event - - def container(self) -> Container: - return self._container - - def session(self) -> Session: - return self._session - - def shell(self) -> "Shell": - return self._shell - - def root_thought(self) -> "Thought": - return self._root_thought - - def logger(self) -> "LoggerItf": - return self._logger - - def llms(self) -> LLMs: - return self._container.force_fetch(LLMs) - - def multitasks(self) -> "MultiTask": - return MultiTaskBasicImpl(self) - - def taskflow(self) -> "Taskflow": - return TaskflowBasicImpl() - - def replier(self) -> "Replier": - e = self.init_event() - event_from_task = e.from_task_id if e else None - task = self.session().task() - return ReplierImpl(task, event_from_task) - - def moss(self) -> "MossCompiler": - return MossCompilerImpl(container=self._container) - - def utils(self) -> "Utils": - return Utils(self) - - def trace(self) -> Dict[str, str]: - return self._make_trace(self._session, self._shell) - - def _make_trace(self, session: Session, shell: Shell) -> Dict: - session_id = session.id() - process_id = session.update_prompt().process_id - task_id = session.task().task_id - identifier = self.conf().identifier() - return { - "ghost_id": identifier.id, - "ghost_name": identifier.name, - "shell_id": shell.id(), - "session_id": session_id, - "process_id": process_id, - "task_id": task_id, - } - - def save(self) -> None: - self._logger.info(f"save ghost") - self._session.save() - - def done(self) -> None: - self._logger.info(f"ghost is done") - self._session.save() - self._session.done() - - def fail(self, err: Optional[Exception]) -> None: - self._logger.error(f"ghost run failed: {err}") - - def destroy(self) -> None: - self._container.destroy() - del self._container - del self._session - del self._logger - del self._shell diff --git a/ghostos/framework/ghosts/demo.py b/ghostos/framework/ghosts/demo.py deleted file mode 100644 index 42c6da21..00000000 --- a/ghostos/framework/ghosts/demo.py +++ /dev/null @@ -1,103 +0,0 @@ -from typing import Optional, List -from ghostos.identifier import Identifier -from ghostos.core.ghosts import GhostConf, Shell, Workspace -from ghostos.core.runtime import GoProcess, GoTaskStruct -from ghostos.contracts.modules import Modules -from ghostos.core.messages import Stream -from ghostos.framework.ghosts.basic import BasicGhost, InputsPipe -from ghostos.framework.streams import EmptyStream -from ghostos.framework.shells import EmptyShell -from ghostos.container import Container, Provider -from ghostos.entity import EntityMeta, EntityFactory -from ghostos.helpers import import_from_path -from pydantic import Field - -__all__ = ['DemoGhost', 'DemoGhostConf'] - - -class DemoGhostConf(GhostConf): - """ - configration of simple ghost implementation - """ - - id: str = Field(description="id of the ghost") - name: str = Field(description="name of the ghost") - description: str = Field(default="", description="description of the ghost") - - # prompt - meta_prompt: str = Field(description="raw meta prompt") - - # meta - thought_meta: EntityMeta = Field(description="root thought meta entity") - - # importing - input_pipes: List[str] = Field(default_factory=list, description="import path for input pipes") - providers: List[str] = Field(default_factory=list, description="import path for providers") - - # system conf - max_operators_run: int = Field(default=10, description="max operators run") - - def identifier(self) -> Identifier: - return Identifier( - id=self.id, - name=self.name, - description=self.description, - ) - - def root_thought_meta(self) -> EntityMeta: - return self.thought_meta - - -class DemoGhost(BasicGhost): - """ - simple implementation of a ghost - """ - - def __init__( - self, - conf: DemoGhostConf, - container: Container, - entity_factory: EntityFactory, - workspace: Workspace, - process: GoProcess, - upstream: Optional[Stream] = None, - shell: Optional[Shell] = None, - task: Optional[GoTaskStruct] = None, - task_id: Optional[str] = None, - ): - self._conf = conf - shell = shell if shell is None else EmptyShell() - upstream = upstream if upstream else EmptyStream() - modules = container.force_fetch(Modules) - - # importing - for provider_path in conf.providers: - provider = import_from_path(provider_path, modules.import_module) - if not isinstance(provider, Provider): - raise ValueError(f"provider {provider_path} is not an instance of {Provider}") - self.providers.append(provider) - - for input_pipe_path in conf.input_pipes: - pipe = import_from_path(input_pipe_path, modules.import_module) - if not issubclass(pipe, InputsPipe): - raise ValueError(f"pipe {input_pipe_path} is not an subclass of {InputsPipe}") - self.inputs_pipes.append(pipe) - - super().__init__( - conf=conf, - container=container, - shell=shell, - workspace=workspace, - entity_factory=entity_factory, - upstream=upstream, - process=process, - task=task, - task_id=task_id, - max_operator_runs=conf.max_operators_run, - ) - - def meta_prompt(self) -> str: - return self._conf.meta_prompt - - def conf(self) -> DemoGhostConf: - return self._conf diff --git a/ghostos/framework/libraries/__init__.py b/ghostos/framework/libraries/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/framework/libraries/auto_memory.py b/ghostos/framework/libraries/auto_memory.py deleted file mode 100644 index bb499de0..00000000 --- a/ghostos/framework/libraries/auto_memory.py +++ /dev/null @@ -1,141 +0,0 @@ -import os - -from abc import ABC, abstractmethod -from typing import List, Dict - -from pydantic import BaseModel, Field - - -class DBConfig(BaseModel): - path: str = Field(description="Path to the database file", default="memory.db") - - -class VectorDBConfig(BaseModel): - provider: str = Field(description="Provider of the vector store (e.g., 'qdrant', 'chroma')", default="qdrant") - - -class ProxyConfig(BaseModel): - proxy_url: str = Field(description="Proxy URL", default=None) - - -class TextMemory(ABC): - """ - TextMemory Storage enhances AI assistants and agents with an intelligent memory layer, enabling personalized AI interactions - """ - - def __init__(self, proxy_config: ProxyConfig): - if proxy_config and proxy_config.proxy_url: - os.environ['http_proxy'] = proxy_config.proxy_url - os.environ['https_proxy'] = proxy_config.proxy_url - - @abstractmethod - def add(self, data: str, agent_id=None, run_id=None, metadata=None, filters=None, prompt=None): - """ - Create a new memory. - - Args: - data (str): Data to store in the memory. - agent_id (str, optional): ID of the agent creating the memory. Defaults to None. - run_id (str, optional): ID of the run creating the memory. Defaults to None. - metadata (dict, optional): Metadata to store with the memory. Defaults to None. - filters (dict, optional): Filters to apply to the search. Defaults to None. - prompt (str, optional): Prompt to use for memory deduction. Defaults to None. - - Returns: None - """ - pass - - @abstractmethod - def search(self, query, agent_id=None, run_id=None, limit=100, filters=None) -> List[Dict]: - """ - Search for memories. - - Args: - query (str): Query to search for. - agent_id (str, optional): ID of the agent to search for. Defaults to None. - run_id (str, optional): ID of the run to search for. Defaults to None. - limit (int, optional): Limit the number of results. Defaults to 100. - filters (dict, optional): Filters to apply to the search. Defaults to None. - - Returns: - list: List of search results. - """ - pass - - @abstractmethod - def get(self, memory_id): - """ - Retrieve a memory by ID. - - Args: - memory_id (str): ID of the memory to retrieve. - - Returns: - dict: Retrieved memory. - """ - pass - - @abstractmethod - def get_all(self, agent_id=None, run_id=None, limit=100): - """ - List all memories. - - Returns: - list: List of all memories. - """ - pass - - @abstractmethod - def update(self, memory_id, data): - """ - Update a memory by ID. - - Args: - memory_id (str): ID of the memory to update. - data (dict): Data to update the memory with. - - Returns: - dict: Updated memory. - """ - pass - - @abstractmethod - def delete(self, memory_id): - """ - Delete a memory by ID. - - Args: - memory_id (str): ID of the memory to delete. - """ - pass - - @abstractmethod - def delete_all(self, agent_id=None, run_id=None): - """ - Delete all memories. - - Args: - agent_id (str, optional): ID of the agent to delete memories for. Defaults to None. - run_id (str, optional): ID of the run to delete memories for. Defaults to None. - """ - pass - - @abstractmethod - def history(self, memory_id): - """ - Get the history of changes for a memory by ID. - - Args: - memory_id (str): ID of the memory to get history for. - - Returns: - list: List of changes for the memory. - """ - pass - - @abstractmethod - def clear(self): - """ - Clear the memory store. - """ - pass diff --git a/ghostos/framework/logger/named.py b/ghostos/framework/logger/named.py index c9789b17..8dd27d3c 100644 --- a/ghostos/framework/logger/named.py +++ b/ghostos/framework/logger/named.py @@ -1,7 +1,7 @@ from typing import Optional, Type from ghostos.container import Provider, Container -from ghostos.contracts.logger import LoggerItf +from ghostos.contracts.logger import LoggerItf, get_console_logger import logging __all__ = ['NamedLoggerProvider'] diff --git a/ghostos/framework/mindsets/__init__.py b/ghostos/framework/mindsets/__init__.py deleted file mode 100644 index 718ad82d..00000000 --- a/ghostos/framework/mindsets/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ghostos.framework.mindsets.storage_impl import StorageMindsetProvider, WorkspaceMindsetProvider diff --git a/ghostos/framework/mindsets/storage_impl.py b/ghostos/framework/mindsets/storage_impl.py deleted file mode 100644 index 78ba0181..00000000 --- a/ghostos/framework/mindsets/storage_impl.py +++ /dev/null @@ -1,108 +0,0 @@ -from typing import Dict, Iterable, Type, Optional -import yaml -from ghostos.core.ghosts import Thought, ThoughtDriver, Mindset, Workspace, get_thought_driver_type -from ghostos.contracts.storage import Storage -from ghostos.contracts.modules import Modules -from ghostos.helpers import generate_import_path, import_from_path -from ghostos.container import Provider, Container - -__all__ = ['StorageMindset', 'StorageMindsetProvider', 'WorkspaceMindsetProvider'] - - -class StorageMindset(Mindset): - """ - 基于 storage 来实现. - """ - - def __init__(self, storage: Storage, modules: Modules, namespace: str): - self._modules = modules - self._storage = storage - self._cache_file = f"mindsets_{namespace}.cache.yml" - self._thought_driver_map: Dict[Type[Thought], Type[ThoughtDriver]] = {} - self._thought_path_driver_path_map: Dict[str, str] = {} - if self._storage.exists(self._cache_file): - content = storage.get(self._cache_file) - data = yaml.safe_load(content) - for key, val in data.items(): - if not isinstance(key, str) or not isinstance(val, str): - continue - self._thought_path_driver_path_map[key] = val - - def register_thought_type(self, cls: Type[Thought], driver: Optional[Type[ThoughtDriver]] = None) -> None: - if driver is None: - driver = get_thought_driver_type(cls) - self._thought_driver_map[cls] = driver - thought_type_path = generate_import_path(cls) - thought_driver_type_path = generate_import_path(driver) - self._thought_path_driver_path_map[thought_type_path] = thought_driver_type_path - self._save_map() - - def _save_map(self) -> None: - content = yaml.safe_dump(self._thought_path_driver_path_map) - self._storage.put(self._cache_file, content.encode('utf-8')) - - def get_thought_driver_type(self, thought_cls: Type[Thought]) -> Type[ThoughtDriver]: - if thought_cls in self._thought_driver_map: - return self._thought_driver_map.get(thought_cls) - thought_type_path = generate_import_path(thought_cls) - if thought_type_path in self._thought_path_driver_path_map: - driver_type_path = self._thought_path_driver_path_map[thought_type_path] - result = import_from_path(driver_type_path, self._modules.import_module) - if result is not None: - return result - return get_thought_driver_type(thought_cls) - - def thought_types(self) -> Iterable[Type[Thought]]: - done = set() - for thought_type in self._thought_driver_map: - thought_type_path = generate_import_path(thought_type) - done.add(thought_type_path) - yield thought_type - - for thought_type_path in self._thought_path_driver_path_map: - if thought_type_path not in done: - done.add(thought_type_path) - thought_type = import_from_path(thought_type_path, self._modules.import_module) - yield thought_type - - -class StorageMindsetProvider(Provider): - """ - mindset based by storage - """ - - def __init__(self, relative_path: str = "runtime/cache"): - self._relative_path = relative_path - - def singleton(self) -> bool: - return True - - def contract(self) -> Type[Mindset]: - return Mindset - - def factory(self, con: Container) -> Optional[Mindset]: - storage = con.force_fetch(Storage) - modules = con.force_fetch(Modules) - cache_storage = storage.sub_storage(self._relative_path) - return StorageMindset(cache_storage, modules, "") - - -class WorkspaceMindsetProvider(Provider[Mindset]): - """ - mindset based by workspace - """ - - def __init__(self, relative_path: str = "cache"): - self._relative_path = relative_path - - def singleton(self) -> bool: - return True - - def contract(self) -> Type[Mindset]: - return Mindset - - def factory(self, con: Container) -> Optional[Mindset]: - workspace = con.force_fetch(Workspace) - modules = con.force_fetch(Modules) - cache_storage = workspace.runtime().sub_storage(self._relative_path) - return StorageMindset(cache_storage, modules, "") diff --git a/ghostos/framework/multitasks/__init__.py b/ghostos/framework/multitasks/__init__.py deleted file mode 100644 index 710a6e75..00000000 --- a/ghostos/framework/multitasks/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ghostos.framework.multitasks.basic import MultiTaskBasicImpl diff --git a/ghostos/framework/multitasks/basic.py b/ghostos/framework/multitasks/basic.py deleted file mode 100644 index 6b3cbf05..00000000 --- a/ghostos/framework/multitasks/basic.py +++ /dev/null @@ -1,89 +0,0 @@ -from typing import Tuple -from ghostos.core.ghosts import MultiTask, Operator, Ghost, Thought, NewTask -from ghostos.core.llms import Prompt -from ghostos.core.messages import MessageKind, Role -from ghostos.core.runtime.events import EventTypes -from ghostos.framework.operators import WaitOnTasksOperator -from ghostos.helpers import yaml_pretty_dump - - -class MultiTaskBasicImpl(MultiTask): - - def __init__(self, ghost: Ghost): - self._ghost = ghost - - def update_prompt(self, chat: Prompt) -> Prompt: - children = self._ghost.session().get_task_briefs(children=True) - if not children: - return chat - prompt = """ -# MultiTask - -You are equipped with MultiTask library. You have created the async tasks below: -```yaml -{tasks} -``` -""" - data = [] - for task in children: - data.append(task.model_dump(exclude_defaults=True, exclude={"task_id"})) - tasks = yaml_pretty_dump(data) - content = prompt.format(tasks=tasks) - chat.system.append(Role.SYSTEM.new(content=content)) - return chat - - def wait_on_tasks(self, *new_tasks: Tuple[str, str, Thought, str]) -> Operator: - tasks = [] - for task in new_tasks: - task_name, task_desc, thought, instruction = task - tasks.append(NewTask( - task_name=task_name, - task_desc=task_desc, - thought=thought, - instruction=instruction, - )) - return WaitOnTasksOperator( - new_tasks=tasks, - ) - - def run_tasks(self, *new_tasks: Tuple[str, str, Thought, str]) -> None: - tasks = [] - for task in new_tasks: - task_name, task_desc, thought, instruction = task - tasks.append(NewTask( - task_name=task_name, - task_desc=task_desc, - thought=thought, - instruction=instruction, - )) - self._ghost.utils().create_child_tasks(depend=False, new_tasks=tasks) - - def send_task(self, task_name: str, *messages: MessageKind) -> None: - messages = list(messages) - if not messages: - return - session = self._ghost.session() - from_task_id = session.task().task_id - tasks = session.get_task_briefs(children=True) - for task in tasks: - if task.name == task_name: - event = EventTypes.ROTATE.new( - task_id=task.id, - from_task_id=from_task_id, - messages=messages, - ) - session.fire_events(event) - - def cancel_task(self, task_name: str, reason: str) -> None: - session = self._ghost.session() - from_task_id = session.task().task_id - tasks = session.get_task_briefs(children=True) - for task in tasks: - if task.name == task_name: - event = EventTypes.CANCEL.new( - task_id=task.id, - from_task_id=from_task_id, - messages=[], - reason=reason, - ) - session.fire_events(event) diff --git a/ghostos/framework/operators/__init__.py b/ghostos/framework/operators/__init__.py deleted file mode 100644 index bed12ddf..00000000 --- a/ghostos/framework/operators/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -from ghostos.framework.operators.action_ops import ( - WaitsOperator, - FailOperator, - FinishOperator, - ThinkOperator, - WaitOnTasksOperator, -) -from ghostos.framework.operators.event_ops import ( - # 统一的事件状态机. - OnEventOperator, - - # 上游相关事件. - OnUpstreamEventOperator, - OnInputOperator, - OnCancelingOperator, - OnCreatedOperator, - - # 自身的事件. - OnSelfEventOperator, - OnObserveOperator, - - # 下游的 callback 事件. - OnCallbackEventOperator, - OnFinishCallbackOperator, - OnNotifyCallbackOperator, - OnWaitCallbackOperator, - OnFailureCallbackOperator, -) diff --git a/ghostos/framework/operators/action_ops.py b/ghostos/framework/operators/action_ops.py deleted file mode 100644 index abcba00b..00000000 --- a/ghostos/framework/operators/action_ops.py +++ /dev/null @@ -1,268 +0,0 @@ -from typing import Optional, List, ClassVar - -from ghostos.core.ghosts import ( - Operator, Ghost, NewTask, -) -from ghostos.core.messages import ( - MessageKind, MessageKindParser, Role, -) -from ghostos.core.runtime import ( - EventTypes, - TaskState, - GoTaskStruct, -) - -__all__ = [ - 'ActionOperator', - 'WaitsOperator', - 'FailOperator', - 'FinishOperator', - 'ThinkOperator', - 'WaitOnTasksOperator', -] - - -class ActionOperator(Operator): - """ - 当前任务进入等待状态. 需要执行的流程: - 1. 发送存在的消息. - 2. 更新当前 task 的状态, 添加日志. - 3. 如果父任务存在, 向父任务发送消息. - """ - task_state: ClassVar[str] = TaskState.WAITING.value - callback_event_type: ClassVar[str] = EventTypes.WAIT_CALLBACK.value - - def __init__( - self, *, - messages: List[MessageKind], - reason: str = "", - instruction: str = "", - callback_task_id: Optional[str] = None, - ): - self.reason = reason - self.instruction = instruction - self.messages = messages - self.callback_task_id = callback_task_id - - def send_replies(self, g: "Ghost") -> None: - if self.messages: - session = g.session() - self.messages = session.send_messages(*self.messages) - - def get_callback_task_id(self, task: GoTaskStruct) -> Optional[str]: - if self.callback_task_id is not None: - return self.callback_task_id - return task.parent - - def change_task_state(self, g: "Ghost") -> None: - session = g.session() - task = session.task() - thread = session.thread() - task.state = self.task_state - if self.reason: - task.logs.append(f"{self.task_state}: {self.reason}") - # 状态判断. - # 消息不为空的时候才发送. - callbacks = self.messages - callback_task_id = self.get_callback_task_id(task) - if callback_task_id: - utils = g.utils() - # 发送消息给父任务. - utils.send_task_event( - task_id=callback_task_id, - event_type=self.callback_event_type, - messages=callbacks, - reason=self.reason, - self_task=task, - ) - - # 更新当前 session, 主要更新 thread 的状态. - session.update_task(task, thread, True) - - def send_children_events(self, g: "Ghost") -> None: - return - - def next_operator(self, g: "Ghost") -> Optional[Operator]: - return None - - def run(self, g: "Ghost") -> Optional["Operator"]: - self.send_replies(g) - self.send_children_events(g) - self.change_task_state(g) - self.send_children_events(g) - return self.next_operator(g) - - def destroy(self) -> None: - del self.messages - - -class WaitsOperator(ActionOperator): - task_state: ClassVar[str] = TaskState.WAITING.value - callback_event_type: ClassVar[str] = EventTypes.WAIT_CALLBACK.value - - def __init__(self, *, reason: str, messages: List[MessageKind], callback_task_id: Optional[str] = None): - super().__init__(reason=reason, messages=messages, callback_task_id=callback_task_id) - - -class FailOperator(ActionOperator): - """ - 结束当前任务. - 1. 通过 session 发送消息, 同时消息保存到 Thread 里. - 2. 变更当前 Task 的状态, 并保存. - 3. 反馈 fail_callback 事件给父 task, 如果存在的话. - 4. 取消未完成的子任务. 向它们发送取消事件. - 5. 自己继续执行 on_finished 事件, 可以创建独立的任务去理解. - """ - task_state: ClassVar[str] = TaskState.FAILED.value - callback_event_type: ClassVar[str] = EventTypes.FAILURE_CALLBACK.value - - def __init__( - self, *, - reason: str, - messages: List[MessageKind], - callback_task_id: Optional[str] = None, - ): - super().__init__(reason=reason, messages=messages, callback_task_id=callback_task_id) - - def format_cancel_children_reason(self) -> str: - if not self.reason: - return "" - return f"task is canceled cause parent task is failed: {self.reason}" - - def send_children_events(self, g: "Ghost") -> None: - # 取消所有的子任务. - reason = self.format_cancel_children_reason() - utils = g.utils() - utils.cancel_children_tasks(reason=reason, includes=None) - - def next_operator(self, g: "Ghost") -> Optional[Operator]: - session = g.session() - task = session.task() - # finish 没有后续. 但还是要执行一个反思事件. - event = EventTypes.FAILED.new( - task_id=task.task_id, - messages=[], - ) - # 立刻执行 on_finished 事件, - return g.utils().handle_event(event) - - -class FinishOperator(ActionOperator): - """ - 结束当前任务. - 1. 通过 session 发送消息, 同时消息保存到 Thread 里. - 2. 变更当前 Task 的状态, 并保存. - 3. 反馈 finish_callback 事件给父 task, 如果存在的话. - 4. 取消未完成的子任务. 向它们发送取消事件. - 5. 自己继续执行 on_finished 事件, 可以创建独立的任务去理解. - """ - task_state: ClassVar[str] = TaskState.FINISHED.value - callback_event_type: ClassVar[str] = EventTypes.FINISH_CALLBACK.value - - def __init__( - self, *, - reason: str, - messages: List[MessageKind], - ): - super().__init__( - messages=messages, - reason=reason, - ) - - def format_cancel_children_reason(self) -> str: - if not self.reason: - return "" - return f"task is canceled cause parent task is finished: {self.reason}" - - def send_children_events(self, g: "Ghost") -> None: - utils = g.utils() - reason = self.format_cancel_children_reason() - utils.cancel_children_tasks(reason=reason, includes=None) - - def next_operator(self, g: "Ghost") -> Optional[Operator]: - # finish 没有后续. 但还是要执行一个反思事件. - session = g.session() - task = session.task() - event = EventTypes.FINISHED.new( - task_id=task.task_id, - messages=[], - ) - - # 立刻执行 on_finished 事件, - return g.utils().handle_event(event) - - -class WaitOnTasksOperator(ActionOperator): - """ - wait on children tasks - """ - task_state: ClassVar[str] = TaskState.RUNNING.value - callback_event_type: ClassVar[str] = EventTypes.NOTIFY.value - - def __init__( - self, *, - new_tasks: List[NewTask], - ): - for item in new_tasks: - if not isinstance(item, NewTask): - raise TypeError(f'new_tasks must be a NewTask instance, got {type(item)}') - self.new_tasks = new_tasks - super().__init__( - messages=[], - reason="", - instruction="", - ) - - def send_children_events(self, g: "Ghost") -> None: - g.utils().create_child_tasks( - depend=True, - new_tasks=self.new_tasks, - ) - - def destroy(self) -> None: - del self.new_tasks - del self.messages - - -class ThinkOperator(ActionOperator): - """ - 运行下一轮思考. - """ - task_state: ClassVar[str] = TaskState.RUNNING.value - callback_event_type: ClassVar[str] = EventTypes.NOTIFY.value - - def __init__( - self, *, - reason: str, - instruction: str, - observation: List[MessageKind], - caller_id: str = "", - ): - self.observation = observation - self.caller_id = caller_id - super().__init__( - messages=[], - reason=reason, - instruction=instruction, - ) - - def next_operator(self, g: "Ghost") -> Optional["Operator"]: - observations = [] - if self.observation: - parser = MessageKindParser(role=Role.TOOL, ref_id=self.caller_id) - observations = parser.parse(self.observation) - task = g.session().task() - # 执行下一轮思考. - utils = g.utils() - utils.send_task_event( - task_id=task.task_id, - event_type=EventTypes.ROTATE.value, - reason=self.reason, - instruction=self.instruction, - messages=observations, - self_task=task, - ) - return None - - def destroy(self) -> None: - del self.observation diff --git a/ghostos/framework/operators/event_ops.py b/ghostos/framework/operators/event_ops.py deleted file mode 100644 index 21975034..00000000 --- a/ghostos/framework/operators/event_ops.py +++ /dev/null @@ -1,253 +0,0 @@ -from typing import Optional, ClassVar - -from ghostos.core.ghosts import ( - EventOperator, Ghost, Operator, get_event_operator -) -from ghostos.core.runtime import ( - TaskState, - EventTypes, -) - -__all__ = [ - 'OnEventOperator', - - # 上游相关事件. - 'OnUpstreamEventOperator', - 'OnInputOperator', - 'OnCancelingOperator', - 'OnCreatedOperator', - - # 自身的事件. - 'OnSelfEventOperator', - 'OnObserveOperator', - - # 下游的 callback 事件. - 'OnCallbackEventOperator', - 'OnFinishCallbackOperator', - 'OnNotifyCallbackOperator', - 'OnWaitCallbackOperator', - 'OnFailureCallbackOperator', -] - - -class OnEventOperator(EventOperator): - """ - 标准的事件状态机. 区分上游事件, 自身事件, 和下游 callback 事件. - """ - event_type: ClassVar[str] = "" - - def run(self, g: "Ghost") -> Optional["Operator"]: - task = g.session().task() - if self.event.from_self(): - op = get_event_operator( - { - "": OnSelfEventOperator, - OnObserveOperator.event_type: OnObserveOperator, - }, - self.event, - ) - return op - elif self.event.callback or self.event.from_task_id in task.children: - op = get_event_operator( - { - "": OnCallbackEventOperator, - OnFinishCallbackOperator.event_type: OnFinishCallbackOperator, - OnFailureCallbackOperator.event_type: OnFailureCallbackOperator, - OnWaitCallbackOperator.event_type: OnWaitCallbackOperator, - OnNotifyCallbackOperator.event_type: OnNotifyCallbackOperator, - }, - self.event, - ) - return op - else: - op = get_event_operator( - { - "": OnUpstreamEventOperator, - OnCancelingOperator.event_type: OnCancelingOperator, - OnInputOperator.event_type: OnInputOperator, - OnCreatedOperator.event_type: OnCreatedOperator, - }, - self.event, - ) - return op - - -class OnUpstreamEventOperator(EventOperator): - """ - 上游事件的默认处理逻辑. - """ - - event_type: ClassVar[str] = "" - default_state: ClassVar[str] = TaskState.WAITING.value - - def handle_event(self, g: "Ghost") -> Optional["Operator"]: - session = g.session() - task = session.task() - # 思考轮次设置为 0. - # task.think_turns = 0 - thread = session.thread() - thread.new_turn(self.event) - session.update_task(task, thread, update_history=False) - return g.utils().handle_event(self.event) - - def default_action(self, g: "Ghost") -> Optional["Operator"]: - session = g.session() - task = session.task() - # 默认 await. - task.state = self.default_state - session.update_task(task, None, update_history=True) - return None - - def run(self, g: "Ghost") -> Optional["Operator"]: - op = self.handle_event(g) - if op is not None: - return op - return self.default_action(g) - - -class OnSelfEventOperator(EventOperator): - """ - 自己触发的事件的处理逻辑. - """ - event_type: ClassVar[str] = "" - default_state: ClassVar[str] = TaskState.WAITING.value - - def handle_event(self, g: "Ghost") -> Optional["Operator"]: - session = g.session() - task = session.task() - # 思考轮次设置为 0. - # task.think_turns += 1 - thread = session.thread() - thread.new_turn(self.event) - if task.think_too_much(): - session.update_task(task, thread, update_history=True) - # 不再运行思考. 只是追加信息. 必须等待上游的输入才能继续运行. - # todo: 是否要通知上游, ask to continue. - return None - session.update_task(task, thread, update_history=False) - return g.utils().handle_event(self.event) - - def default_action(self, g: "Ghost") -> Optional["Operator"]: - session = g.session() - task = session.task() - # 默认 await. - task.state = self.default_state - session.update_task(task, None, update_history=True) - return None - - def run(self, g: "Ghost") -> Optional["Operator"]: - op = self.handle_event(g) - if op is not None: - return op - return self.default_action(g) - - -class OnCallbackEventOperator(EventOperator): - """ - 子任务触发的事件. - """ - event_type: ClassVar[str] = "" - - def receive_event(self, g: "Ghost") -> bool: - session = g.session() - task = session.task() - # 思考轮次设置为 0. - thread = session.thread() - thread.new_turn(self.event) - session.update_task(task, thread, update_history=False) - return task.is_dead() - - def handle_event(self, g: "Ghost") -> Optional["Operator"]: - return g.utils().handle_event(self.event) - - def run(self, g: "Ghost") -> Optional["Operator"]: - # 接受事件. - dead = self.receive_event(g) - if dead: - # 不处理逻辑了. - return None - # 处理事件. - op = self.handle_event(g) - if op is not None: - return op - # 不变更任何状态. - return None - - -# --- upstream event operators --- # - -class OnInputOperator(OnUpstreamEventOperator): - """ - 接受到上游的输入. - """ - event_type: ClassVar[str] = EventTypes.ROTATE.value - default_state: ClassVar[str] = TaskState.WAITING.value - - -class OnCreatedOperator(OnUpstreamEventOperator): - """ - 接受到创建任务的消息. - """ - event_type: ClassVar[str] = EventTypes.CREATED.value - default_state: ClassVar[str] = TaskState.WAITING.value - - -class OnCancelingOperator(OnUpstreamEventOperator): - event_type = EventTypes.CANCEL.value - default_state: ClassVar[str] = TaskState.CANCELLED.value - - def handle_event(self, g: "Ghost") -> Optional["Operator"]: - session = g.session() - task = session.task() - if task.is_dead(): - # 不做额外处理. - return None - return super().handle_event(g) - - def default_action(self, g: "Ghost") -> Optional["Operator"]: - session = g.session() - task = session.task() - # 取消所有的子任务. - if task.children: - g.utils().cancel_children_tasks( - reason=f"parent task {task.task_id} is canceled", - self_task=task, - ) - return super().default_action(g) - - -# --- self event operators --- # - -class OnObserveOperator(OnSelfEventOperator): - event_type: ClassVar[str] = EventTypes.ROTATE.value - default_state: ClassVar[str] = TaskState.WAITING.value - - -# --- call back operators --- # - -class OnFinishCallbackOperator(OnCallbackEventOperator): - event_type: ClassVar[str] = EventTypes.FINISH_CALLBACK - - def handle_event(self, g: "Ghost") -> Optional["Operator"]: - session = g.session() - task = session.task() - from_task_id = self.event.from_task_id - group = None - if from_task_id: - group = task.on_callback_task(from_task_id) - session.update_task(task, None, update_history=False) - if group is not None: - return g.utils().handle_event(self.event) - return None - - -class OnFailureCallbackOperator(OnCallbackEventOperator): - event_type: ClassVar[str] = EventTypes.FAILURE_CALLBACK - - -class OnWaitCallbackOperator(OnCallbackEventOperator): - event_type: ClassVar[str] = EventTypes.WAIT_CALLBACK - - -class OnNotifyCallbackOperator(OnCallbackEventOperator): - event_type: ClassVar[str] = EventTypes.NOTIFY diff --git a/ghostos/framework/repliers/__init__.py b/ghostos/framework/repliers/__init__.py deleted file mode 100644 index 0c9f8827..00000000 --- a/ghostos/framework/repliers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ghostos.framework.repliers.basic import ReplierImpl \ No newline at end of file diff --git a/ghostos/framework/repliers/basic.py b/ghostos/framework/repliers/basic.py deleted file mode 100644 index 634c63a9..00000000 --- a/ghostos/framework/repliers/basic.py +++ /dev/null @@ -1,71 +0,0 @@ -from typing import Optional, Dict, Any - -from ghostos.core.ghosts import Operator -from ghostos.core.ghosts.schedulers import Replier -from ghostos.core.messages import Role -from ghostos.core.runtime import GoTaskStruct -from ghostos.framework.operators import WaitsOperator, ThinkOperator, FinishOperator -from ghostos.helpers import yaml_pretty_dump - - -class ReplierImpl(Replier): - - def __init__(self, task: GoTaskStruct, event_from_task: Optional[str] = None): - callback_task_id = task.parent - if event_from_task and event_from_task != task.task_id: - callback_task_id = event_from_task - self.callback_task_id = callback_task_id - - def finish(self, reply: str) -> Operator: - if not reply: - raise AttributeError(f'finish reply shall not be empty ') - return FinishOperator( - reason="", - messages=[reply], - ) - - def reply(self, content: str) -> Operator: - if not content: - raise ValueError("reply Content cannot be empty") - return WaitsOperator( - reason="", - messages=[content], - callback_task_id=self.callback_task_id, - ) - - def ask_clarification(self, question: str) -> Operator: - if not question: - raise ValueError("ask clarification question cannot be empty") - return WaitsOperator( - reason="", - messages=[question], - callback_task_id=self.callback_task_id, - ) - - def fail(self, reply: str) -> Operator: - if not reply: - raise ValueError("fail reply cannot be empty") - return WaitsOperator( - reason="", - messages=[reply], - callback_task_id=self.callback_task_id, - ) - - def think(self, observations: Optional[Dict[str, Any]] = None, instruction: str = "") -> Operator: - messages = [] - if observations: - values = {name: str(value) for name, value in observations.items()} - content = yaml_pretty_dump(values) - - # 用什么协议没想明白, function ? tool? system ? - content = "# observe values: \n" + content - msg = Role.new_system( - content=content, - ) - messages.append(msg) - - return ThinkOperator( - observation=messages, - reason="", - instruction=instruction, - ) diff --git a/ghostos/framework/runtimes/__init__.py b/ghostos/framework/runtimes/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/framework/shells/__init__.py b/ghostos/framework/shells/__init__.py deleted file mode 100644 index 63107798..00000000 --- a/ghostos/framework/shells/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from ghostos.framework.shells.basic import BasicShell -from ghostos.framework.shells.empty import EmptyShell \ No newline at end of file diff --git a/ghostos/framework/shells/basic.py b/ghostos/framework/shells/basic.py deleted file mode 100644 index 13d339b1..00000000 --- a/ghostos/framework/shells/basic.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Type, Iterable, List, Tuple - -from ghostos.container import INSTANCE, ABSTRACT -from ghostos.core.ghosts import Action -from ghostos.core.ghosts.shells import Shell - - -class BasicShell(Shell): - """ - A shell implementation that almost do nothing important. - just for testing. - """ - - def __init__( - self, *, - shell_id: str, - prompt: str, - actions: List[Action], - drivers: List[Tuple[Type, object]] - ): - self._id = shell_id - self._actions = actions - self._prompt = prompt - self._drivers = {t: i for t, i in drivers} - - def id(self) -> str: - return self._id - - def status_description(self) -> str: - return self._prompt - - def actions(self) -> Iterable[Action]: - return self._actions - - def drivers(self) -> Iterable[ABSTRACT]: - for driver in self._drivers: - yield driver - - def get_driver(self, driver: ABSTRACT) -> INSTANCE: - if driver not in self._drivers: - raise KeyError(f"Driver {driver} not supported") - return self._drivers[driver] diff --git a/ghostos/framework/shells/empty.py b/ghostos/framework/shells/empty.py deleted file mode 100644 index 7c2b53e9..00000000 --- a/ghostos/framework/shells/empty.py +++ /dev/null @@ -1,12 +0,0 @@ -from ghostos.framework.shells.basic import BasicShell - - -class EmptyShell(BasicShell): - - def __init__(self): - super().__init__( - shell_id="empty_shell", - prompt="", - actions=[], - drivers=[] - ) diff --git a/ghostos/identifier.py b/ghostos/identifier.py index 76268132..ee9ff54a 100644 --- a/ghostos/identifier.py +++ b/ghostos/identifier.py @@ -38,7 +38,7 @@ def try_get_identifier(value: Any, throw: bool = False) -> Union[Identifier, Non return value # explicit identifiable object elif isinstance(value, Identical): - return value.identifier() + return value.__identifier__() # explicit identifiable class elif issubclass(value, IdenticalClass): return value.class_identifier() @@ -115,7 +115,7 @@ class Identical(ABC): """ @abstractmethod - def identifier(self) -> Identifier: + def __identifier__(self) -> Identifier: pass diff --git a/ghostos/prototypes/ghostfunc/prepare.py b/ghostos/prototypes/ghostfunc/prepare.py index 1eebe84d..5c3d1679 100644 --- a/ghostos/prototypes/ghostfunc/prepare.py +++ b/ghostos/prototypes/ghostfunc/prepare.py @@ -45,4 +45,6 @@ def init_ghost_func( :param container: application container. """ ghost_func_contracts.validate(container) - return GhostFunc(container) + self_container = Container(parent=container) + self_container.bootstrap() + return GhostFunc(self_container) diff --git a/ghostos/scripts/aifunc_test.py b/ghostos/scripts/aifunc_test.py deleted file mode 100644 index 9cada308..00000000 --- a/ghostos/scripts/aifunc_test.py +++ /dev/null @@ -1,154 +0,0 @@ -import argparse -import sys -import os -import yaml -from typing import List, Dict - -from ghostos.core.runtime import GoThreadInfo -from ghostos.scripts.logconf import prepare_logger -from ghostos.core.llms import Prompt -from ghostos.core.messages import Message -from ghostos.core.moss import moss_container -from ghostos.core.aifunc import DefaultAIFuncExecutorImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncExecutor -from ghostos.framework.logger import NamedLoggerProvider -from ghostos.framework.storage import FileStorageProvider -from ghostos.framework.llms import ConfigBasedLLMsProvider -from ghostos.framework.threads import MsgThreadRepoByStorageProvider -from ghostos.container import Container -from ghostos.contracts.modules import Modules -from ghostos.contracts.storage import Storage -from ghostos.framework.configs import ConfigsByStorageProvider -from ghostos.helpers import import_from_path, yaml_pretty_dump -from rich.console import Console -from rich.panel import Panel -from rich.markdown import Markdown -from rich.prompt import Prompt - -console = Console() - -prepare_logger() - - -def prepare_container(root_dir: str) -> Container: - container = moss_container() - container.register(FileStorageProvider(root_dir)) - container.register(NamedLoggerProvider(logger_name="debug")) - container.register(MsgThreadRepoByStorageProvider(threads_dir='runtime/threads')) - container.register(ConfigsByStorageProvider("configs")) - container.register(ConfigBasedLLMsProvider("llms_conf.yml")) - return container - - -def main() -> None: - parser = argparse.ArgumentParser( - description="run ghostos aifunc test cases, show results", - ) - parser.add_argument( - "--case", '-c', - help="ghostos aifunc test case name in demo/tests/aifunc_tests.yml", - type=str, - default="swe_bench_lite", - ) - parser.add_argument( - "--import_path", '-i', - help="the import path of the AIFunc instance, such as foo.bar:baz", - type=str, - # 默认使用专门测试 MossTestSuite 的文件. - # default="ghostos.core.aifunc.examples.agentic:example", - default="", - ) - parser.add_argument( - "--llm_api", '-l', - help="the llm api name", - type=str, - # 默认使用专门测试 MossTestSuite 的文件. - default="", - ) - parser.add_argument( - "--auto", '-a', - help="auto run the test or stop at each generations", - action="store_true", - # 默认使用专门测试 MossTestSuite 的文件. - default=True, - ) - - parsed = parser.parse_args(sys.argv[1:]) - llm_api = parsed.llm_api - demo_dir = os.path.abspath(os.path.dirname(__file__) + "/../../demo") - container = prepare_container(demo_dir) - import_path = parsed.import_path - if parsed.case: - storage = container.force_fetch(Storage) - cases_content_file = storage.get("tests/aifunc_tests.yml") - cases: Dict[str, str] = yaml.safe_load(cases_content_file) - import_path = cases.get(parsed.case, import_path) - if not import_path: - raise Exception("no aifunc test cases found. use -c or -i to specify aifunc test case") - - class TestDriverImpl(DefaultAIFuncDriverImpl): - console = console - - def on_message(self, message: Message) -> None: - self.console.print( - Panel( - Markdown(message.get_content()), - title=f"generated message ({self.name()})", - ) - ) - if not parsed.auto: - value = Prompt.ask("Continue?", choices=["y", "n"], default="y") - if value != "y": - exit(0) - - def on_chat(self, chat: Prompt) -> None: - for message in chat.get_messages(): - self.console.print(Panel( - Markdown(message.get_content()), - title=f"chat_info ({self.name()})", - )) - if not parsed.auto: - value = Prompt.ask("Continue?", choices=["y", "n"], default="y") - if value != "y": - exit(0) - - def on_system_messages(self, messages: List[Message]) -> None: - pass - - def on_save(self, manager: AIFuncExecutor, thread: GoThreadInfo) -> None: - current = thread.current - if current: - for message in current.messages(): - self.console.print( - Panel( - Markdown(message.get_content()), - title="thread new round message", - ) - ) - super().on_save(manager, thread) - - manager_ = DefaultAIFuncExecutorImpl( - container=container, - llm_api_name=llm_api, - default_driver=TestDriverImpl, - ) - modules = container.force_fetch(Modules) - aifunc = import_from_path(import_path, modules.import_module) - if not isinstance(aifunc, AIFunc): - raise AttributeError(f'aifunc must be an instance of {AIFunc}, {aifunc} given') - - driver = manager_.get_driver(aifunc) - # print initialized thread. - thread_ = driver.initialize() - thread_content = yaml_pretty_dump(thread_.model_dump(exclude_defaults=True)) - console.print(Panel( - Markdown(f"```markdown\n{thread_content}\n```"), - title="initialized thread", - )) - - result = manager_.execute(aifunc) - console.print(result) - manager_.destroy() - - -if __name__ == "__main__": - main() diff --git a/ghostos/scripts/demo.py b/ghostos/scripts/demo.py deleted file mode 100644 index 8d357ecd..00000000 --- a/ghostos/scripts/demo.py +++ /dev/null @@ -1,44 +0,0 @@ -import argparse -import sys -from ghostos.prototypes.console import demo_console_app - - -def main() -> None: - parser = argparse.ArgumentParser( - description="run ghostos demo in console", - ) - parser.add_argument( - "--ghost-id", '-g', - help="ghost_id in demo/configs/ghosts.yml", - type=str, - default="baseline", - ) - parser.add_argument( - "--debug", "-d", - help="debug mode", - action="store_true", - default=False, - ) - parser.add_argument( - "--username", '-u', - help="username", - type=str, - default="BrightRed", - ) - parser.add_argument( - "--session-id", '-s', - help="session id", - type=str, - default=None, - ) - parsed = parser.parse_args(sys.argv[1:]) - demo_console_app.run_console( - ghost_id=parsed.ghost_id, - debug=parsed.debug, - username=parsed.username, - session_id=parsed.session_id, - ) - - -if __name__ == "__main__": - main() diff --git a/ghostos/scripts/logconf.py b/ghostos/scripts/logconf.py deleted file mode 100644 index 37259041..00000000 --- a/ghostos/scripts/logconf.py +++ /dev/null @@ -1,13 +0,0 @@ -import os -import yaml -from logging.config import dictConfig - - -def prepare_logger(): - demo_dir = os.path.abspath(os.path.dirname(__file__) + "/../../demo") - conf_path = os.path.join(demo_dir, "ghostos/configs/logging.yaml") - conf_path = os.path.abspath(conf_path) - with open(conf_path) as f: - content = f.read() - data = yaml.safe_load(content) - dictConfig(data) diff --git a/ghostos/thoughts/basic.py b/ghostos/thoughts/basic.py index 878289e1..63619874 100644 --- a/ghostos/thoughts/basic.py +++ b/ghostos/thoughts/basic.py @@ -70,7 +70,7 @@ def think(self, g: Ghost, e: Event) -> Optional[Operator]: # prepare actions actions = list(self.actions(g, e)) - action_map = {action.identifier().name: action for action in actions} + action_map = {action.__identifier__().name: action for action in actions} # prepare chat by actions for action in actions: diff --git a/tests/python/test_pydantic.py b/tests/python/test_pydantic.py index 4b54f3cc..d84ce6d4 100644 --- a/tests/python/test_pydantic.py +++ b/tests/python/test_pydantic.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field from pydantic.errors import PydanticSchemaGenerationError -from typing import TypedDict, Required, Iterable, List, Optional +from typing import TypedDict, Required, Iterable, List, Optional, Protocol from typing_extensions import Literal From 98d50e4956d8d7fd3b02b05a8b0aa60d1eabc717 Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 15 Nov 2024 15:20:55 +0800 Subject: [PATCH 079/148] test: test about moss runtime gc and success with joy --- ghostos/bootstrap.py | 6 +++++ ghostos/container.py | 9 +++++-- ghostos/core/moss/abcd.py | 4 ++- ghostos/core/moss/impl.py | 7 ++--- tests/core/moss/examples/test_baseline.py | 33 +++++++++++++++++++++-- tests/test_container.py | 6 +++++ 6 files changed, 57 insertions(+), 8 deletions(-) diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index a645c5ee..3998a7c0 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -73,8 +73,13 @@ 'application_dir', 'default_application_contracts', 'default_application_providers', + + 'GHOSTOS_VERSION', ] +GHOSTOS_VERSION_KEY = "ghostos_version" +GHOSTOS_VERSION = "0.1.0" + # --- prepare application paths --- # @@ -258,6 +263,7 @@ def make_app_container( # contracts validation app_contracts.validate(_container) # bootstrap. + _container.set(GHOSTOS_VERSION_KEY, GHOSTOS_VERSION) _container.bootstrap() return _container diff --git a/ghostos/container.py b/ghostos/container.py index e30a745d..7d6cc606 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -2,7 +2,7 @@ import inspect from abc import ABCMeta, abstractmethod from typing import Type, Dict, TypeVar, Callable, Set, Optional, List, Generic, Any, Union, Iterable -from typing import get_args, get_origin +from typing import get_args, get_origin, ClassVar import warnings __all__ = [ @@ -137,6 +137,7 @@ class Container(IoCContainer): - 对于 MOSS 而言, Container 也是必要的. 这样可以只把 interface 暴露给 LLM, 但又可以让它使用实例. - 仍然需要考虑加入 RAG Memories 来支持. 获取做到 OS 层. """ + instance_count: ClassVar[int] = 0 def __init__(self, parent: Optional[Container] = None): # container extended by children container @@ -157,6 +158,10 @@ def __init__(self, parent: Optional[Container] = None): self._aliases: Dict[Any, Any] = {} self._destroyed: bool = False self._shutdown: List[Callable[[], None]] = [] + Container.instance_count += 1 + + def __del__(self): + Container.instance_count -= 1 def bootstrap(self) -> None: """ @@ -198,7 +203,7 @@ def bound(self, contract: Type) -> bool: self._check_destroyed() return contract in self._bound or (self.parent is not None and self.parent.bound(contract)) - def get(self, abstract: Union[Type[INSTANCE]]) -> Optional[INSTANCE]: + def get(self, abstract: Union[Type[INSTANCE], Any]) -> Optional[INSTANCE]: """ get bound instance or initialize one from registered factory or provider. diff --git a/ghostos/core/moss/abcd.py b/ghostos/core/moss/abcd.py index 90fb7602..ef56fb72 100644 --- a/ghostos/core/moss/abcd.py +++ b/ghostos/core/moss/abcd.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Dict, Any, Union, List, Optional, NamedTuple, Type, Callable, Self, TypeVar +from typing import Dict, Any, Union, List, Optional, NamedTuple, Type, Callable, Self, TypeVar, ClassVar from types import ModuleType from abc import ABC, abstractmethod from ghostos.container import Container, Provider, Factory, provide @@ -352,6 +352,8 @@ def moss_injections_prompt(self) -> str: class MossRuntime(ABC): + instance_count: ClassVar[int] = 0 + @abstractmethod def container(self) -> Container: """ diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index 579b664b..5c486032 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -175,6 +175,10 @@ def __init__( self._destroyed: bool = False self._injected = set() self._moss: Moss = self._compile_moss() + MossRuntime.instance_count += 1 + + def __del__(self): + MossRuntime.instance_count -= 1 def _compile_moss(self) -> Moss: moss_type = self.moss_type() @@ -330,9 +334,6 @@ def destroy(self) -> None: del self._moss del self._pycontext - def __del__(self): - self.destroy() - class DefaultMOSSProvider(Provider[MossCompiler]): """ diff --git a/tests/core/moss/examples/test_baseline.py b/tests/core/moss/examples/test_baseline.py index 80fb67c2..b662abce 100644 --- a/tests/core/moss/examples/test_baseline.py +++ b/tests/core/moss/examples/test_baseline.py @@ -1,8 +1,9 @@ -from ghostos.core.moss import moss_container, pycontext -from ghostos.core.moss.abcd import MossCompiler, Moss, MOSS_TYPE_NAME +from ghostos.core.moss import moss_container +from ghostos.core.moss.abcd import MossCompiler, Moss, MOSS_TYPE_NAME, MossRuntime from ghostos.core.moss.pycontext import PyContext from ghostos.core.moss.examples import baseline from ghostos.contracts.modules import ImportWrapper +from ghostos.container import Container def test_baseline_exec(): @@ -130,3 +131,31 @@ def test_baseline_with_pycontext_code(): line = "print('hello')" compiler.join_context(PyContext(module=baseline.__name__, code=line)) assert line in compiler.pycontext_code() + + +def test_moss_gc(): + from threading import Thread + from gc import collect + container = moss_container() + assert Container.instance_count == 2 + + def run(c: Container): + compiler = container.force_fetch(MossCompiler) + compiler = compiler.join_context(PyContext(module=baseline.__name__)) + runtime = compiler.compile("__test__") + assert MossRuntime.instance_count > 0 + with runtime: + runtime.execute(target="test_main", local_args=["moss"]) + + threads = [] + for i in range(10): + t = Thread(target=run, args=(container,)) + t.start() + threads.append(t) + for t in threads: + t.join() + + # assert gc success + collect() + assert MossRuntime.instance_count == 0 + assert Container.instance_count < 10 diff --git a/tests/test_container.py b/tests/test_container.py index 96c0e5cd..bf0f508b 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -135,3 +135,9 @@ def test_provide_in_loop(): assert container.force_fetch(int) == 10 assert container.force_fetch(str) == "hello" + + +def test_container_set_str(): + container = Container() + container.set("foo", "bar") + assert container.get("foo") == "bar" From 160d2d482fe43f9abe0773a692a96998ba62878a Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 15 Nov 2024 17:02:14 +0800 Subject: [PATCH 080/148] dev: save prompt in yaml pattern --- ghostos/framework/llms/prompt_storage_impl.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ghostos/framework/llms/prompt_storage_impl.py b/ghostos/framework/llms/prompt_storage_impl.py index 7f4ba02e..92bea9a6 100644 --- a/ghostos/framework/llms/prompt_storage_impl.py +++ b/ghostos/framework/llms/prompt_storage_impl.py @@ -3,7 +3,8 @@ from ghostos.contracts.storage import Storage from ghostos.core.llms import Prompt from ghostos.core.llms.prompt import PromptStorage -import json +from ghostos.helpers import yaml_pretty_dump +import yaml class PromptStorageImpl(PromptStorage): @@ -13,18 +14,19 @@ def __init__(self, storage: Storage): @staticmethod def _get_filename(prompt_id: str) -> str: - filename = f"{prompt_id}.prompt.json" + filename = f"{prompt_id}.prompt.yml" return filename def save(self, prompt: Prompt) -> None: - data = prompt.model_dump_json(indent=2) + data = prompt.model_dump(exclude_defaults=True) filename = self._get_filename(prompt.id) - self._storage.put(filename, data.encode()) + content = yaml_pretty_dump(data) + self._storage.put(filename, content.encode()) def get(self, prompt_id: str) -> Optional[Prompt]: filename = self._get_filename(prompt_id) if self._storage.exists(filename): content = self._storage.get(filename) - data = json.loads(content) + data = yaml.safe_load(content) return Prompt(**data) return None From 61b9687e374fa059376c2f3d7be17be6220dac58 Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 15 Nov 2024 17:10:37 +0800 Subject: [PATCH 081/148] dev: update session with system log --- ghostos/core/abcd/concepts.py | 13 +++++++++++++ ghostos/framework/ghostos/conversation_impl.py | 8 ++++++++ ghostos/framework/ghostos/session_impl.py | 18 +++++++++++++++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index e8e8b482..7bcaf2b1 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -316,6 +316,10 @@ def container(self) -> Container: def task(self) -> GoTaskStruct: pass + @abstractmethod + def thread(self) -> GoThreadInfo: + pass + @abstractmethod def get_artifact(self) -> Tuple[Union[G.Artifact, None], TaskState]: pass @@ -454,6 +458,7 @@ class Session(Generic[G], ABC): Session 在运行周期里不会立刻调用底层 IO 存储消息, 而是要等一个周期正常结束. 这是为了减少运行时错误对状态机造成的副作用. """ + instance_count: ClassVar[int] = 0 stream: Stream @@ -496,6 +501,14 @@ def to_messages(self, values: Iterable[Union[MessageKind, Any]]) -> List[Message def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator]]: pass + @abstractmethod + def system_log(self, log: str) -> None: + """ + log system info, save to thread as a system message + :param log: log info + """ + pass + @abstractmethod def get_context(self) -> Optional[Prompter]: """ diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index 5588a5d1..fddafb54 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -10,6 +10,7 @@ from ghostos.core.runtime import ( Event, EventTypes, EventBus, GoTaskStruct, TaskLocker, GoTasks, TaskState, + GoThreadInfo, GoThreads, ) from ghostos.contracts.pool import Pool from ghostos.contracts.logger import LoggerItf, wrap_logger @@ -62,6 +63,7 @@ def __init__( self._is_background = is_background self._locker = task_locker self._tasks = container.force_fetch(GoTasks) + self._threads = container.force_fetch(GoThreads) self._eventbus = container.force_fetch(EventBus) self._closed = False self._bootstrap() @@ -77,6 +79,11 @@ def container(self) -> Container: def task(self) -> GoTaskStruct: return self._tasks.get_task(self._scope.task_id) + def thread(self) -> GoThreadInfo: + task = self.task() + thread_id = task.thread_id + return self._threads.get_thread(thread_id, create=True) + def get_artifact(self) -> Tuple[Union[Ghost.Artifact, None], TaskState]: task = self.task() session = self._create_session(task, self._locker, None) @@ -174,6 +181,7 @@ def _destroy(self): self._container.destroy() del self._container del self._tasks + del self._threads del self._eventbus del self._pool del self._logger diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 7ae84a36..06f727d9 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -85,12 +85,18 @@ def __init__( self._creating_tasks: Dict[str, GoTaskStruct] = {} self._firing_events: List[Event] = [] self._saving_threads: Dict[str, GoThreadInfo] = {} + self._system_logs: List[str] = [] self._failed = False self._done = False self._destroyed = False self._bootstrap() if not self.refresh(): raise RuntimeError(f"Failed to start session") + Session.instance_count += 1 + + def __del__(self): + # for gc test + Session.instance_count -= 1 def _bootstrap(self): self.contracts.validate(self.container) @@ -179,6 +185,9 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] event.context = None return event, None + def system_log(self, log: str) -> None: + self._system_logs.append(log) + def taskflow(self) -> Taskflow: self._validate_alive() return TaskflowImpl(self, self._message_parser) @@ -204,6 +213,7 @@ def _reset(self): self._firing_events = [] self._creating_tasks = {} self._saving_threads = {} + self._system_logs = [] self.task = self.task.new_turn() def messenger(self) -> Messenger: @@ -302,12 +312,18 @@ def _update_subtasks(self): def _update_state_changes(self) -> None: task = self.task - task.thread_id = self.thread.id task.meta = to_entity_meta(self.ghost) state_values = {} for name, value in self.state: state_values[name] = to_entity_meta(value) thread = self.thread + # update system log + if len(self._system_logs) > 0: + content = "\n".join(self._system_logs) + message = Role.SYSTEM.new(content=content) + thread.append(message) + + task.thread_id = thread.id task.state_values = state_values tasks = self.container.force_fetch(GoTasks) threads = self.container.force_fetch(GoThreads) From 4c5cf5591237d1f4fd4cb9d1f4803e1e0fa34843 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 16 Nov 2024 14:48:03 +0800 Subject: [PATCH 082/148] dev: streamlit app test run and parse args --- .../{design2/router.py => tests/__init__.py} | 0 .../streamlitapp/tests/design2/__init__.py | 0 .../streamlitapp/tests/design2/aifuncs/__init__.py | 0 .../{ => tests}/design2/aifuncs/details.py | 0 .../{ => tests}/design2/aifuncs/index.py | 0 .../streamlitapp/tests/design2/homepage/__init__.py | 0 .../{ => tests}/design2/homepage/applications.py | 0 .../{ => tests}/design2/homepage/home.py | 0 .../{ => tests}/design2/homepage/host.py | 0 .../streamlitapp/{ => tests}/design2/index.py | 0 .../prototypes/streamlitapp/tests/design2/router.py | 0 .../prototypes/streamlitapp/tests/sub_run/main.py | 12 ++++++++++++ .../prototypes/streamlitapp/tests/sub_run/page.py | 8 ++++++++ 13 files changed, 20 insertions(+) rename ghostos/prototypes/streamlitapp/{design2/router.py => tests/__init__.py} (100%) create mode 100644 ghostos/prototypes/streamlitapp/tests/design2/__init__.py create mode 100644 ghostos/prototypes/streamlitapp/tests/design2/aifuncs/__init__.py rename ghostos/prototypes/streamlitapp/{ => tests}/design2/aifuncs/details.py (100%) rename ghostos/prototypes/streamlitapp/{ => tests}/design2/aifuncs/index.py (100%) create mode 100644 ghostos/prototypes/streamlitapp/tests/design2/homepage/__init__.py rename ghostos/prototypes/streamlitapp/{ => tests}/design2/homepage/applications.py (100%) rename ghostos/prototypes/streamlitapp/{ => tests}/design2/homepage/home.py (100%) rename ghostos/prototypes/streamlitapp/{ => tests}/design2/homepage/host.py (100%) rename ghostos/prototypes/streamlitapp/{ => tests}/design2/index.py (100%) create mode 100644 ghostos/prototypes/streamlitapp/tests/design2/router.py create mode 100644 ghostos/prototypes/streamlitapp/tests/sub_run/main.py create mode 100644 ghostos/prototypes/streamlitapp/tests/sub_run/page.py diff --git a/ghostos/prototypes/streamlitapp/design2/router.py b/ghostos/prototypes/streamlitapp/tests/__init__.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design2/router.py rename to ghostos/prototypes/streamlitapp/tests/__init__.py diff --git a/ghostos/prototypes/streamlitapp/tests/design2/__init__.py b/ghostos/prototypes/streamlitapp/tests/design2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/streamlitapp/tests/design2/aifuncs/__init__.py b/ghostos/prototypes/streamlitapp/tests/design2/aifuncs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/streamlitapp/design2/aifuncs/details.py b/ghostos/prototypes/streamlitapp/tests/design2/aifuncs/details.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design2/aifuncs/details.py rename to ghostos/prototypes/streamlitapp/tests/design2/aifuncs/details.py diff --git a/ghostos/prototypes/streamlitapp/design2/aifuncs/index.py b/ghostos/prototypes/streamlitapp/tests/design2/aifuncs/index.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design2/aifuncs/index.py rename to ghostos/prototypes/streamlitapp/tests/design2/aifuncs/index.py diff --git a/ghostos/prototypes/streamlitapp/tests/design2/homepage/__init__.py b/ghostos/prototypes/streamlitapp/tests/design2/homepage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/streamlitapp/design2/homepage/applications.py b/ghostos/prototypes/streamlitapp/tests/design2/homepage/applications.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design2/homepage/applications.py rename to ghostos/prototypes/streamlitapp/tests/design2/homepage/applications.py diff --git a/ghostos/prototypes/streamlitapp/design2/homepage/home.py b/ghostos/prototypes/streamlitapp/tests/design2/homepage/home.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design2/homepage/home.py rename to ghostos/prototypes/streamlitapp/tests/design2/homepage/home.py diff --git a/ghostos/prototypes/streamlitapp/design2/homepage/host.py b/ghostos/prototypes/streamlitapp/tests/design2/homepage/host.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design2/homepage/host.py rename to ghostos/prototypes/streamlitapp/tests/design2/homepage/host.py diff --git a/ghostos/prototypes/streamlitapp/design2/index.py b/ghostos/prototypes/streamlitapp/tests/design2/index.py similarity index 100% rename from ghostos/prototypes/streamlitapp/design2/index.py rename to ghostos/prototypes/streamlitapp/tests/design2/index.py diff --git a/ghostos/prototypes/streamlitapp/tests/design2/router.py b/ghostos/prototypes/streamlitapp/tests/design2/router.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/streamlitapp/tests/sub_run/main.py b/ghostos/prototypes/streamlitapp/tests/sub_run/main.py new file mode 100644 index 00000000..0a58a984 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/sub_run/main.py @@ -0,0 +1,12 @@ +from streamlit.web.cli import main_run +from os.path import dirname, join +import streamlit as st + +hello = "world" + +if __name__ == "__main__": + hello = "hello" + st.session_state["hello"] = "hello" + filename = join(dirname(__file__), "page.py") + print("++++++++++++", filename) + main_run([filename, "hello world", "who are your daddy", "--logger.enableRich=True"] ) diff --git a/ghostos/prototypes/streamlitapp/tests/sub_run/page.py b/ghostos/prototypes/streamlitapp/tests/sub_run/page.py new file mode 100644 index 00000000..30c07035 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/sub_run/page.py @@ -0,0 +1,8 @@ +import streamlit as st +from ghostos.prototypes.streamlitapp.tests.sub_run.main import hello +import sys + +st.write(sys.argv) +st.write(hello) +if "hello" in st.session_state: + st.title(st.session_state["hello"]) From c121347d6061783587588472fd107411a82882ec Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 16 Nov 2024 15:07:19 +0800 Subject: [PATCH 083/148] dev: refact streamlit app structure, ready for script testing --- .../streamlitapp/pages/aifuncs/detail.py | 13 +- .../streamlitapp/pages/aifuncs/index.py | 2 +- .../prototypes/streamlitapp/pages/homepage.py | 4 +- .../prototypes/streamlitapp/utils/route.py | 32 ++- ghostos/prototypes/streamlitapp/widgets.py | 241 ------------------ .../streamlitapp/widgets/__init__.py | 0 .../streamlitapp/widgets/dialogs.py | 8 + .../prototypes/streamlitapp/widgets/docs.py | 16 ++ .../streamlitapp/widgets/exec_frame.py | 90 +++++++ .../streamlitapp/widgets/messages.py | 98 +++++++ .../prototypes/streamlitapp/widgets/moss.py | 19 ++ .../streamlitapp/widgets/navigators.py | 11 + 12 files changed, 271 insertions(+), 263 deletions(-) delete mode 100644 ghostos/prototypes/streamlitapp/widgets.py create mode 100644 ghostos/prototypes/streamlitapp/widgets/__init__.py create mode 100644 ghostos/prototypes/streamlitapp/widgets/dialogs.py create mode 100644 ghostos/prototypes/streamlitapp/widgets/docs.py create mode 100644 ghostos/prototypes/streamlitapp/widgets/exec_frame.py create mode 100644 ghostos/prototypes/streamlitapp/widgets/messages.py create mode 100644 ghostos/prototypes/streamlitapp/widgets/moss.py create mode 100644 ghostos/prototypes/streamlitapp/widgets/navigators.py diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py index ead74a12..d8f525f8 100644 --- a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py @@ -1,17 +1,20 @@ -import inspect -from typing import List, Iterable, Tuple, Type, Optional +from typing import Type import streamlit as st import streamlit_react_jsonschema as srj from ghostos.prototypes.streamlitapp.navigation import AIFuncListRoute, AIFuncDetailRoute from ghostos.prototypes.streamlitapp.resources import ( get_container, ) -from ghostos.prototypes.streamlitapp.widgets import ( +from ghostos.prototypes.streamlitapp.widgets.docs import ( help_document, markdown_document, - render_message, +) +from ghostos.prototypes.streamlitapp.widgets.exec_frame import ( render_exec_frame_tree, - render_pycontext, flatten_exec_frame_tree, +) +from ghostos.prototypes.streamlitapp.widgets.moss import render_pycontext +from ghostos.prototypes.streamlitapp.widgets.messages import ( + render_message, render_messages, ) from ghostos.core.messages import new_arr_connection diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py index 578aa113..ed44a9b1 100644 --- a/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py @@ -5,7 +5,7 @@ get_app_conf, get_app_docs, ) -from ghostos.prototypes.streamlitapp.widgets import ( +from ghostos.prototypes.streamlitapp.widgets.dialogs import ( open_code_dialog ) from ghostos.core.aifunc import ( diff --git a/ghostos/prototypes/streamlitapp/pages/homepage.py b/ghostos/prototypes/streamlitapp/pages/homepage.py index bb884fcd..2ffea97d 100644 --- a/ghostos/prototypes/streamlitapp/pages/homepage.py +++ b/ghostos/prototypes/streamlitapp/pages/homepage.py @@ -1,9 +1,9 @@ -from ghostos.helpers import gettext as _, get_current_locale +from ghostos.helpers import gettext as _ def home(): import streamlit as st - from ghostos.prototypes.streamlitapp.widgets import application_navigator_menu + from ghostos.prototypes.streamlitapp.widgets.navigators import application_navigator_menu st.title(_("GhostOS Homepage")) with st.expander(_("App Menu"), expanded=True): diff --git a/ghostos/prototypes/streamlitapp/utils/route.py b/ghostos/prototypes/streamlitapp/utils/route.py index 6c59d1b9..a2283f0a 100644 --- a/ghostos/prototypes/streamlitapp/utils/route.py +++ b/ghostos/prototypes/streamlitapp/utils/route.py @@ -197,6 +197,12 @@ def render_homepage(self) -> None: route.render_page_link(use_container_width=True) def pages(self, default: Optional[str] = None, names: Optional[List[str]] = None) -> List[st.Page]: + """ + render sidebar pages + :param default: + :param names: + :return: + """ pages = [] if names is None: names = self.routes_order @@ -218,6 +224,9 @@ def render_page_links( disabled: Optional[Set[str]] = None, use_container_width: bool = True, ) -> None: + """ + render streamlit page link buttons + """ for name in names: route = self.routes[name] is_disabled = disabled is not None and name in disabled @@ -231,24 +240,19 @@ def render_navigator( disabled: Optional[Set[str]] = None, use_container_width: bool = True, ): + """ + render default page links built buttons + """ self.render_page_links( names=self.default_navigator_names, disabled=disabled, use_container_width=use_container_width, ) - def render_default_sidebar_buttons( - self, - disabled: Optional[Set[str]] = None, - use_container_width: bool = True, - ) -> None: - self.render_page_links( - names=self.routes_order, - disabled=disabled, - use_container_width=use_container_width, - ) - - def antd_menu_items(self, node_tree: Dict[str, Union[sac.MenuItem, Dict, None]]) -> List[sac.MenuItem]: + def _antd_menu_items(self, node_tree: Dict[str, Union[sac.MenuItem, Dict, None]]) -> List[sac.MenuItem]: + """ + return antd menu items from routes. + """ result = [] for label in node_tree: item = node_tree[label] @@ -261,13 +265,13 @@ def antd_menu_items(self, node_tree: Dict[str, Union[sac.MenuItem, Dict, None]]) route = self.routes[label] children = None if isinstance(item, dict) and len(item) > 0: - children = self.antd_menu_items(item) + children = self._antd_menu_items(item) menu_item = route.antd_menu_item(children) result.append(menu_item) return result def default_antd_menu_items(self) -> List[sac.MenuItem]: - return self.antd_menu_items(self.default_menu_tree) + return self._antd_menu_items(self.default_menu_tree) def render_antd_menu(self, items: List[sac.MenuItem]) -> Optional[Route]: choose = sac.menu(items, index=-1) diff --git a/ghostos/prototypes/streamlitapp/widgets.py b/ghostos/prototypes/streamlitapp/widgets.py deleted file mode 100644 index baadba1f..00000000 --- a/ghostos/prototypes/streamlitapp/widgets.py +++ /dev/null @@ -1,241 +0,0 @@ -import streamlit as st -from typing import List, Union, Dict, Iterable, Tuple, Optional -import streamlit_antd_components as sac -from ghostos.core.aifunc import ExecFrame, ExecStep -from ghostos.core.messages import Message, Role, MessageType -from ghostos.core.moss import PyContext -from ghostos.prototypes.streamlitapp.utils.route import Router -from ghostos.prototypes.streamlitapp.utils.session import Singleton -from ghostos.prototypes.streamlitapp.resources import get_app_docs, get_app_conf -from ghostos.helpers import gettext as _ - - -def application_navigator_menu(): - router = Singleton.get(Router, st.session_state) - menu = router.default_antd_menu_items() - route = router.render_antd_menu(menu) - if route and route.label() != route.current_page_label(): - route.switch_page() - - -@st.dialog(title=_("Code"), width="large") -def open_code_dialog(title: str, code: str): - st.subheader(title) - st.code(code, line_numbers=True, wrap_lines=True) - - -def help_document(doc_name: str, label="help"): - is_helping = get_app_conf().BoolOpts.HELP_MODE.get() - with st.expander(label=label, expanded=is_helping): - doc = get_app_docs().read(doc_name) - st.markdown(doc, unsafe_allow_html=True) - - -def markdown_document(doc_name: str, **kwargs): - doc = get_app_docs().read(doc_name) - if kwargs: - doc = doc.format(**kwargs) - st.markdown(doc, unsafe_allow_html=True) - - -def get_exec_label_bloodline(label: str) -> List[int]: - splits = label.split("|", 2) - if len(splits) == 1: - return [] - return [int(c) for c in splits[1].split("_")] - - -def render_messages(messages: Iterable[Message]): - debug = get_app_conf().BoolOpts.DEBUG_MODE.get() - for msg in messages: - render_message(msg, debug=debug) - - -def render_message(msg: Message, debug: bool): - if not msg.is_complete(): - return - if MessageType.ERROR.match(msg): - with st.chat_message("user"): - st.caption(_("Error")) - st.error(msg.get_content()) - return - if msg.role == Role.ASSISTANT.value: - render_ai_message(msg, debug) - elif msg.role == Role.USER.value: - render_user_message(msg, debug) - elif msg.role == Role.SYSTEM.value: - render_sys_message(msg, debug) - elif msg.role == Role.FUNCTION.value: - render_func_message(msg, debug) - else: - render_other_message(msg, debug) - - -def render_ai_message(msg: Message, debug: bool): - content = msg.content - if not content: - return - replacements = { - "": "\n```python\n", - "": "\n```\n", - "": "\n```python\n", - "": "\n```\n", - } - for key, value in replacements.items(): - content = content.replace(key, value) - - with st.chat_message("ai"): - if msg.type: - st.caption(msg.type) - if msg.name: - st.caption(msg.name) - st.markdown(content, unsafe_allow_html=True) - if debug: - render_msg_debug(msg) - - -def render_msg_debug(msg: Message): - with st.expander(label=_("debug"), expanded=False): - st.json(msg.model_dump_json(exclude_defaults=True, indent=2)) - - -def render_user_message(msg: Message, debug: bool): - content = msg.get_content() - with st.chat_message("user"): - if msg.name: - st.caption(msg.name) - if msg.type: - st.caption(msg.type) - st.markdown(content, unsafe_allow_html=True) - - -def render_sys_message(msg: Message, debug: bool): - content = msg.content - with st.chat_message("user"): - st.caption("system message") - st.markdown(content, unsafe_allow_html=True) - if debug: - render_msg_debug(msg) - - -def render_func_message(msg: Message, debug: bool): - content = msg.content - with st.expander(_("function"), expanded=False): - if msg.name: - st.caption(msg.name) - st.markdown(content, unsafe_allow_html=True) - if debug: - render_msg_debug(msg) - - -def render_other_message(msg: Message, debug: bool): - content = msg.content - with st.expander(_("other"), expanded=False): - if msg.name: - st.caption(msg.name) - st.markdown(content, unsafe_allow_html=True) - if debug: - render_msg_debug(msg) - - -def flatten_exec_frame_tree(frame: ExecFrame) -> Dict[str, Union[ExecFrame, ExecStep]]: - def iter_frame(fr: ExecFrame, bloodline: List[int]) -> Iterable[Tuple[str, Union[ExecFrame, ExecStep]]]: - yield __frame_label(fr, bloodline), fr - idx = 0 - for step in fr.steps: - idx += 1 - next_bloodline = bloodline.copy() - next_bloodline.append(idx) - yield from iter_step(step, next_bloodline) - - def iter_step(step: ExecStep, bloodline: List[int]) -> Iterable[Tuple[str, Union[ExecStep, ExecFrame]]]: - yield __step_label(step, bloodline), step - idx = 0 - for fra in step.frames: - idx += 1 - next_bloodline = bloodline.copy() - next_bloodline.append(idx) - yield from iter_frame(fra, next_bloodline) - - result = {} - for key, value in iter_frame(frame.model_copy(), []): - result[key] = value - return result - - -def render_exec_frame_tree(label: str, frame: ExecFrame): - root = build_exec_frame_tree_node(frame.model_copy(), []) - return sac.tree( - [root], - label=label, - size="lg", - open_all=True, - show_line=True, - ) - - -def build_exec_frame_tree_node(frame: ExecFrame, bloodline: List[int]) -> sac.TreeItem: - children = [] - if len(bloodline) < 20: - steps = frame.steps - idx = 0 - for step in steps: - idx += 1 - next_bloodline = bloodline.copy() - next_bloodline.append(idx) - step_node = build_exec_step_tree_node(step, next_bloodline) - children.append(step_node) - return sac.TreeItem( - label=__frame_label(frame, bloodline), - icon="stack", - tooltip=f"click to see the frame details", - children=children, - ) - - -def build_exec_step_tree_node(step: ExecStep, bloodline: List[int]) -> sac.TreeItem: - children = [] - if len(bloodline) < 20: - idx = 0 - for frame in step.frames: - idx += 1 - next_bloodline = bloodline.copy() - next_bloodline.append(idx) - frame_node = build_exec_frame_tree_node(frame, next_bloodline) - children.append(frame_node) - return sac.TreeItem( - __step_label(step, bloodline), - icon="circle" if len(children) == 0 else "plus-circle", - tooltip=f"click to see the step details", - children=children, - ) - - -def __frame_label(frame: ExecFrame, bloodline: List[int]) -> str: - suffix = "" - if len(bloodline) > 0: - suffix = "__" + "_".join([str(c) for c in bloodline]) - return frame.func_name() + suffix - - -def __step_label(step: ExecStep, bloodline: List[int]) -> str: - suffix = "" - if len(bloodline) > 0: - suffix = "__" + "_".join([str(c) for c in bloodline]) - return step.func_name() + suffix - - -def render_pycontext(pycontext: PyContext): - if not pycontext: - return - st.subheader("PyContext") - if pycontext.module: - st.caption(f"module: {pycontext.module}") - if pycontext.code: - with st.expander(_("Code"), expanded=True): - st.code(pycontext.code) - if pycontext.execute_code: - with st.expander(_("Execute"), expanded=True): - st.code(pycontext.execute_code) - st.write(f"executed: {pycontext.executed}") - st.divider() diff --git a/ghostos/prototypes/streamlitapp/widgets/__init__.py b/ghostos/prototypes/streamlitapp/widgets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/streamlitapp/widgets/dialogs.py b/ghostos/prototypes/streamlitapp/widgets/dialogs.py new file mode 100644 index 00000000..21ad231f --- /dev/null +++ b/ghostos/prototypes/streamlitapp/widgets/dialogs.py @@ -0,0 +1,8 @@ +import streamlit as st +from ghostos.helpers import gettext as _ + + +@st.dialog(title=_("Code"), width="large") +def open_code_dialog(title: str, code: str): + st.subheader(title) + st.code(code, line_numbers=True, wrap_lines=True) diff --git a/ghostos/prototypes/streamlitapp/widgets/docs.py b/ghostos/prototypes/streamlitapp/widgets/docs.py new file mode 100644 index 00000000..3f287cab --- /dev/null +++ b/ghostos/prototypes/streamlitapp/widgets/docs.py @@ -0,0 +1,16 @@ +import streamlit as st +from ghostos.prototypes.streamlitapp.resources import get_app_docs, get_app_conf + + +def help_document(doc_name: str, label="help"): + is_helping = get_app_conf().BoolOpts.HELP_MODE.get() + with st.expander(label=label, expanded=is_helping): + doc = get_app_docs().read(doc_name) + st.markdown(doc, unsafe_allow_html=True) + + +def markdown_document(doc_name: str, **kwargs): + doc = get_app_docs().read(doc_name) + if kwargs: + doc = doc.format(**kwargs) + st.markdown(doc, unsafe_allow_html=True) diff --git a/ghostos/prototypes/streamlitapp/widgets/exec_frame.py b/ghostos/prototypes/streamlitapp/widgets/exec_frame.py new file mode 100644 index 00000000..580f1a6b --- /dev/null +++ b/ghostos/prototypes/streamlitapp/widgets/exec_frame.py @@ -0,0 +1,90 @@ +from typing import List, Union, Dict, Iterable, Tuple +import streamlit_antd_components as sac +from ghostos.core.aifunc import ExecFrame, ExecStep + + +def flatten_exec_frame_tree(frame: ExecFrame) -> Dict[str, Union[ExecFrame, ExecStep]]: + def iter_frame(fr: ExecFrame, bloodline: List[int]) -> Iterable[Tuple[str, Union[ExecFrame, ExecStep]]]: + yield __frame_label(fr, bloodline), fr + idx = 0 + for step in fr.steps: + idx += 1 + next_bloodline = bloodline.copy() + next_bloodline.append(idx) + yield from iter_step(step, next_bloodline) + + def iter_step(step: ExecStep, bloodline: List[int]) -> Iterable[Tuple[str, Union[ExecStep, ExecFrame]]]: + yield __step_label(step, bloodline), step + idx = 0 + for fra in step.frames: + idx += 1 + next_bloodline = bloodline.copy() + next_bloodline.append(idx) + yield from iter_frame(fra, next_bloodline) + + result = {} + for key, value in iter_frame(frame.model_copy(), []): + result[key] = value + return result + + +def render_exec_frame_tree(label: str, frame: ExecFrame): + root = build_exec_frame_tree_node(frame.model_copy(), []) + return sac.tree( + [root], + label=label, + size="lg", + open_all=True, + show_line=True, + ) + + +def build_exec_frame_tree_node(frame: ExecFrame, bloodline: List[int]) -> sac.TreeItem: + children = [] + if len(bloodline) < 20: + steps = frame.steps + idx = 0 + for step in steps: + idx += 1 + next_bloodline = bloodline.copy() + next_bloodline.append(idx) + step_node = build_exec_step_tree_node(step, next_bloodline) + children.append(step_node) + return sac.TreeItem( + label=__frame_label(frame, bloodline), + icon="stack", + tooltip=f"click to see the frame details", + children=children, + ) + + +def build_exec_step_tree_node(step: ExecStep, bloodline: List[int]) -> sac.TreeItem: + children = [] + if len(bloodline) < 20: + idx = 0 + for frame in step.frames: + idx += 1 + next_bloodline = bloodline.copy() + next_bloodline.append(idx) + frame_node = build_exec_frame_tree_node(frame, next_bloodline) + children.append(frame_node) + return sac.TreeItem( + __step_label(step, bloodline), + icon="circle" if len(children) == 0 else "plus-circle", + tooltip=f"click to see the step details", + children=children, + ) + + +def __frame_label(frame: ExecFrame, bloodline: List[int]) -> str: + suffix = "" + if len(bloodline) > 0: + suffix = "__" + "_".join([str(c) for c in bloodline]) + return frame.func_name() + suffix + + +def __step_label(step: ExecStep, bloodline: List[int]) -> str: + suffix = "" + if len(bloodline) > 0: + suffix = "__" + "_".join([str(c) for c in bloodline]) + return step.func_name() + suffix diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py new file mode 100644 index 00000000..ee5d82d0 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -0,0 +1,98 @@ +import streamlit as st +from typing import Iterable +from ghostos.core.messages import Message, Role, MessageType +from ghostos.prototypes.streamlitapp.resources import get_app_conf +from ghostos.helpers import gettext as _ + + +def render_messages(messages: Iterable[Message]): + debug = get_app_conf().BoolOpts.DEBUG_MODE.get() + for msg in messages: + render_message(msg, debug=debug) + + +def render_message(msg: Message, debug: bool): + if not msg.is_complete(): + return + if MessageType.ERROR.match(msg): + with st.chat_message("user"): + st.caption(_("Error")) + st.error(msg.get_content()) + return + if msg.role == Role.ASSISTANT.value: + render_ai_message(msg, debug) + elif msg.role == Role.USER.value: + render_user_message(msg, debug) + elif msg.role == Role.SYSTEM.value: + render_sys_message(msg, debug) + elif msg.role == Role.FUNCTION.value: + render_func_message(msg, debug) + else: + render_other_message(msg, debug) + + +def render_ai_message(msg: Message, debug: bool): + content = msg.content + if not content: + return + replacements = { + "": "\n```python\n", + "": "\n```\n", + "": "\n```python\n", + "": "\n```\n", + } + for key, value in replacements.items(): + content = content.replace(key, value) + + with st.chat_message("ai"): + if msg.type: + st.caption(msg.type) + if msg.name: + st.caption(msg.name) + st.markdown(content, unsafe_allow_html=True) + if debug: + render_msg_debug(msg) + + +def render_msg_debug(msg: Message): + with st.expander(label=_("debug"), expanded=False): + st.json(msg.model_dump_json(exclude_defaults=True, indent=2)) + + +def render_user_message(msg: Message, debug: bool): + content = msg.get_content() + with st.chat_message("user"): + if msg.name: + st.caption(msg.name) + if msg.type: + st.caption(msg.type) + st.markdown(content, unsafe_allow_html=True) + + +def render_sys_message(msg: Message, debug: bool): + content = msg.content + with st.chat_message("user"): + st.caption("system message") + st.markdown(content, unsafe_allow_html=True) + if debug: + render_msg_debug(msg) + + +def render_func_message(msg: Message, debug: bool): + content = msg.content + with st.expander(_("function"), expanded=False): + if msg.name: + st.caption(msg.name) + st.markdown(content, unsafe_allow_html=True) + if debug: + render_msg_debug(msg) + + +def render_other_message(msg: Message, debug: bool): + content = msg.content + with st.expander(_("other"), expanded=False): + if msg.name: + st.caption(msg.name) + st.markdown(content, unsafe_allow_html=True) + if debug: + render_msg_debug(msg) diff --git a/ghostos/prototypes/streamlitapp/widgets/moss.py b/ghostos/prototypes/streamlitapp/widgets/moss.py new file mode 100644 index 00000000..599ae697 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/widgets/moss.py @@ -0,0 +1,19 @@ +import streamlit as st +from ghostos.core.moss import PyContext +from ghostos.helpers import gettext as _ + + +def render_pycontext(pycontext: PyContext): + if not pycontext: + return + st.subheader("PyContext") + if pycontext.module: + st.caption(f"module: {pycontext.module}") + if pycontext.code: + with st.expander(_("Code"), expanded=True): + st.code(pycontext.code) + if pycontext.execute_code: + with st.expander(_("Execute"), expanded=True): + st.code(pycontext.execute_code) + st.write(f"executed: {pycontext.executed}") + st.divider() diff --git a/ghostos/prototypes/streamlitapp/widgets/navigators.py b/ghostos/prototypes/streamlitapp/widgets/navigators.py new file mode 100644 index 00000000..ea642edf --- /dev/null +++ b/ghostos/prototypes/streamlitapp/widgets/navigators.py @@ -0,0 +1,11 @@ +import streamlit as st +from ghostos.prototypes.streamlitapp.utils.route import Router +from ghostos.prototypes.streamlitapp.utils.session import Singleton + + +def application_navigator_menu(): + router = Singleton.get(Router, st.session_state) + menu = router.default_antd_menu_items() + route = router.render_antd_menu(menu) + if route and route.label() != route.current_page_label(): + route.switch_page() From ebba182256ea0098c5621f4e7dec8ea672c99c3b Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 16 Nov 2024 23:21:39 +0800 Subject: [PATCH 084/148] dev: complete streamlit aifunc script for agent talk test --- {app => .ghostos}/.example.env | 0 {app => .ghostos}/.gitignore | 0 {app => .ghostos}/.streamlit/config.toml | 0 .../docs/ghostos/en/aifunc_introduction.md | 0 .../docs/ghostos/zh/aifunc/introduction.md | 0 .../docs/ghostos/zh/aifunc/request_info.md | 0 .../docs/ghostos/zh/aifunc/usage_example.md | 0 .../configs/documents_registry.yml | 0 {app => .ghostos}/configs/ghosts.yml | 0 {app => .ghostos}/configs/llms_conf.yml | 0 .ghostos/configs/registered_aifunc.yml | 2 + {app => .ghostos}/configs/streamlit_app.yml | 0 {app/configs => .ghostos}/logging.yml | 2 +- {app => .ghostos}/memories/.gitkeep | 0 .../runtime/aifunc_frames/.gitignore | 0 {app => .ghostos}/runtime/cache/.gitignore | 0 {app => .ghostos}/runtime/events/.gitignore | 0 .../runtime/processes/.gitignore | 0 {app => .ghostos}/runtime/prompts/.gitignore | 0 {app => .ghostos}/runtime/tasks/.gitignore | 0 {app => .ghostos}/runtime/threads/.gitignore | 0 {app => .ghostos}/streamlit_main.py | 6 +- README.md | 2 +- app/configs/registered_aifunc.yml | 19 ----- examples/aifunc_raw_test.py | 2 +- ghostos/{ => app}/__init__.py | 0 .../{core => app/aifuncs_demo}/__init__.py | 0 .../aifuncs => app/aifuncs_demo}/agentic.py | 2 - .../aifuncs => app/aifuncs_demo}/news.py | 0 .../aifuncs => app/aifuncs_demo}/utils.py | 0 .../aifuncs => app/aifuncs_demo}/weather.py | 1 - ghostos/bootstrap.py | 40 +++++----- ghostos/core/aifunc/repository.py | 4 +- ghostos/core/moss/impl.py | 4 +- ghostos/entity.py | 4 +- ghostos/framework/llms/providers.py | 1 - ghostos/helpers/__init__.py | 8 +- ghostos/helpers/modules.py | 52 +++++++++---- ghostos/helpers/tree_sitter.py | 5 +- ghostos/prototypes/ghostfunc/decorator.py | 1 - ghostos/prototypes/ghostfunc/driver.py | 1 + ghostos/prototypes/ghostfunc/prepare.py | 2 + .../streamlitapp/cli}/__init__.py | 0 .../prototypes/streamlitapp/cli/helloworld.py | 11 +++ .../streamlitapp/cli/run_aifunc_app.py | 44 +++++++++++ ghostos/prototypes/streamlitapp/main.py | 5 +- .../prototypes/streamlitapp/pages/__init__.py | 0 .../streamlitapp/pages/aifuncs/__init__.py | 0 .../streamlitapp/pages/aifuncs/detail.py | 8 +- .../streamlitapp/pages/aifuncs/index.py | 2 +- .../{navigation.py => pages/router.py} | 2 +- .../prototypes/streamlitapp/utils/route.py | 32 +++++--- .../streamlitapp/widgets/navigators.py | 2 +- ghostos/scripts/__init__.py | 0 ghostos/scripts/clear_runtime.py | 4 +- ghostos/scripts/cli/run_aifunc.py | 77 +++++++++++++++++++ ghostos/scripts/cli/run_helloworld.py | 15 ++++ ghostos/scripts/cli/utils.py | 14 ++++ pyproject.toml | 5 +- tests/core/test_bootstrap.py | 7 ++ 60 files changed, 291 insertions(+), 95 deletions(-) rename {app => .ghostos}/.example.env (100%) rename {app => .ghostos}/.gitignore (100%) rename {app => .ghostos}/.streamlit/config.toml (100%) rename {app => .ghostos}/assets/docs/ghostos/en/aifunc_introduction.md (100%) rename {app => .ghostos}/assets/docs/ghostos/zh/aifunc/introduction.md (100%) rename {app => .ghostos}/assets/docs/ghostos/zh/aifunc/request_info.md (100%) rename {app => .ghostos}/assets/docs/ghostos/zh/aifunc/usage_example.md (100%) rename {app => .ghostos}/configs/documents_registry.yml (100%) rename {app => .ghostos}/configs/ghosts.yml (100%) rename {app => .ghostos}/configs/llms_conf.yml (100%) create mode 100644 .ghostos/configs/registered_aifunc.yml rename {app => .ghostos}/configs/streamlit_app.yml (100%) rename {app/configs => .ghostos}/logging.yml (95%) rename {app => .ghostos}/memories/.gitkeep (100%) rename {app => .ghostos}/runtime/aifunc_frames/.gitignore (100%) rename {app => .ghostos}/runtime/cache/.gitignore (100%) rename {app => .ghostos}/runtime/events/.gitignore (100%) rename {app => .ghostos}/runtime/processes/.gitignore (100%) rename {app => .ghostos}/runtime/prompts/.gitignore (100%) rename {app => .ghostos}/runtime/tasks/.gitignore (100%) rename {app => .ghostos}/runtime/threads/.gitignore (100%) rename {app => .ghostos}/streamlit_main.py (65%) delete mode 100644 app/configs/registered_aifunc.yml rename ghostos/{ => app}/__init__.py (100%) rename ghostos/{core => app/aifuncs_demo}/__init__.py (100%) rename ghostos/{demo/aifuncs => app/aifuncs_demo}/agentic.py (83%) rename ghostos/{demo/aifuncs => app/aifuncs_demo}/news.py (100%) rename ghostos/{demo/aifuncs => app/aifuncs_demo}/utils.py (100%) rename ghostos/{demo/aifuncs => app/aifuncs_demo}/weather.py (96%) rename ghostos/{demo/aifuncs => prototypes/streamlitapp/cli}/__init__.py (100%) create mode 100644 ghostos/prototypes/streamlitapp/cli/helloworld.py create mode 100644 ghostos/prototypes/streamlitapp/cli/run_aifunc_app.py delete mode 100644 ghostos/prototypes/streamlitapp/pages/__init__.py delete mode 100644 ghostos/prototypes/streamlitapp/pages/aifuncs/__init__.py rename ghostos/prototypes/streamlitapp/{navigation.py => pages/router.py} (98%) delete mode 100644 ghostos/scripts/__init__.py create mode 100644 ghostos/scripts/cli/run_aifunc.py create mode 100644 ghostos/scripts/cli/run_helloworld.py create mode 100644 ghostos/scripts/cli/utils.py create mode 100644 tests/core/test_bootstrap.py diff --git a/app/.example.env b/.ghostos/.example.env similarity index 100% rename from app/.example.env rename to .ghostos/.example.env diff --git a/app/.gitignore b/.ghostos/.gitignore similarity index 100% rename from app/.gitignore rename to .ghostos/.gitignore diff --git a/app/.streamlit/config.toml b/.ghostos/.streamlit/config.toml similarity index 100% rename from app/.streamlit/config.toml rename to .ghostos/.streamlit/config.toml diff --git a/app/assets/docs/ghostos/en/aifunc_introduction.md b/.ghostos/assets/docs/ghostos/en/aifunc_introduction.md similarity index 100% rename from app/assets/docs/ghostos/en/aifunc_introduction.md rename to .ghostos/assets/docs/ghostos/en/aifunc_introduction.md diff --git a/app/assets/docs/ghostos/zh/aifunc/introduction.md b/.ghostos/assets/docs/ghostos/zh/aifunc/introduction.md similarity index 100% rename from app/assets/docs/ghostos/zh/aifunc/introduction.md rename to .ghostos/assets/docs/ghostos/zh/aifunc/introduction.md diff --git a/app/assets/docs/ghostos/zh/aifunc/request_info.md b/.ghostos/assets/docs/ghostos/zh/aifunc/request_info.md similarity index 100% rename from app/assets/docs/ghostos/zh/aifunc/request_info.md rename to .ghostos/assets/docs/ghostos/zh/aifunc/request_info.md diff --git a/app/assets/docs/ghostos/zh/aifunc/usage_example.md b/.ghostos/assets/docs/ghostos/zh/aifunc/usage_example.md similarity index 100% rename from app/assets/docs/ghostos/zh/aifunc/usage_example.md rename to .ghostos/assets/docs/ghostos/zh/aifunc/usage_example.md diff --git a/app/configs/documents_registry.yml b/.ghostos/configs/documents_registry.yml similarity index 100% rename from app/configs/documents_registry.yml rename to .ghostos/configs/documents_registry.yml diff --git a/app/configs/ghosts.yml b/.ghostos/configs/ghosts.yml similarity index 100% rename from app/configs/ghosts.yml rename to .ghostos/configs/ghosts.yml diff --git a/app/configs/llms_conf.yml b/.ghostos/configs/llms_conf.yml similarity index 100% rename from app/configs/llms_conf.yml rename to .ghostos/configs/llms_conf.yml diff --git a/.ghostos/configs/registered_aifunc.yml b/.ghostos/configs/registered_aifunc.yml new file mode 100644 index 00000000..bd5cfc78 --- /dev/null +++ b/.ghostos/configs/registered_aifunc.yml @@ -0,0 +1,2 @@ +# from class: ghostos.core.aifunc.repository:AIFuncsConf +identifiers: [] diff --git a/app/configs/streamlit_app.yml b/.ghostos/configs/streamlit_app.yml similarity index 100% rename from app/configs/streamlit_app.yml rename to .ghostos/configs/streamlit_app.yml diff --git a/app/configs/logging.yml b/.ghostos/logging.yml similarity index 95% rename from app/configs/logging.yml rename to .ghostos/logging.yml index 64c94a3a..0bcb312f 100644 --- a/app/configs/logging.yml +++ b/.ghostos/logging.yml @@ -21,7 +21,7 @@ handlers: loggers: debug: - handlers: [ debug_file ] + handlers: [ console ] level: DEBUG console: handlers: [ console ] diff --git a/app/memories/.gitkeep b/.ghostos/memories/.gitkeep similarity index 100% rename from app/memories/.gitkeep rename to .ghostos/memories/.gitkeep diff --git a/app/runtime/aifunc_frames/.gitignore b/.ghostos/runtime/aifunc_frames/.gitignore similarity index 100% rename from app/runtime/aifunc_frames/.gitignore rename to .ghostos/runtime/aifunc_frames/.gitignore diff --git a/app/runtime/cache/.gitignore b/.ghostos/runtime/cache/.gitignore similarity index 100% rename from app/runtime/cache/.gitignore rename to .ghostos/runtime/cache/.gitignore diff --git a/app/runtime/events/.gitignore b/.ghostos/runtime/events/.gitignore similarity index 100% rename from app/runtime/events/.gitignore rename to .ghostos/runtime/events/.gitignore diff --git a/app/runtime/processes/.gitignore b/.ghostos/runtime/processes/.gitignore similarity index 100% rename from app/runtime/processes/.gitignore rename to .ghostos/runtime/processes/.gitignore diff --git a/app/runtime/prompts/.gitignore b/.ghostos/runtime/prompts/.gitignore similarity index 100% rename from app/runtime/prompts/.gitignore rename to .ghostos/runtime/prompts/.gitignore diff --git a/app/runtime/tasks/.gitignore b/.ghostos/runtime/tasks/.gitignore similarity index 100% rename from app/runtime/tasks/.gitignore rename to .ghostos/runtime/tasks/.gitignore diff --git a/app/runtime/threads/.gitignore b/.ghostos/runtime/threads/.gitignore similarity index 100% rename from app/runtime/threads/.gitignore rename to .ghostos/runtime/threads/.gitignore diff --git a/app/streamlit_main.py b/.ghostos/streamlit_main.py similarity index 65% rename from app/streamlit_main.py rename to .ghostos/streamlit_main.py index 23a12c4e..0b944324 100644 --- a/app/streamlit_main.py +++ b/.ghostos/streamlit_main.py @@ -1,11 +1,11 @@ -from ghostos.prototypes.streamlitapp.main import run_ghostos_streamlit_app, SINGLETONS +from ghostos.prototypes.streamlitapp.main import main_run, SINGLETONS from ghostos.prototypes.streamlitapp.utils.session import Singleton def bootstrap() -> SINGLETONS: from os.path import dirname from ghostos.bootstrap import make_app_container - from ghostos.prototypes.streamlitapp.navigation import default_router + from ghostos.prototypes.streamlitapp.pages.router import default_router app_dir = dirname(__file__) app_container = make_app_container(app_dir) @@ -15,4 +15,4 @@ def bootstrap() -> SINGLETONS: yield Singleton(default_router()) -run_ghostos_streamlit_app(bootstrap) +main_run(bootstrap) diff --git a/README.md b/README.md index ee26295f..c93bb932 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ In [this case](ghostos/demo/src/examples/run_aifunc_test.py) we ask an agent-lik We expect the `AgentFn` will call `WeatherAIFunc` and `NewsAIFunc` to help with subtasks, and give a final result to us. -The testing AIFuncs are defined at [aifuncs](ghostos/demo/aifuncs). +The testing AIFuncs are defined at [aifuncs](ghostos/app/aifuncs_demo). ### File Editor Agent Test diff --git a/app/configs/registered_aifunc.yml b/app/configs/registered_aifunc.yml deleted file mode 100644 index e250630f..00000000 --- a/app/configs/registered_aifunc.yml +++ /dev/null @@ -1,19 +0,0 @@ -# from class: ghostos.core.aifunc.repository:AIFuncsConf -identifiers: - ghostos.demo.aifuncs.agentic:AgentFn: - description: "\n AIFunc that act like an agent\n " - id: ghostos.demo.aifuncs.agentic:AgentFn - kind: null - name: AgentFn - ghostos.demo.aifuncs.news:NewsAIFunc: - description: "\n search news\n " - id: ghostos.demo.aifuncs.news:NewsAIFunc - kind: null - name: NewsAIFunc - ghostos.demo.aifuncs.weather:WeatherAIFunc: - description: "\n tell about weather\n " - id: ghostos.demo.aifuncs.weather:WeatherAIFunc - kind: null - name: WeatherAIFunc -overdue: 3600 -validated_at: 1729316054 diff --git a/examples/aifunc_raw_test.py b/examples/aifunc_raw_test.py index cb7a45e7..f8e7fc9e 100644 --- a/examples/aifunc_raw_test.py +++ b/examples/aifunc_raw_test.py @@ -14,7 +14,7 @@ if __name__ == '__main__': from ghostos.bootstrap import application_container - from ghostos.demo.aifuncs.agentic import AgentFn + from ghostos.app.aifuncs_demo import AgentFn from rich.console import Console from rich.markdown import Markdown from rich.panel import Panel diff --git a/ghostos/__init__.py b/ghostos/app/__init__.py similarity index 100% rename from ghostos/__init__.py rename to ghostos/app/__init__.py diff --git a/ghostos/core/__init__.py b/ghostos/app/aifuncs_demo/__init__.py similarity index 100% rename from ghostos/core/__init__.py rename to ghostos/app/aifuncs_demo/__init__.py diff --git a/ghostos/demo/aifuncs/agentic.py b/ghostos/app/aifuncs_demo/agentic.py similarity index 83% rename from ghostos/demo/aifuncs/agentic.py rename to ghostos/app/aifuncs_demo/agentic.py index 2b23ab56..9942befd 100644 --- a/ghostos/demo/aifuncs/agentic.py +++ b/ghostos/app/aifuncs_demo/agentic.py @@ -1,8 +1,6 @@ from typing import Optional from ghostos.core.aifunc import AIFunc, AIFuncResult, AIFuncCtx from ghostos.core.moss import Moss as Parent -from ghostos.demo.aifuncs.weather import WeatherAIFunc, WeatherAIFuncResult -from ghostos.demo.aifuncs.news import NewsAIFunc, NewsAIFuncResult from pydantic import Field diff --git a/ghostos/demo/aifuncs/news.py b/ghostos/app/aifuncs_demo/news.py similarity index 100% rename from ghostos/demo/aifuncs/news.py rename to ghostos/app/aifuncs_demo/news.py diff --git a/ghostos/demo/aifuncs/utils.py b/ghostos/app/aifuncs_demo/utils.py similarity index 100% rename from ghostos/demo/aifuncs/utils.py rename to ghostos/app/aifuncs_demo/utils.py diff --git a/ghostos/demo/aifuncs/weather.py b/ghostos/app/aifuncs_demo/weather.py similarity index 96% rename from ghostos/demo/aifuncs/weather.py rename to ghostos/app/aifuncs_demo/weather.py index 91a00dd0..912e2b6c 100644 --- a/ghostos/demo/aifuncs/weather.py +++ b/ghostos/app/aifuncs_demo/weather.py @@ -1,6 +1,5 @@ from typing import Optional from ghostos.core.aifunc import AIFunc, AIFuncResult -from ghostos.demo.aifuncs.utils import get_weather from pydantic import Field diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 3998a7c0..a8f38fee 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -1,8 +1,10 @@ -from typing import List, Optional +from typing import List, Optional, Tuple from os.path import dirname, join + from ghostos.core.abcd import GhostOS + from ghostos.container import Container, Provider, Contracts -from ghostos.contracts.logger import config_logging + from ghostos.prototypes.ghostfunc import init_ghost_func, GhostFunc import dotenv import os @@ -42,6 +44,7 @@ __all__ = [ + 'expect_workspace_dir', # >>> container # GhostOS use IoC Container to manage dependency injections at everywhere. @@ -70,7 +73,7 @@ 'reset', # default configuration - 'application_dir', + 'workspace_dir', 'default_application_contracts', 'default_application_providers', @@ -83,10 +86,17 @@ # --- prepare application paths --- # -application_dir = join(dirname(dirname(__file__)), 'app') +workspace_dir = join(dirname(dirname(__file__)), '.ghostos') """application root directory path""" +def expect_workspace_dir() -> Tuple[str, bool]: + from os.path import join, exists, abspath, isdir + from os import getcwd + expect_dir = join(getcwd(), ".ghostos") + return abspath(expect_dir), exists(expect_dir) and isdir(expect_dir) + + # --- default providers --- # @@ -228,32 +238,26 @@ def default_application_providers( # --- system bootstrap --- # def make_app_container( - app_dir: str, - logging_conf_path: str = "configs/logging.yml", + workspace_path: str, dotenv_file_path: str = ".env", app_providers: Optional[List[Provider]] = None, app_contracts: Optional[Contracts] = None, ) -> Container: """ make application global container - :param app_dir: - :param logging_conf_path: + :param workspace_path: :param dotenv_file_path: :param app_providers: :param app_contracts: :return: """ # load env from dotenv file - dotenv.load_dotenv(dotenv_path=join(app_dir, dotenv_file_path)) - logging_conf_path = join(app_dir, logging_conf_path) + dotenv.load_dotenv(dotenv_path=join(workspace_path, dotenv_file_path)) # default logger name for GhostOS application logger_name = os.environ.get("LoggerName", "debug") - # initialize logging configs - config_logging(logging_conf_path) - # todo: i18n install if app_providers is None: - app_providers = default_application_providers(root_dir=application_dir, logger_name=logger_name) + app_providers = default_application_providers(root_dir=workspace_path, logger_name=logger_name) if app_contracts is None: app_contracts = default_application_contracts() @@ -268,7 +272,7 @@ def make_app_container( return _container -application_container = make_app_container(application_dir) +application_container = make_app_container(workspace_dir) """ the global static application container. reset it before application usage""" ghost_func = init_ghost_func(application_container) @@ -299,10 +303,10 @@ def reset_at(app_dir: str) -> None: reset application with default configuration at specified app directory only run once if app_dir is the same """ - global application_dir - if app_dir == application_dir: + global workspace_dir + if app_dir == workspace_dir: return - application_dir = app_dir + workspace_dir = app_dir _container = make_app_container(app_dir) reset(_container) diff --git a/ghostos/core/aifunc/repository.py b/ghostos/core/aifunc/repository.py index cf7f0002..db68d51e 100644 --- a/ghostos/core/aifunc/repository.py +++ b/ghostos/core/aifunc/repository.py @@ -9,7 +9,7 @@ from ghostos.contracts.modules import Modules from ghostos.contracts.storage import Storage from ghostos.contracts.workspace import Workspace -from ghostos.helpers import generate_module_spec +from ghostos.helpers import generate_module_and_attr_name from ghostos.container import Provider, Container from pydantic import Field from os.path import join @@ -99,7 +99,7 @@ def list(self, offset: int = 0, limit: int = -1) -> Iterable[Identifier]: def validate(self) -> None: identifiers = {} for key, val in self.conf.identifiers.items(): - modulename, attr_name = generate_module_spec(val.id) + modulename, attr_name = generate_module_and_attr_name(val.id) try: mod = self.modules.import_module(modulename) if key not in mod.__dict__: diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index 5c486032..d4e8c28d 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -13,7 +13,7 @@ ) from ghostos.core.moss.pycontext import PyContext from ghostos.prompter import Prompter, TextPrmt -from ghostos.helpers import generate_module_spec, code_syntax_check +from ghostos.helpers import generate_module_and_attr_name, code_syntax_check from contextlib import contextmanager, redirect_stdout IMPORT_FUTURE = "from __future__ import annotations" @@ -183,7 +183,7 @@ def __del__(self): def _compile_moss(self) -> Moss: moss_type = self.moss_type() if not issubclass(moss_type, Moss): - raise TypeError(f"Moss type {moss_type} is not subclass of {generate_module_spec(Moss)}") + raise TypeError(f"Moss type {moss_type} is not subclass of {generate_module_and_attr_name(Moss)}") # 创建 stub. pycontext = self._pycontext diff --git a/ghostos/entity.py b/ghostos/entity.py index b7ea6b4b..9b5f18af 100644 --- a/ghostos/entity.py +++ b/ghostos/entity.py @@ -5,7 +5,7 @@ from typing import Union, Any, TypedDict, Required, Self, TypeVar, Type, Optional, Protocol from types import ModuleType from pydantic import BaseModel -from ghostos.helpers import generate_import_path, import_from_path, parse_import_module_and_spec +from ghostos.helpers import generate_import_path, import_from_path, parse_import_path_module_and_attr_name import inspect import pickle import base64 @@ -138,7 +138,7 @@ def from_entity_meta(meta: EntityMeta, module: Optional[ModuleType] = None) -> A # raise if import error cls = None if module: - module_name, local_name = parse_import_module_and_spec(unmarshal_type) + module_name, local_name = parse_import_path_module_and_attr_name(unmarshal_type) if module_name == module.__name__: cls = module.__dict__[local_name] if cls is None: diff --git a/ghostos/framework/llms/providers.py b/ghostos/framework/llms/providers.py index 696f1edc..0ab60978 100644 --- a/ghostos/framework/llms/providers.py +++ b/ghostos/framework/llms/providers.py @@ -1,5 +1,4 @@ from typing import Type, Optional - from ghostos.contracts.configs import YamlConfig, Configs from ghostos.container import Provider, Container from ghostos.core.llms import LLMs, LLMsConfig, PromptStorage diff --git a/ghostos/helpers/__init__.py b/ghostos/helpers/__init__.py index eefd79b9..bb3fbf14 100644 --- a/ghostos/helpers/__init__.py +++ b/ghostos/helpers/__init__.py @@ -5,16 +5,18 @@ from ghostos.helpers.modules import ( import_from_path, import_class_from_path, - parse_import_module_and_spec, + parse_import_path_module_and_attr_name, join_import_module_and_spec, - get_module_spec, - generate_module_spec, + get_module_attr, + generate_module_and_attr_name, generate_import_path, Importer, is_method_belongs_to_class, get_calling_modulename, rewrite_module, rewrite_module_by_path, + create_module, + create_and_bind_module, ) from ghostos.helpers.io import BufferPrint from ghostos.helpers.time import Timeleft diff --git a/ghostos/helpers/modules.py b/ghostos/helpers/modules.py index f5dd1166..359bce40 100644 --- a/ghostos/helpers/modules.py +++ b/ghostos/helpers/modules.py @@ -7,14 +7,16 @@ 'import_from_path', 'import_class_from_path', 'get_calling_modulename', - 'get_module_spec', + 'get_module_attr', 'generate_import_path', - 'generate_module_spec', + 'generate_module_and_attr_name', 'join_import_module_and_spec', 'is_method_belongs_to_class', - 'parse_import_module_and_spec', + 'parse_import_path_module_and_attr_name', 'rewrite_module', 'rewrite_module_by_path', + 'create_module', + 'create_and_bind_module', ] Importer = Callable[[str], ModuleType] @@ -38,18 +40,18 @@ def import_from_path(module_spec: str, importer: Optional[Importer] = None) -> A spec = parts[1] if len(parts) > 1 else None imported_module = importer(module) if spec: - return get_module_spec(imported_module.__dict__, spec) + return get_module_attr(imported_module.__dict__, spec) return imported_module -def get_module_spec(module, spec: str) -> Optional[Any]: - parts = spec.split('.') +def get_module_attr(module, attr_name: str) -> Optional[Any]: + parts = attr_name.split('.') value = module for part in parts: if value is None: - raise AttributeError(f'Module has no attribute {spec}') + raise AttributeError(f'Module has no attribute {attr_name}') if part == "": - raise AttributeError(f'local attribute {spec} is not support yet') + raise AttributeError(f'local attribute {attr_name} is not support yet') if isinstance(value, Dict): value = value.get(part) else: @@ -57,18 +59,20 @@ def get_module_spec(module, spec: str) -> Optional[Any]: return value -def generate_module_spec(value: Any) -> Tuple[str, Optional[str]]: +def generate_module_and_attr_name(value: Any) -> Tuple[str, Optional[str]]: if inspect.ismodule(value): return value.__name__, None elif inspect.isclass(value) or inspect.isfunction(value): - module = getattr(value, '__module__', '') - spec = getattr(value, '__qualname__', getattr(value, '__name__', "")) - return module, spec + modulename = getattr(value, '__module__', '') + if modulename.endswith(".__init__"): + modulename = modulename[:-len(".__init__")] + attr_name = getattr(value, '__qualname__', getattr(value, '__name__', "")) + return modulename, attr_name else: raise AttributeError(f'value {value} should be module or class to generate module spec') -def parse_import_module_and_spec(import_path: str) -> Tuple[str, Optional[str]]: +def parse_import_path_module_and_attr_name(import_path: str) -> Tuple[str, Optional[str]]: """ parse import_path to modulename and spec :param import_path: pattern is `module:spec` @@ -80,8 +84,28 @@ def parse_import_module_and_spec(import_path: str) -> Tuple[str, Optional[str]]: return parts[0], parts[1] +def create_module(module_name: str, file_path: str): + from importlib import util + + # 加载模块 + spec = util.spec_from_file_location(module_name, file_path) + module = util.module_from_spec(spec) + spec.loader.exec_module(module) + + return module + + +def create_and_bind_module(modulename: str, filename: str, force: bool = False): + from sys import modules + if not force and modulename in modules: + return modules[modulename] + module = create_module(modulename, filename) + modules[modulename] = module + return module + + def generate_import_path(value: Any) -> str: - module, spec = generate_module_spec(value) + module, spec = generate_module_and_attr_name(value) return join_import_module_and_spec(module, spec) diff --git a/ghostos/helpers/tree_sitter.py b/ghostos/helpers/tree_sitter.py index 0147b263..30e492c6 100644 --- a/ghostos/helpers/tree_sitter.py +++ b/ghostos/helpers/tree_sitter.py @@ -6,12 +6,15 @@ ) from enum import Enum -_PythonParser = get_parser('python') +_PythonParser = None __all__ = ['tree_sitter_parse', 'code_syntax_check'] def tree_sitter_parse(code: str) -> Tree: + global _PythonParser + if _PythonParser is None: + _PythonParser = get_parser('python') return _PythonParser.parse(code.encode()) diff --git a/ghostos/prototypes/ghostfunc/decorator.py b/ghostos/prototypes/ghostfunc/decorator.py index 50f4a4b5..a68c360f 100644 --- a/ghostos/prototypes/ghostfunc/decorator.py +++ b/ghostos/prototypes/ghostfunc/decorator.py @@ -1,6 +1,5 @@ import inspect from typing import Callable, Optional, Dict -from abc import ABC, abstractmethod from ghostos.container import Container from ghostos.prototypes.ghostfunc.driver import ( GhostFuncDriver, GhostFuncCache, get_ghost_func_cache, save_ghost_func_cache, diff --git a/ghostos/prototypes/ghostfunc/driver.py b/ghostos/prototypes/ghostfunc/driver.py index 1fc788cb..341d5b1f 100644 --- a/ghostos/prototypes/ghostfunc/driver.py +++ b/ghostos/prototypes/ghostfunc/driver.py @@ -2,6 +2,7 @@ import os import yaml import importlib + from ghostos.container import Container from ghostos.core.runtime import GoThreadInfo, EventTypes, thread_to_chat from ghostos.core.moss import MossRuntime, MossCompiler, PyContext diff --git a/ghostos/prototypes/ghostfunc/prepare.py b/ghostos/prototypes/ghostfunc/prepare.py index 5c3d1679..41e1dd2b 100644 --- a/ghostos/prototypes/ghostfunc/prepare.py +++ b/ghostos/prototypes/ghostfunc/prepare.py @@ -1,7 +1,9 @@ from typing import Optional from ghostos.container import Container from ghostos.core.moss import moss_container, MossCompiler + from ghostos.core.llms import LLMs + from ghostos.framework.configs import ConfigsByStorageProvider from ghostos.framework.storage import FileStorageProvider from ghostos.framework.llms import ConfigBasedLLMsProvider diff --git a/ghostos/demo/aifuncs/__init__.py b/ghostos/prototypes/streamlitapp/cli/__init__.py similarity index 100% rename from ghostos/demo/aifuncs/__init__.py rename to ghostos/prototypes/streamlitapp/cli/__init__.py diff --git a/ghostos/prototypes/streamlitapp/cli/helloworld.py b/ghostos/prototypes/streamlitapp/cli/helloworld.py new file mode 100644 index 00000000..dea0fcb7 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/cli/helloworld.py @@ -0,0 +1,11 @@ +import streamlit as st +from ghostos.prototypes.streamlitapp.main import main_run, Singleton +from sys import argv + +if len(argv) < 2: + raise SystemExit("invalid arguments") + +argument = argv[1] + +st.title("Hello World") +st.write(argument) diff --git a/ghostos/prototypes/streamlitapp/cli/run_aifunc_app.py b/ghostos/prototypes/streamlitapp/cli/run_aifunc_app.py new file mode 100644 index 00000000..42869611 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/cli/run_aifunc_app.py @@ -0,0 +1,44 @@ +from ghostos.helpers import create_and_bind_module +from ghostos.scripts.cli.run_aifunc import RunAIFuncApp +from ghostos.bootstrap import make_app_container +from ghostos.prototypes.streamlitapp.main import main_run +from ghostos.prototypes.streamlitapp.pages.router import default_router, AIFuncDetailRoute +from ghostos.prototypes.streamlitapp.utils.session import Singleton +from ghostos.contracts.logger import get_console_logger +import streamlit as st +import sys +import json + +if len(sys.argv) < 2: + raise SystemExit(f"invalid RunAIFuncApp arguments") + + +def bootstrap(): + logger = get_console_logger() + run_aifunc_app_arg = sys.argv[1] + data = json.loads(run_aifunc_app_arg) + + app_arg = RunAIFuncApp(**data) + + if app_arg.is_temp: + # create temp module + logger.debug(f"Create Temp module {app_arg.modulename}") + create_and_bind_module(app_arg.modulename, app_arg.filename) + + # bootstrap container + logger.debug(f"generate ghostos app container at workspace {app_arg.workspace_dir}") + container = make_app_container(app_arg.workspace_dir) + + # bound route. + page_route = AIFuncDetailRoute(aifunc_id=app_arg.import_path) + # initialize router and set aifunc is default + router = default_router().with_current(page_route) + + st.session_state["hello"] = "world" + return [ + Singleton(container), + Singleton(router), + ] + + +main_run(bootstrap) diff --git a/ghostos/prototypes/streamlitapp/main.py b/ghostos/prototypes/streamlitapp/main.py index 6a4deb2c..58918aae 100644 --- a/ghostos/prototypes/streamlitapp/main.py +++ b/ghostos/prototypes/streamlitapp/main.py @@ -12,7 +12,8 @@ "streamlit_contracts", "container_contracts", "SingletonContracts", - "run_ghostos_streamlit_app", + "Singleton", + "main_run", ] SINGLETONS = List[Singleton] @@ -51,7 +52,7 @@ def boot(fn: BOOTSTRAP) -> None: st.session_state[BOOTSTRAPPED_KEY] = True -def run_ghostos_streamlit_app(bootstrap: BOOTSTRAP) -> None: +def main_run(bootstrap: BOOTSTRAP) -> None: """ run streamlit application with outside bootstrap function. :param bootstrap: a bootstrap function defined outside the streamlit app run diff --git a/ghostos/prototypes/streamlitapp/pages/__init__.py b/ghostos/prototypes/streamlitapp/pages/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs/__init__.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py index d8f525f8..0cbe0c74 100644 --- a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py @@ -1,7 +1,7 @@ from typing import Type import streamlit as st import streamlit_react_jsonschema as srj -from ghostos.prototypes.streamlitapp.navigation import AIFuncListRoute, AIFuncDetailRoute +from ghostos.prototypes.streamlitapp.pages.router import AIFuncListRoute, AIFuncDetailRoute from ghostos.prototypes.streamlitapp.resources import ( get_container, ) @@ -30,7 +30,7 @@ gettext as _, import_from_path, import_class_from_path, generate_import_path, - parse_import_module_and_spec, + parse_import_path_module_and_attr_name, Timeleft, ) import inspect @@ -54,7 +54,7 @@ def render_header(fn: Type[AIFunc]) -> Identifier: def render_source(route: AIFuncDetailRoute, fn: Type[AIFunc]): # prepare - module_name, attr_name = parse_import_module_and_spec(route.aifunc_id) + module_name, attr_name = parse_import_path_module_and_attr_name(route.aifunc_id) mod = import_from_path(module_name) result_type = get_aifunc_result_type(fn) idt = identify_class(fn) @@ -212,7 +212,7 @@ def main(): render_sidebar() if not route.aifunc_id: - st.error("No AI Functions found") + st.error(f"AI Function {route.aifunc_id} found") return try: fn = import_class_from_path(route.aifunc_id, AIFunc) diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py index ed44a9b1..859a16cf 100644 --- a/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs/index.py @@ -1,5 +1,5 @@ from typing import Iterable -from ghostos.prototypes.streamlitapp.navigation import AIFuncListRoute, AIFuncDetailRoute +from ghostos.prototypes.streamlitapp.pages.router import AIFuncListRoute, AIFuncDetailRoute from ghostos.prototypes.streamlitapp.resources import ( get_container, get_app_conf, diff --git a/ghostos/prototypes/streamlitapp/navigation.py b/ghostos/prototypes/streamlitapp/pages/router.py similarity index 98% rename from ghostos/prototypes/streamlitapp/navigation.py rename to ghostos/prototypes/streamlitapp/pages/router.py index 17994578..949abe37 100644 --- a/ghostos/prototypes/streamlitapp/navigation.py +++ b/ghostos/prototypes/streamlitapp/pages/router.py @@ -109,7 +109,7 @@ def default_router() -> Router: AIFuncDetailRoute(), ], home=Home.label(), - navigator_names=[ + navigator_page_names=[ GhostOSHost.label(), AIFuncListRoute.label(), ], diff --git a/ghostos/prototypes/streamlitapp/utils/route.py b/ghostos/prototypes/streamlitapp/utils/route.py index a2283f0a..93c8b902 100644 --- a/ghostos/prototypes/streamlitapp/utils/route.py +++ b/ghostos/prototypes/streamlitapp/utils/route.py @@ -145,7 +145,8 @@ def session_state_key(cls) -> str: def get(cls, session_state: MutableMapping) -> Optional[Self]: key = cls.session_state_key() if key in session_state: - return session_state[key] + data = session_state[key] + return cls(**data) return None @classmethod @@ -153,13 +154,12 @@ def default(cls) -> Self: return cls() def bind(self, session_state: MutableMapping) -> None: + from ghostos.container import get_caller_info key = self.session_state_key() - session_state[key] = self - current = generate_import_path(Route) - session_state[current] = self.label() + session_state[key] = self.model_dump(exclude_defaults=True) @classmethod - def current_page_label(cls) -> str: + def label_of_current_page(cls) -> str: current = generate_import_path(Route) if current in st.session_state: return st.session_state[current] @@ -172,9 +172,10 @@ def __init__( self, routes: List[Route], *, home: str, - navigator_names: List[str], + navigator_page_names: List[str], default_menu: Dict[str, Union[sac.MenuItem, Dict, None]], default_sidebar_buttons: List[str], + current_page: str = None, ): self.routes: Dict[str, Route] = {} self.routes_order = [] @@ -182,7 +183,13 @@ def __init__( self.append(*routes) self.default_menu_tree = default_menu self.default_sidebar_buttons = default_sidebar_buttons - self.default_navigator_names = navigator_names + self.default_navigator_names = navigator_page_names + self.current_page = current_page if current_page is not None else self.home + + def with_current(self, route: Route) -> Self: + self.current_page = route.label() + self.routes[route.label()] = route + return self def append(self, *routes: Route): for route in routes: @@ -207,13 +214,16 @@ def pages(self, default: Optional[str] = None, names: Optional[List[str]] = None if names is None: names = self.routes_order idx = 0 + if default is None: + default = self.current_page + for name in names: route = self.routes[name] - if default is None: - is_default = idx == 0 - else: - is_default = name == default + is_default = name == default idx += 1 + if is_default: + route.bind(st.session_state) + st.session_state["hello"] = route.model_dump() page = route.page(default=is_default) pages.append(page) return pages diff --git a/ghostos/prototypes/streamlitapp/widgets/navigators.py b/ghostos/prototypes/streamlitapp/widgets/navigators.py index ea642edf..8a8f719e 100644 --- a/ghostos/prototypes/streamlitapp/widgets/navigators.py +++ b/ghostos/prototypes/streamlitapp/widgets/navigators.py @@ -7,5 +7,5 @@ def application_navigator_menu(): router = Singleton.get(Router, st.session_state) menu = router.default_antd_menu_items() route = router.render_antd_menu(menu) - if route and route.label() != route.current_page_label(): + if route and route.label() != route.label_of_current_page(): route.switch_page() diff --git a/ghostos/scripts/__init__.py b/ghostos/scripts/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/scripts/clear_runtime.py b/ghostos/scripts/clear_runtime.py index d036eac5..c3f03dfa 100644 --- a/ghostos/scripts/clear_runtime.py +++ b/ghostos/scripts/clear_runtime.py @@ -67,8 +67,8 @@ def main(): "--cache", "-c", action="store_true", ) - from ghostos.bootstrap import application_dir - runtime_dir = join(application_dir, "runtime") + from ghostos.bootstrap import workspace_dir + runtime_dir = join(workspace_dir, "runtime") parsed = parser.parse_args(sys.argv[1:]) _all = parsed.all print(f"Clearing runtime files in {runtime_dir}") diff --git a/ghostos/scripts/cli/run_aifunc.py b/ghostos/scripts/cli/run_aifunc.py new file mode 100644 index 00000000..82ad6560 --- /dev/null +++ b/ghostos/scripts/cli/run_aifunc.py @@ -0,0 +1,77 @@ +from sys import argv +from os import path +from typing import Optional, NamedTuple +from ghostos.helpers import import_from_path +from ghostos.scripts.cli.utils import check_ghostos_workspace_exists +from ghostos.prototypes.streamlitapp import cli +from ghostos.core.aifunc import AIFunc +from pydantic import BaseModel, Field +from ghostos.helpers import create_module, generate_import_path +from streamlit.web.cli import main_run + +import inspect +import sys + + +class RunAIFuncApp(BaseModel): + modulename: str = Field(description="expect aifunc modulename") + filename: str = Field(description="expect aifunc filename") + import_path: str = Field(description="expect aifunc import path") + is_temp: bool = Field(description="if the modulename is temp module") + workspace_dir: str = Field(description="the ghostos dir") + + +class FoundAIFunc(NamedTuple): + aifunc: Optional[AIFunc] + filename: str + modulename: str + is_temp: bool + + +def find_aifunc_by_name(filename_or_modulename: str) -> FoundAIFunc: + if filename_or_modulename.endswith(".py"): + filename = path.abspath(filename_or_modulename) + module = create_module("ghostos_app.temp.aifunc", filename) + is_temp = True + else: + module = import_from_path(filename_or_modulename) + filename = module.__file__ + is_temp = False + + aifunc = None + for name, value in module.__dict__.items(): + if name.startswith("_"): + continue + if not inspect.isclass(value): + continue + if value.__module__ != module.__name__: + continue + if issubclass(value, AIFunc): + aifunc = value + break + return FoundAIFunc(aifunc, filename, module.__name__, is_temp) + + +def main(): + # path + workspace_dir = check_ghostos_workspace_exists() + + # argument check + args = argv[1:] + if len(args) <= 0: + raise ValueError("At least one argument (python file or module) is required") + filename_or_modulename = args[0] + found = find_aifunc_by_name(filename_or_modulename) + if found.aifunc is None: + raise ValueError(f"No aifunc in module named {filename_or_modulename}") + + run_aifunc_app_arg = RunAIFuncApp( + modulename=found.modulename, + filename=found.filename, + import_path=generate_import_path(found.aifunc), + is_temp=found.is_temp, + workspace_dir=workspace_dir, + ) + script_path = path.join(path.dirname(cli.__file__), "run_aifunc_app.py") + args = [script_path, run_aifunc_app_arg.model_dump_json(), *sys.argv[1:]] + main_run(args) diff --git a/ghostos/scripts/cli/run_helloworld.py b/ghostos/scripts/cli/run_helloworld.py new file mode 100644 index 00000000..0c2f754a --- /dev/null +++ b/ghostos/scripts/cli/run_helloworld.py @@ -0,0 +1,15 @@ +from sys import argv +from os.path import dirname, join + + +def main(): + """ + test start streamlit and pass value + :return: + """ + from ghostos.prototypes.streamlitapp import cli + from streamlit.web.cli import main_run + args = argv[1:] + filename = join(dirname(cli.__file__), "helloworld.py") + args.insert(0, filename) + main_run(args) diff --git a/ghostos/scripts/cli/utils.py b/ghostos/scripts/cli/utils.py new file mode 100644 index 00000000..54154e53 --- /dev/null +++ b/ghostos/scripts/cli/utils.py @@ -0,0 +1,14 @@ +import sys + +from ghostos.bootstrap import expect_workspace_dir +from ghostos.contracts.logger import get_console_logger + + +def check_ghostos_workspace_exists() -> str: + logger = get_console_logger() + app_dir, ok = expect_workspace_dir() + if not ok: + logger.error("expect GhostOS workspace `%s` is not found. ", app_dir) + logger.info("run `ghostos init` to create workspace") + sys.exit(0) + return app_dir diff --git a/pyproject.toml b/pyproject.toml index 3e703c6b..07313eb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,13 +34,16 @@ python-dotenv = "^1.0.1" babel = "^2.16.0" websockets = "^13.1" pysocks = "^1.7.1" -requests = {extras = ["socks"], version = "^2.32.3"} +requests = { extras = ["socks"], version = "^2.32.3" } [tool.poetry.scripts] init = "ghostos.scripts.init:main" demo = "ghostos.demo.scripts.demo:main" llm_test = "ghostos.demo.scripts.llm_test:main" clear_runtime = "ghostos.scripts.clear_runtime:main" +# web app cli +hello = "ghostos.scripts.cli.run_helloworld:main" +aifunc = "ghostos.scripts.cli.run_aifunc:main" [build-system] requires = ["poetry-core"] diff --git a/tests/core/test_bootstrap.py b/tests/core/test_bootstrap.py new file mode 100644 index 00000000..f5607149 --- /dev/null +++ b/tests/core/test_bootstrap.py @@ -0,0 +1,7 @@ +from ghostos.bootstrap import expect_workspace_dir + + +def test_expect_app_dir(): + dirname, ok = expect_workspace_dir() + assert dirname.endswith('.ghostos') + assert isinstance(ok, bool) From 72d0c50e0ce2c1967c708c0bd6c23fa44c18e34b Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 17 Nov 2024 15:46:38 +0800 Subject: [PATCH 085/148] dev: container inherit none singleton provider from parent so that parent can late static bound providers --- ghostos/container.py | 31 +++++++++++++++++++++++++++---- ghostos/prototypes/console/app.py | 10 +++------- tests/test_container.py | 21 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/ghostos/container.py b/ghostos/container.py index 7d6cc606..fe9c65ee 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -139,7 +139,7 @@ class Container(IoCContainer): """ instance_count: ClassVar[int] = 0 - def __init__(self, parent: Optional[Container] = None): + def __init__(self, parent: Optional[Container] = None, inherit: bool = True): # container extended by children container if parent is not None: if not isinstance(parent, Container): @@ -158,11 +158,22 @@ def __init__(self, parent: Optional[Container] = None): self._aliases: Dict[Any, Any] = {} self._destroyed: bool = False self._shutdown: List[Callable[[], None]] = [] + if inherit and parent is not None: + self._inherit(parent) + Container.instance_count += 1 def __del__(self): Container.instance_count -= 1 + def _inherit(self, parent: Container): + """ + inherit none singleton provider from parent + """ + for provider in parent.providers(recursively=True): + if not provider.singleton() and not isinstance(provider, Bootstrapper): + self._register(provider) + def bootstrap(self) -> None: """ 执行 bootstrap, 只执行一次. 可以操作依赖关系. 比如实例化后反向注册. @@ -190,7 +201,7 @@ def set(self, abstract: Any, instance: INSTANCE) -> None: self._check_destroyed() self._set_instance(abstract, instance) - def _bind_contract(self, abstract: ABSTRACT) -> None: + def _add_bound_contract(self, abstract: ABSTRACT) -> None: """ 添加好绑定关系, 方便快速查找. """ @@ -282,7 +293,7 @@ def register(self, *providers: Provider) -> None: def _register(self, provider: Provider) -> None: contract = provider.contract() - self._bind_contract(contract) + self._add_bound_contract(contract) self._register_provider(contract, provider) # additional bindings @@ -355,7 +366,7 @@ def _set_instance(self, abstract: Any, instance: Any) -> None: """ 设定常量. """ - self._bind_contract(abstract) + self._add_bound_contract(abstract) self._instances[abstract] = instance def contracts(self, recursively: bool = True) -> Iterable[ABSTRACT]: @@ -370,6 +381,18 @@ def contracts(self, recursively: bool = True) -> Iterable[ABSTRACT]: done.add(contract) yield contract + def providers(self, recursively: bool = True) -> Iterable[Provider]: + self._check_destroyed() + done = set() + for provider in self._providers.values(): + done.add(provider.contract()) + yield provider + if recursively and self.parent is not None: + for provider in self.parent.providers(): + if provider.contract() not in done: + done.add(provider.contract()) + yield provider + def _check_destroyed(self) -> None: if self._destroyed: raise RuntimeError("container is called after destroyed") diff --git a/ghostos/prototypes/console/app.py b/ghostos/prototypes/console/app.py index c6004875..910544b2 100644 --- a/ghostos/prototypes/console/app.py +++ b/ghostos/prototypes/console/app.py @@ -5,8 +5,7 @@ from typing import Optional, List from ghostos.core.messages import Message, Role, MessageType -from ghostos.core.ghosts import Inputs -from ghostos.framework.streams import QueueStream +from ghostos.core.abcd import GhostOS from ghostos.framework.messages import TaskPayload from ghostos.helpers import uuid from threading import Thread @@ -78,12 +77,12 @@ def _print_output(self): def _start_background(self): while not self._stopped: - stream = self._stream() handled = self._os.background_run(stream) if not handled: time.sleep(1) elif not self._debug: - self._console.print(f"handled event {handled.type}: task_id {handled.task_id}; event_id {handled.event_id};") + self._console.print( + f"handled event {handled.type}: task_id {handled.task_id}; event_id {handled.event_id};") else: self._console.print(Panel( Markdown(f"```json\n{handled.model_dump_json(indent=2)}\n```"), @@ -91,9 +90,6 @@ def _start_background(self): border_style="yellow", )) - def _stream(self) -> QueueStream: - return QueueStream(self._main_queue, accept_chunks=False) - async def _main(self): self._welcome() if self._on_create_message: diff --git a/tests/test_container.py b/tests/test_container.py index bf0f508b..dc8ca66b 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -141,3 +141,24 @@ def test_container_set_str(): container = Container() container.set("foo", "bar") assert container.get("foo") == "bar" + + +def test_container_inherit(): + class Foo: + def __init__(self, foo: int): + self.foo = foo + + class Bar: + def __init__(self, foo: Foo): + self.foo = foo + + bar: str = "hello" + + container = Container() + container.register(provide(Bar, singleton=False)(lambda c: Bar(c.force_fetch(Foo)))) + sub_container = Container(container) + # sub container register Foo that Bar needed + sub_container.register(provide(Foo, singleton=False)(lambda c: Foo(2))) + bar = sub_container.force_fetch(Bar) + assert bar.bar == "hello" + assert bar.foo.foo == 2 From b0a41fc543842b6286fa80b21cbf7d2848181d24 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 17 Nov 2024 15:58:21 +0800 Subject: [PATCH 086/148] dev: ghost driver add get providers --- .ghostos/.gitignore | 3 ++- ghostos/core/abcd/concepts.py | 11 +++++++++-- ghostos/entity.py | 14 ++++++++++++++ ghostos/framework/ghostos/session_impl.py | 6 +++--- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.ghostos/.gitignore b/.ghostos/.gitignore index 2eea525d..7ce1d676 100644 --- a/.ghostos/.gitignore +++ b/.ghostos/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +debug.log \ No newline at end of file diff --git a/ghostos/core/abcd/concepts.py b/ghostos/core/abcd/concepts.py index 7bcaf2b1..e75c7f50 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/core/abcd/concepts.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from ghostos.identifier import Identical -from ghostos.entity import EntityType, Entity +from ghostos.entity import EntityType, EntityClass from ghostos.prompter import Prompter, DataPrompter, DataPrompterDriver from ghostos.core.runtime import ( TaskState, @@ -57,7 +57,7 @@ ) -class Ghost(Identical, ABC): +class Ghost(Identical, EntityClass, ABC): """ the class defines the model of a kind of ghosts. four parts included: @@ -117,6 +117,13 @@ def get_artifact(self, session: Session) -> Optional[G.Artifact]: """ pass + @abstractmethod + def providers(self) -> Iterable[Provider]: + """ + ghost return session level container providers + """ + pass + @abstractmethod def parse_event( self, diff --git a/ghostos/entity.py b/ghostos/entity.py index 9b5f18af..51622ac6 100644 --- a/ghostos/entity.py +++ b/ghostos/entity.py @@ -44,6 +44,20 @@ def __from_entity_meta__(cls, meta: EntityMeta) -> Self: pass +class ModelEntity(BaseModel, EntityClass, ABC): + + def __to_entity_meta__(self) -> EntityMeta: + return EntityMeta( + type=generate_import_path(self.__class__), + content=self.model_dump_json(exclude_defaults=True), + ) + + @classmethod + def __from_entity_meta__(cls, meta: EntityMeta) -> Self: + data = json.loads(meta['content']) + return cls(**data) + + class EntityMeta(TypedDict): """ I want python has an official way to marshal and unmarshal any instance and make it readable if allowed. diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 06f727d9..f30e6912 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -109,6 +109,9 @@ def _bootstrap(self): self.container.register(provide(Taskflow, False)(lambda c: self.taskflow())) self.container.register(provide(Subtasks, False)(lambda c: self.subtasks())) self.container.register(provide(Messenger, False)(lambda c: self.messenger())) + # bind ghost providers. + for provider in self.ghost_driver.providers(): + self.container.register(provider) self.container.bootstrap() @staticmethod @@ -392,6 +395,3 @@ def destroy(self) -> None: del self.ghost del self.ghost_driver del self.scope - - def __del__(self): - self.destroy() From 2f4f8dba301c507bdbbef7b7f7c0e8f5e39db06f Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 17 Nov 2024 17:53:15 +0800 Subject: [PATCH 087/148] dev: implement simplest chatbot with thought, prepering for testing --- ghostos/{core => }/abcd/__init__.py | 0 ghostos/{core => }/abcd/concepts.py | 4 +- ghostos/{core => }/abcd/ghosts.py | 10 -- ghostos/abcd/thoughts.py | 97 +++++++++++++++++++ ghostos/{core => }/abcd/utils.py | 0 ghostos/bootstrap.py | 2 +- ghostos/container.py | 12 ++- ghostos/core/aifunc/driver.py | 4 +- ghostos/core/llms/prompt.py | 68 ++++++++----- ghostos/core/messages/helpers.py | 9 +- ghostos/core/messages/message.py | 1 + ghostos/core/runtime/__init__.py | 2 +- ghostos/core/runtime/threads.py | 45 +++++---- ghostos/entity.py | 4 +- .../framework/ghostos/conversation_impl.py | 4 +- ghostos/framework/ghostos/ghostos_impl.py | 2 +- ghostos/framework/ghostos/session_impl.py | 21 ++-- ghostos/framework/ghostos/shell_impl.py | 4 +- ghostos/framework/ghostos/subtasks_impl.py | 4 +- ghostos/framework/ghostos/taskflow_impl.py | 4 +- ghostos/framework/messengers/defaults.py | 11 ++- ghostos/{core/agents => ghosts}/__init__.py | 0 ghostos/ghosts/chatbot/__init__.py | 1 + ghostos/ghosts/chatbot/chatbots.py | 18 ++++ ghostos/ghosts/chatbot/simplest.py | 69 +++++++++++++ .../moss_agent/__init__.py} | 0 .../moss_agent}/instructions.py | 0 .../moss_agent}/moss_agent.py | 2 +- ghostos/ghosts/moss_agent/template.py | 0 ghostos/prototypes/console/app.py | 2 +- ghostos/prototypes/ghostfunc/driver.py | 8 +- ghostos/thoughts/basic.py | 4 +- tests/framework/ghostos/test_session.py | 46 +++++++++ tests/python/test_pydantic.py | 14 +++ tests/python/test_set.py | 3 + 35 files changed, 379 insertions(+), 96 deletions(-) rename ghostos/{core => }/abcd/__init__.py (100%) rename ghostos/{core => }/abcd/concepts.py (99%) rename ghostos/{core => }/abcd/ghosts.py (86%) create mode 100644 ghostos/abcd/thoughts.py rename ghostos/{core => }/abcd/utils.py (100%) rename ghostos/{core/agents => ghosts}/__init__.py (100%) create mode 100644 ghostos/ghosts/chatbot/__init__.py create mode 100644 ghostos/ghosts/chatbot/chatbots.py create mode 100644 ghostos/ghosts/chatbot/simplest.py rename ghostos/{core/agents/template.py => ghosts/moss_agent/__init__.py} (100%) rename ghostos/{core/agents => ghosts/moss_agent}/instructions.py (100%) rename ghostos/{core/agents => ghosts/moss_agent}/moss_agent.py (99%) create mode 100644 ghostos/ghosts/moss_agent/template.py create mode 100644 tests/framework/ghostos/test_session.py create mode 100644 tests/python/test_set.py diff --git a/ghostos/core/abcd/__init__.py b/ghostos/abcd/__init__.py similarity index 100% rename from ghostos/core/abcd/__init__.py rename to ghostos/abcd/__init__.py diff --git a/ghostos/core/abcd/concepts.py b/ghostos/abcd/concepts.py similarity index 99% rename from ghostos/core/abcd/concepts.py rename to ghostos/abcd/concepts.py index e75c7f50..ce3c6b85 100644 --- a/ghostos/core/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -556,7 +556,7 @@ def subtasks(self) -> Subtasks: pass @abstractmethod - def messenger(self) -> "Messenger": + def messenger(self, stage: str = "") -> "Messenger": """ Task 当前运行状态下, 向上游发送消息的 Messenger. 每次会实例化一个 Messenger, 理论上不允许并行发送消息. 但也可能做一个技术方案去支持它. @@ -568,7 +568,7 @@ def messenger(self) -> "Messenger": def respond( self, messages: Iterable[MessageKind], - remember: bool = True, + stage: str = "", ) -> Tuple[List[Message], List[Caller]]: """ 发送消息, 但不影响运行状态. diff --git a/ghostos/core/abcd/ghosts.py b/ghostos/abcd/ghosts.py similarity index 86% rename from ghostos/core/abcd/ghosts.py rename to ghostos/abcd/ghosts.py index ff3b4320..1335b856 100644 --- a/ghostos/core/abcd/ghosts.py +++ b/ghostos/abcd/ghosts.py @@ -28,16 +28,6 @@ def __identifier__(self) -> Identifier: pass -class ChatBot(Agent, ABC): - """ - Chatbot is the simplest kind of the Agents. - Typical Chatbot is Customer Service or Internet search. - Chat only means the most needed feature is to create a dialog-related but alternative context, - for LLM in-context learning. - """ - pass - - class UserProxy(Ghost, ABC): """ LLM-based UserProxy can understand human language and translate the user intends to system actions. diff --git a/ghostos/abcd/thoughts.py b/ghostos/abcd/thoughts.py new file mode 100644 index 00000000..7c6f72ae --- /dev/null +++ b/ghostos/abcd/thoughts.py @@ -0,0 +1,97 @@ +from typing import Optional, Generic, TypeVar, Tuple, List +from abc import ABC, abstractmethod +from ghostos.abcd.concepts import Session, Operator, Action +from ghostos.core.llms import Prompt, ModelConf, ServiceConf, PromptPipe, LLMs, LLMApi + +T = TypeVar("T") + + +class Thought(Generic[T], ABC): + """ + LLM wrapper + """ + + @abstractmethod + def think(self, session: Session, prompt: Prompt) -> Tuple[Prompt, Optional[T]]: + pass + + +class ChainOfThoughts(Thought[Operator]): + def __init__( + self, + final: Thought[Operator], + nodes: List[Thought[Operator]], + ): + self.nodes = nodes + self.final = final + + def think(self, session: Session, prompt: Prompt) -> Tuple[Prompt, Optional[T]]: + for node in self.nodes: + prompt, op = node.think(session, prompt) + if op is not None: + return prompt, op + + return self.final.think(session, prompt) + + +class LLMThought(Thought[Operator]): + """ + basic llm thought + """ + + def __init__( + self, + llm_api: str = "", + message_stage: Optional[str] = "", + *actions: Action, + model: Optional[ModelConf] = None, + service: Optional[ServiceConf] = None, + ): + """ + + :param llm_api: The LLM API to use, see LLMsConfig + :param message_stage: + :param actions: + :param model: the llm model to use, if given, overrides llm_api + :param service: the llm service to use, if given, override ModelConf service field + """ + self.llm_api = llm_api + self.message_stage = message_stage + self.model = model + self.service = service + self.actions = {action.name(): action for action in actions} + + def think(self, session: Session, prompt: Prompt) -> Tuple[Prompt, Optional[Operator]]: + for action in self.actions.values(): + if isinstance(action, PromptPipe): + prompt = action.update_prompt(prompt) + llm_api = self.get_llm_api(session) + + streaming = not session.stream.completes_only() + if self.message_stage: + stages = ["", self.message_stage] + elif self.message_stage is None: + stages = [] + else: + stages = [""] + + stage_prompt = prompt.filter_stages(stages) + items = llm_api.deliver_chat_completion(stage_prompt, streaming) + messages, callers = session.respond(items, self.message_stage or "") + prompt.added.extend(messages) + + for caller in callers: + if caller.name in self.actions: + action = self.actions[caller.name] + op = action.run(session, caller) + if op is not None: + return prompt, op + return prompt, None + + def get_llm_api(self, session: Session) -> LLMApi: + llms = session.container.force_fetch(LLMs) + if self.model: + llm_api = llms.new_api(self.service, self.model) + else: + llm_api = llms.get_api(self.llm_api) + return llm_api diff --git a/ghostos/core/abcd/utils.py b/ghostos/abcd/utils.py similarity index 100% rename from ghostos/core/abcd/utils.py rename to ghostos/abcd/utils.py diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index a8f38fee..381473ec 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -1,7 +1,7 @@ from typing import List, Optional, Tuple from os.path import dirname, join -from ghostos.core.abcd import GhostOS +from ghostos.abcd import GhostOS from ghostos.container import Container, Provider, Contracts diff --git a/ghostos/container.py b/ghostos/container.py index fe9c65ee..c716d616 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -119,6 +119,10 @@ def contracts(self, recursively: bool = True) -> Iterable[ABSTRACT]: """ pass + @abstractmethod + def providers(self, recursively: bool = True) -> Iterable[Provider]: + pass + @abstractmethod def destroy(self) -> None: """ @@ -171,7 +175,7 @@ def _inherit(self, parent: Container): inherit none singleton provider from parent """ for provider in parent.providers(recursively=True): - if not provider.singleton() and not isinstance(provider, Bootstrapper): + if not provider.inheritable() and not isinstance(provider, Bootstrapper): self._register(provider) def bootstrap(self) -> None: @@ -428,6 +432,12 @@ def singleton(self) -> bool: """ pass + def inheritable(self) -> bool: + """ + if the provider is inheritable to sub container + """ + return not self.singleton() + def contract(self) -> ABSTRACT: """ :return: contract for this provider. diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index d9a5ca96..2a763b82 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -11,7 +11,7 @@ ) from ghostos.core.llms import LLMs, Prompt from ghostos.core.moss.abcd import MossRuntime -from ghostos.core.runtime import GoThreadInfo, EventTypes, GoThreads, thread_to_chat +from ghostos.core.runtime import GoThreadInfo, EventTypes, GoThreads, thread_to_prompt from ghostos.core.messages import Role, Message, Stream from ghostos.container import Container @@ -168,7 +168,7 @@ def think( # build chat self.on_system_messages(systems) - chat = thread_to_chat(thread.id, systems, thread) + chat = thread_to_prompt(thread.id, systems, thread) step.chat = chat.model_copy(deep=True) # on_chat hook self.on_chat(chat) diff --git a/ghostos/core/llms/prompt.py b/ghostos/core/llms/prompt.py index 20a9c231..1c32b54d 100644 --- a/ghostos/core/llms/prompt.py +++ b/ghostos/core/llms/prompt.py @@ -3,7 +3,7 @@ import time from abc import ABC, abstractmethod -from typing import List, Iterable, Optional, Union, Callable, Self +from typing import List, Iterable, Optional, Union, Callable, Self, Set from openai.types.chat.completion_create_params import Function, FunctionCall from openai import NotGiven, NOT_GIVEN from openai.types.chat.chat_completion_function_call_option_param import ChatCompletionFunctionCallOptionParam @@ -34,7 +34,7 @@ class Prompt(BaseModel): system: List[Message] = Field(default_factory=list, description="system messages") history: List[Message] = Field(default_factory=list) inputs: List[Message] = Field(default_factory=list, description="input messages") - appending: List[Message] = Field(default_factory=list, description="appending messages") + added: List[Message] = Field(default_factory=list, description="appending messages") functions: List[LLMFunc] = Field(default_factory=list) function_call: Optional[str] = Field(default=None, description="function call") @@ -55,33 +55,33 @@ def system_prompt(self) -> str: contents.append(message.get_content()) return "\n\n".join(contents) - def get_messages(self, with_system: bool = True) -> List[Message]: + def get_messages(self, with_system: bool = True, stages: Optional[List[str]] = None) -> List[Message]: """ 返回所有的消息. """ messages = [] + if stages: + stage_set = set(stages) + else: + stage_set = set() + # combine system messages into one if with_system and self.system: system_message = Role.SYSTEM.new(content=self.system_prompt()) - messages.append(system_message) + messages = join_messages_by_stages(messages, stage_set, system_message) if self.history: - messages.extend(self.history) + messages = join_messages_by_stages(messages, stage_set, *self.history) if self.inputs: - messages.extend(self.inputs) - if self.appending: - messages.extend(self.appending) - results = [] - for message in messages: - if message.is_empty(): - continue - results.append(message) - return results + messages = join_messages_by_stages(messages, stage_set, *self.inputs) + if self.added: + messages = join_messages_by_stages(messages, stage_set, *self.added) + return messages def filter_messages(self, filter_: Callable[[Message], Optional[Message]]) -> None: self.system = self._filter_messages(self.system, filter_) self.history = self._filter_messages(self.history, filter_) self.inputs = self._filter_messages(self.inputs, filter_) - self.appending = self._filter_messages(self.appending, filter_) + self.added = self._filter_messages(self.added, filter_) return @staticmethod @@ -128,33 +128,44 @@ def get_openai_function_call(self) -> Union[FunctionCall, NotGiven]: def add(self, messages: Iterable[Message]) -> Iterable[Message]: for msg in messages: if msg.is_complete(): - self.appending.append(msg.get_copy()) + self.added.append(msg.get_copy()) yield msg + def filter_stages(self, stages: Optional[List[str]] = None) -> Self: + if not stages: + return self + stages = set(stages) + copied = self.model_copy(deep=True) + if stages: + copied.history = join_messages_by_stages([], stages, *copied.history) + copied.inputs = join_messages_by_stages([], stages, *copied.inputs) + copied.added = join_messages_by_stages([], stages, *copied.added) + return copied + def fork( self, inputs: List[Message], + *, system: Optional[List[Message]] = None, description: str = "", prompt_id: Optional[str] = None, functions: Optional[List[Function]] = None, function_call: Optional[str] = None, + stages: Optional[List[str]] = None, ) -> Prompt: """ fork current prompt. """ prompt_id = prompt_id or helpers.uuid() description = description - copied = self.model_copy(update={ - "id": prompt_id, - "description": description, - }, deep=True) - if copied.inputs: + copied = self.filter_stages(stages) + copied.id = prompt_id + copied.description = description + if inputs: copied.history.extend(copied.inputs) + copied.history.extend(copied.added) copied.inputs = inputs - if copied.appending: - copied.history.extend(copied.appending) - copied.appending = [] + copied.added = [] if system: copied.system = system if functions: @@ -195,6 +206,15 @@ def run_prompt_pipeline(prompt: Prompt, pipeline: Iterable[PromptPipe]) -> Promp return prompt +def join_messages_by_stages(messages: List[Message], stages: Set[str], *join: Message) -> List[Message]: + for msg in join: + if msg.is_empty() or not msg.is_complete(): + continue + if not stages or msg.stage in stages: + messages.append(msg) + return messages + + class PromptStorage(ABC): """ save and get prompt diff --git a/ghostos/core/messages/helpers.py b/ghostos/core/messages/helpers.py index 509aed96..251390ef 100644 --- a/ghostos/core/messages/helpers.py +++ b/ghostos/core/messages/helpers.py @@ -1,4 +1,4 @@ -from typing import Iterable, List, Union, Dict +from typing import Iterable, List, Union, Dict, Optional from ghostos.core.messages.message import Message, Role, MessageClass __all__ = [ @@ -6,13 +6,16 @@ ] -def copy_messages(messages: Iterable[Message]) -> List[Message]: +def copy_messages(messages: Iterable[Message], stages: Optional[List[str]] = None) -> List[Message]: """ syntax sugar for copy """ result = [] + if stages: + stages = set(stages) for message in messages: - result.append(message.get_copy()) + if not stages or message.stage in stages: + result.append(message.get_copy()) return result diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 4cc6fe24..a9be8731 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -169,6 +169,7 @@ class Message(BaseModel): ref_id: Optional[str] = Field(default=None, description="the referenced message id.") index: Optional[int] = Field(default=None, description="the index of the message.") type: str = Field(default="", description="default message type, if empty, means text") + stage: str = Field(default="", description="message stage") role: str = Field(default="", description="Message role", enum=Role.all()) name: Optional[str] = Field(default=None, description="Message sender name") diff --git a/ghostos/core/runtime/__init__.py b/ghostos/core/runtime/__init__.py index ea888d0b..dda2298a 100644 --- a/ghostos/core/runtime/__init__.py +++ b/ghostos/core/runtime/__init__.py @@ -2,7 +2,7 @@ GoTaskStruct, TaskPayload, TaskBrief, GoTasks, TaskState, TaskLocker, ) -from ghostos.core.runtime.threads import GoThreads, GoThreadInfo, thread_to_chat, Turn +from ghostos.core.runtime.threads import GoThreads, GoThreadInfo, thread_to_prompt, Turn from ghostos.core.runtime.processes import GoProcess, GoProcesses from ghostos.core.runtime.messenger import Messenger, Buffed from ghostos.core.runtime.events import Event, EventBus, EventTypes diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index b119d297..e0ec4e21 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -11,7 +11,7 @@ __all__ = [ 'GoThreads', 'GoThreadInfo', 'Turn', - 'thread_to_chat', + 'thread_to_prompt', ] @@ -63,13 +63,13 @@ def append(self, *messages: Message, pycontext: Optional[PyContext] = None) -> N if pycontext is not None: self.pycontext = pycontext - def event_messages(self) -> Iterable[Message]: + def event_messages(self, show_instruction: bool = False) -> Iterable[Message]: if not self.event: return [] - yield from self.iter_event_message(self.event) + yield from self.iter_event_message(self.event, show_instruction) @staticmethod - def iter_event_message(event: Event) -> Iterable[Message]: + def iter_event_message(event: Event, show_instruction: bool = True) -> Iterable[Message]: if event is None: return [] @@ -86,7 +86,7 @@ def iter_event_message(event: Event) -> Iterable[Message]: yield message # instruction after messages. - if event.instruction: + if show_instruction and event.instruction: yield Role.new_system(content=event.instruction) def messages(self) -> Iterable[Message]: @@ -284,34 +284,37 @@ def reset_history(self, messages: Iterable[Message]) -> Self: def thread_copy(self, update: Optional[dict] = None) -> "GoThreadInfo": return self.model_copy(update=update, deep=True) - def to_prompt(self, system: List[Message]) -> Prompt: + def to_prompt(self, system: List[Message], stages: Optional[List[str]] = None) -> Prompt: turn_id = self.last_turn().turn_id history = list(self.get_history_messages()) inputs = [] appending = [] current_turn = self.current if current_turn is not None: - inputs = list(current_turn.event_messages()) + inputs = list(current_turn.event_messages(show_instruction=True)) appending = current_turn.added prompt = Prompt( description=f"created from thread {self.id} turn {turn_id}", system=system, - history=copy_messages(history), - inputs=copy_messages(inputs), - appending=copy_messages(appending), + history=copy_messages(history, stages), + inputs=copy_messages(inputs, stages), + added=copy_messages(appending, stages), ) return prompt -def thread_to_chat(chat_id: str, system: List[Message], thread: GoThreadInfo) -> Prompt: +def thread_to_prompt( + prompt_id: str, + system: List[Message], + thread: GoThreadInfo, + stages: Optional[List[str]] = None +) -> Prompt: """ 将 thread 转换成基准的 chat. - :param chat_id: - :param system: - :param thread: - :return: """ + if stages is None: + stages = [""] history = list(thread.get_history_messages()) inputs = [] appending = [] @@ -320,14 +323,14 @@ def thread_to_chat(chat_id: str, system: List[Message], thread: GoThreadInfo) -> inputs = list(current_turn.event_messages()) appending = current_turn.added - chat = Prompt( - id=chat_id, + prompt = Prompt( + id=prompt_id, system=system, - history=copy_messages(history), - inputs=copy_messages(inputs), - appending=copy_messages(appending), + history=copy_messages(history, stages), + inputs=copy_messages(inputs, stages), + added=copy_messages(appending, stages), ) - return chat + return prompt class GoThreads(ABC): diff --git a/ghostos/entity.py b/ghostos/entity.py index 51622ac6..c7606802 100644 --- a/ghostos/entity.py +++ b/ghostos/entity.py @@ -15,7 +15,9 @@ 'to_entity_meta', 'from_entity_meta', 'get_entity', 'is_entity_type', - 'EntityMeta', 'Entity', 'EntityType', 'EntityClass', + 'EntityMeta', + 'Entity', 'EntityType', + 'EntityClass', 'ModelEntity', ] diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index fddafb54..3cece201 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -1,8 +1,8 @@ from typing import Optional, Iterable, List, TypeVar, Tuple, Union from ghostos.container import Container -from ghostos.core.abcd.concepts import Conversation, Scope, Ghost -from ghostos.core.abcd.utils import run_session_event +from ghostos.abcd import Conversation, Scope, Ghost +from ghostos.abcd import run_session_event from ghostos.core.messages import ( Message, Role, Stream, Receiver, new_arr_connection, diff --git a/ghostos/framework/ghostos/ghostos_impl.py b/ghostos/framework/ghostos/ghostos_impl.py index a30b6c2f..9a1ad6c1 100644 --- a/ghostos/framework/ghostos/ghostos_impl.py +++ b/ghostos/framework/ghostos/ghostos_impl.py @@ -1,6 +1,6 @@ from typing import Optional, Dict -from ghostos.core.abcd.concepts import GhostOS, Shell +from ghostos.abcd import GhostOS, Shell from ghostos.core.runtime import GoProcesses, GoProcess, GoThreads, GoTasks, EventBus from ghostos.container import Container, Provider, Contracts, INSTANCE from ghostos.contracts.configs import Configs, YamlConfig diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index f30e6912..320cf314 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -1,9 +1,9 @@ from typing import Optional, List, Iterable, Tuple, TypeVar, Dict, Union, Any -from ghostos.core.abcd.concepts import ( +from ghostos.abcd import ( Session, Ghost, GhostDriver, Shell, Scope, Taskflow, Operator, Subtasks ) -from ghostos.core.abcd.utils import get_ghost_driver +from ghostos.abcd import get_ghost_driver from ghostos.core.messages import ( MessageKind, Message, Caller, Stream, Role, MessageKindParser, MessageType ) @@ -22,6 +22,7 @@ from ghostos.framework.messengers import DefaultMessenger from .taskflow_impl import TaskflowImpl from .subtasks_impl import SubtasksImpl +from threading import Lock G = TypeVar("G", bound=Ghost) @@ -90,6 +91,7 @@ def __init__( self._done = False self._destroyed = False self._bootstrap() + self._thread_locker = Lock() if not self.refresh(): raise RuntimeError(f"Failed to start session") Session.instance_count += 1 @@ -219,7 +221,7 @@ def _reset(self): self._system_logs = [] self.task = self.task.new_turn() - def messenger(self) -> Messenger: + def messenger(self, stage: str = "") -> Messenger: self._validate_alive() task_payload = TaskPayload.from_task(self.task) identity = get_identifier(self.ghost) @@ -228,16 +230,17 @@ def messenger(self) -> Messenger: name=identity.name, role=Role.ASSISTANT.value, payloads=[task_payload], + stage=stage, ) - def respond(self, messages: Iterable[MessageKind], remember: bool = True) -> Tuple[List[Message], List[Caller]]: + def respond(self, messages: Iterable[MessageKind], stage: str = "") -> Tuple[List[Message], List[Caller]]: self._validate_alive() - messenger = self.messenger() - messenger.send(messages) - messages, callers = messenger.flush() - if remember: + with self._thread_locker: + messenger = self.messenger(stage) + messenger.send(messages) + messages, callers = messenger.flush() self.thread.append(*messages) - return messages, callers + return messages, callers def cancel_subtask(self, ghost: G, reason: str = "") -> None: self._validate_alive() diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index e448e609..5f395c53 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -2,8 +2,8 @@ from typing import Union, Optional, Iterable, List, Tuple, TypeVar from ghostos.container import Container -from ghostos.core.abcd.concepts import Shell, Conversation, Ghost, Scope, Background -from ghostos.core.abcd.utils import get_ghost_driver +from ghostos.abcd import Shell, Conversation, Ghost, Scope, Background +from ghostos.abcd import get_ghost_driver from ghostos.core.messages import Message, Receiver from ghostos.core.runtime import ( Event, GoProcess, EventBus, diff --git a/ghostos/framework/ghostos/subtasks_impl.py b/ghostos/framework/ghostos/subtasks_impl.py index 4ac706db..5eb6d305 100644 --- a/ghostos/framework/ghostos/subtasks_impl.py +++ b/ghostos/framework/ghostos/subtasks_impl.py @@ -1,8 +1,8 @@ from typing import Optional, Dict, List from ghostos.container import Container -from ghostos.core.abcd.concepts import Subtasks, Session, Ghost -from ghostos.core.abcd.utils import get_ghost_driver +from ghostos.abcd import Subtasks, Session, Ghost +from ghostos.abcd import get_ghost_driver from ghostos.core.runtime import GoTaskStruct, GoTasks, EventTypes, TaskBrief, TaskState from ghostos.identifier import get_identifier from ghostos.helpers import yaml_pretty_dump diff --git a/ghostos/framework/ghostos/taskflow_impl.py b/ghostos/framework/ghostos/taskflow_impl.py index 201f65e5..8b0d5675 100644 --- a/ghostos/framework/ghostos/taskflow_impl.py +++ b/ghostos/framework/ghostos/taskflow_impl.py @@ -4,8 +4,8 @@ from abc import ABC from ghostos.container import Container -from ghostos.core.abcd.concepts import Taskflow, Session, Operator -from ghostos.core.abcd.utils import fire_session_event +from ghostos.abcd import Taskflow, Session, Operator +from ghostos.abcd import fire_session_event from ghostos.core.runtime import TaskState, EventTypes, TaskBrief from ghostos.core.moss import Injection, MossRuntime from ghostos.core.messages import MessageKind, MessageKindParser, Message, Role diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index 99af2b70..c21ce330 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -20,6 +20,7 @@ def __init__( name: Optional[str] = None, role: Optional[str] = None, payloads: Optional[Iterable[Payload]] = None, + stage: str = "", ): self._upstream = upstream self._assistant_name = name @@ -27,6 +28,7 @@ def __init__( self._payloads = payloads self._sent_messages = [] self._sent_callers = [] + self._stage = stage def flush(self) -> Tuple[List[Message], List[Caller]]: messages = self._sent_messages @@ -48,14 +50,15 @@ def buffer(self, messages: Iterable[Message]) -> Iterable[Message]: for item in messages: if item.is_complete() or item.is_head(): item.name = self._assistant_name + item.stage = self._stage if not item.role: item.role = self._role - if item.is_complete() and self._payloads: - for payload in self._payloads: - payload.set(item) - if item.is_complete(): + if self._payloads: + for payload in self._payloads: + payload.set(item) + self._sent_messages.append(item) if len(item.callers) > 0: self._sent_callers.extend(item.callers) diff --git a/ghostos/core/agents/__init__.py b/ghostos/ghosts/__init__.py similarity index 100% rename from ghostos/core/agents/__init__.py rename to ghostos/ghosts/__init__.py diff --git a/ghostos/ghosts/chatbot/__init__.py b/ghostos/ghosts/chatbot/__init__.py new file mode 100644 index 00000000..bff5386d --- /dev/null +++ b/ghostos/ghosts/chatbot/__init__.py @@ -0,0 +1 @@ +from ghostos.ghosts.chatbot.simplest import Chatbot diff --git a/ghostos/ghosts/chatbot/chatbots.py b/ghostos/ghosts/chatbot/chatbots.py new file mode 100644 index 00000000..72fa27f9 --- /dev/null +++ b/ghostos/ghosts/chatbot/chatbots.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod +from typing import Optional, List +from ghostos.ghosts.chatbot.simplest import Chatbot +from ghostos.identifier import Identifier + + +class Chatbots(ABC): + @abstractmethod + def save(self, bot: Chatbot) -> None: + pass + + @abstractmethod + def find(self, bot_name: str) -> Optional[Chatbot]: + pass + + @abstractmethod + def search(self, query: str) -> List[Identifier]: + pass diff --git a/ghostos/ghosts/chatbot/simplest.py b/ghostos/ghosts/chatbot/simplest.py new file mode 100644 index 00000000..25cdd02c --- /dev/null +++ b/ghostos/ghosts/chatbot/simplest.py @@ -0,0 +1,69 @@ +from typing import Union, Iterable + +from ghostos.abcd import Agent, GhostDriver, Session, Operator +from ghostos.abcd.thoughts import LLMThought +from ghostos.container import Provider +from ghostos.core.runtime import Event +from ghostos.core.messages import Role +from ghostos.entity import ModelEntity +from ghostos.prompter import TextPrmt, Prompter +from ghostos.identifier import Identifier +from pydantic import Field + + +class Chatbot(Agent, ModelEntity): + """ + simplest chatbot that can chat only + """ + name: str = Field(description="name of the chatbot") + description: str = Field(description="description of the chatbot") + persona: str = Field(description="persona of the chatbot") + instruction: str = Field(description="instruction of the chatbot") + llm_api: str = Field(description="llm api of the chatbot") + + Artifact = None + Context = None + + def __identifier__(self) -> Identifier: + return Identifier( + id=None, + name=self.name, + description=self.description, + ) + + +class ChatbotDriver(GhostDriver[Chatbot]): + + def get_artifact(self, session: Session) -> None: + return None + + def providers(self) -> Iterable[Provider]: + return [] + + def parse_event(self, session: Session, event: Event) -> Union[Event, None]: + return event + + def get_system_prompter(self) -> Prompter: + return TextPrmt().with_children( + TextPrmt(title="Persona", content=self.ghost.persona), + TextPrmt(title="Instruction", content=self.ghost.instruction), + ) + + def on_event(self, session: Session, event: Event) -> Union[Operator, None]: + thought = LLMThought(llm_api=self.ghost.llm_api) + + system_prompter = self.get_system_prompter() + system_message = Role.SYSTEM.new(content=system_prompter.get_prompt(session.container)) + prompt = session.thread.to_prompt([system_message]) + prompt, op = thought.think(session, prompt) + if op is not None: + return op + return session.taskflow().wait() + + +__ghost__ = Chatbot( + name="jojo", + description="a chatbot for baseline test", + persona="you are an LLM-driven cute girl, named jojo", + instruction="remember talk to user with user's language." +) diff --git a/ghostos/core/agents/template.py b/ghostos/ghosts/moss_agent/__init__.py similarity index 100% rename from ghostos/core/agents/template.py rename to ghostos/ghosts/moss_agent/__init__.py diff --git a/ghostos/core/agents/instructions.py b/ghostos/ghosts/moss_agent/instructions.py similarity index 100% rename from ghostos/core/agents/instructions.py rename to ghostos/ghosts/moss_agent/instructions.py diff --git a/ghostos/core/agents/moss_agent.py b/ghostos/ghosts/moss_agent/moss_agent.py similarity index 99% rename from ghostos/core/agents/moss_agent.py rename to ghostos/ghosts/moss_agent/moss_agent.py index 26296fd6..98345d6c 100644 --- a/ghostos/core/agents/moss_agent.py +++ b/ghostos/ghosts/moss_agent/moss_agent.py @@ -6,7 +6,7 @@ from ghostos.helpers import import_from_path from ghostos.prompter import TextPrmt, Prompter -from ghostos.core.abcd import GhostDriver, Operator, Agent, Session, StateValue, Action +from ghostos.abcd import GhostDriver, Operator, Agent, Session, StateValue, Action from ghostos.core.runtime import Event, GoThreadInfo from ghostos.core.moss import MossCompiler, PyContext, Moss, MossRuntime from ghostos.core.messages import Message, Caller, Role diff --git a/ghostos/ghosts/moss_agent/template.py b/ghostos/ghosts/moss_agent/template.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/console/app.py b/ghostos/prototypes/console/app.py index 910544b2..f002310d 100644 --- a/ghostos/prototypes/console/app.py +++ b/ghostos/prototypes/console/app.py @@ -5,7 +5,7 @@ from typing import Optional, List from ghostos.core.messages import Message, Role, MessageType -from ghostos.core.abcd import GhostOS +from ghostos.abcd import GhostOS from ghostos.framework.messages import TaskPayload from ghostos.helpers import uuid from threading import Thread diff --git a/ghostos/prototypes/ghostfunc/driver.py b/ghostos/prototypes/ghostfunc/driver.py index 341d5b1f..48375b1c 100644 --- a/ghostos/prototypes/ghostfunc/driver.py +++ b/ghostos/prototypes/ghostfunc/driver.py @@ -4,7 +4,7 @@ import importlib from ghostos.container import Container -from ghostos.core.runtime import GoThreadInfo, EventTypes, thread_to_chat +from ghostos.core.runtime import GoThreadInfo, EventTypes, thread_to_prompt from ghostos.core.moss import MossRuntime, MossCompiler, PyContext from ghostos.core.llms import LLMs, LLMApi from ghostos.core.messages import Role, Message @@ -216,7 +216,7 @@ def _think(self, thread: GoThreadInfo, args: List[Any], kwargs: Dict[str, Any]) def _run_turn(self, thread: GoThreadInfo, args: List[Any], kwargs: Dict[str, Any]) -> Tuple[Any, bool]: pycontext = thread.last_turn().pycontext - chat = thread_to_chat(thread.id, [], thread) + chat = thread_to_prompt(thread.id, [], thread) llm_api = self._get_llm_api() message = llm_api.chat_completion(chat) thread.append(message) @@ -267,8 +267,8 @@ def _run_code( return result, True def _ask_confirm_error(self, thread: GoThreadInfo, error: Exception) -> bool: - chat = thread_to_chat(thread.id, [], thread) - chat.appending.append( + chat = thread_to_prompt(thread.id, [], thread) + chat.added.append( Role.SYSTEM.new( content=f"Catch Error: {error} \nIf the error is expected, return `ok`, otherwise return `false`" ) diff --git a/ghostos/thoughts/basic.py b/ghostos/thoughts/basic.py index 63619874..295df5ae 100644 --- a/ghostos/thoughts/basic.py +++ b/ghostos/thoughts/basic.py @@ -1,6 +1,6 @@ from typing import Optional, Generic, Iterable from abc import ABC, abstractmethod -from ghostos.core.runtime import Event, thread_to_chat +from ghostos.core.runtime import Event, thread_to_prompt from ghostos.core.ghosts import Ghost, Operator, Action from ghostos.core.llms import LLMApi, PromptPipe, Prompt, run_prompt_pipeline from ghostos.core.messages import Role @@ -53,7 +53,7 @@ def initialize_chat(self, g: Ghost, e: Event) -> Prompt: content = "\n\n".join([system_prompt, thought_instruction]) # system prompt from thought system_messages = [Role.SYSTEM.new(content=content.strip())] - chat = thread_to_chat(e.event_id, system_messages, thread) + chat = thread_to_prompt(e.event_id, system_messages, thread) return chat def think(self, g: Ghost, e: Event) -> Optional[Operator]: diff --git a/tests/framework/ghostos/test_session.py b/tests/framework/ghostos/test_session.py new file mode 100644 index 00000000..e81bd1ae --- /dev/null +++ b/tests/framework/ghostos/test_session.py @@ -0,0 +1,46 @@ +from ghostos.framework.messengers import DefaultMessenger +from ghostos.core.runtime.threads import GoThreadInfo +from ghostos.core.messages import Message +from threading import Lock, Thread + + +def test_thread_sending_message_with_stage(): + thread = GoThreadInfo.new(None) + lock = Lock() + + def send_thread(content: str, stage: str): + items = [] + for c in content: + msg = Message.new_chunk(content=c) + items.append(msg) + messenger = DefaultMessenger(None, stage=stage) + messenger.send(items) + messages, callers = messenger.flush() + with lock: + thread.append(*messages) + + cases = [ + ("hello world1", ""), + ("hello world2", "a"), + ("hello world3", "a"), + ("hello world4", "b"), + ("hello world5", ""), + ] + + run = [] + for c in cases: + t = Thread(target=send_thread, args=c) + t.start() + run.append(t) + + for t in run: + t.join() + + assert len(thread.last_turn().added) == 5 + for message in thread.last_turn().added: + assert message.content.startswith("hello world") + + prompt = thread.to_prompt([], [""]) + assert len(prompt.added) == 2 + prompt = thread.to_prompt([], ["a", "b"]) + assert len(prompt.added) == 3 diff --git a/tests/python/test_pydantic.py b/tests/python/test_pydantic.py index d84ce6d4..2252460d 100644 --- a/tests/python/test_pydantic.py +++ b/tests/python/test_pydantic.py @@ -175,3 +175,17 @@ class Bar(BaseModel): f = Foo() assert f.bar.bar == "hello" + + +def test_model_with_none_model_object(): + class Foo: + foo = 123 + + err = None + try: + + class Bar(BaseModel): + foo: Foo + except PydanticSchemaGenerationError as e: + err = e + assert err is not None diff --git a/tests/python/test_set.py b/tests/python/test_set.py new file mode 100644 index 00000000..c730ea87 --- /dev/null +++ b/tests/python/test_set.py @@ -0,0 +1,3 @@ +def test_set_len(): + s = {1, 2, 3} + assert len(s) == 3 From ec8e0e67c7f8931ad8a5e8cdefec85f1bceef4db Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 17 Nov 2024 20:39:11 +0800 Subject: [PATCH 088/148] fix: fix import errors --- ghostos/abcd/__init__.py | 14 +++++++++++--- ghostos/abcd/concepts.py | 1 + ghostos/abcd/thoughts.py | 14 +++----------- ghostos/framework/ghostos/shell_impl.py | 2 +- tests/core/aifuncs/test_aifunc_repository.py | 4 ++-- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/ghostos/abcd/__init__.py b/ghostos/abcd/__init__.py index a3755a86..19393093 100644 --- a/ghostos/abcd/__init__.py +++ b/ghostos/abcd/__init__.py @@ -1,4 +1,12 @@ -from .concepts import ( - Ghost, GhostDriver, Operator, Session, GhostOS, StateValue, Action, +from ghostos.abcd.concepts import ( + GhostOS, Ghost, GhostDriver, Operator, + Session, Scope, StateValue, Action, Shell, + Background, + Conversation, Context, + Taskflow, Subtasks, +) +from ghostos.abcd.ghosts import Agent +from ghostos.abcd.utils import ( + get_ghost_driver_type, get_ghost_driver, is_ghost, + run_session_event, fire_session_event, ) -from .ghosts import Agent diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index ce3c6b85..35e2097f 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -54,6 +54,7 @@ "Ghost", "Session", "GhostDriver", "GhostOS", "Operator", "StateValue", "Action", "Shell", "Scope", "Conversation", "Background", "Taskflow", "Subtasks", + "Context", ) diff --git a/ghostos/abcd/thoughts.py b/ghostos/abcd/thoughts.py index 7c6f72ae..d48faee0 100644 --- a/ghostos/abcd/thoughts.py +++ b/ghostos/abcd/thoughts.py @@ -42,7 +42,7 @@ class LLMThought(Thought[Operator]): def __init__( self, llm_api: str = "", - message_stage: Optional[str] = "", + message_stage: str = "", *actions: Action, model: Optional[ModelConf] = None, service: Optional[ServiceConf] = None, @@ -68,16 +68,8 @@ def think(self, session: Session, prompt: Prompt) -> Tuple[Prompt, Optional[Oper llm_api = self.get_llm_api(session) streaming = not session.stream.completes_only() - if self.message_stage: - stages = ["", self.message_stage] - elif self.message_stage is None: - stages = [] - else: - stages = [""] - - stage_prompt = prompt.filter_stages(stages) - items = llm_api.deliver_chat_completion(stage_prompt, streaming) - messages, callers = session.respond(items, self.message_stage or "") + items = llm_api.deliver_chat_completion(prompt, streaming) + messages, callers = session.respond(items, self.message_stage) prompt.added.extend(messages) for caller in callers: diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index 5f395c53..d0a43a1d 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -3,7 +3,7 @@ from ghostos.container import Container from ghostos.abcd import Shell, Conversation, Ghost, Scope, Background -from ghostos.abcd import get_ghost_driver +from ghostos.abcd.utils import get_ghost_driver from ghostos.core.messages import Message, Receiver from ghostos.core.runtime import ( Event, GoProcess, EventBus, diff --git a/tests/core/aifuncs/test_aifunc_repository.py b/tests/core/aifuncs/test_aifunc_repository.py index e7596631..a3a1982d 100644 --- a/tests/core/aifuncs/test_aifunc_repository.py +++ b/tests/core/aifuncs/test_aifunc_repository.py @@ -2,7 +2,7 @@ from ghostos.framework.configs import Configs, MemoryConfigs from ghostos.contracts.modules import Modules, DefaultModules from ghostos.container import Container -from ghostos.demo import aifuncs +from ghostos.app import aifuncs_demo def test_aifunc_repository(): @@ -16,7 +16,7 @@ def test_aifunc_repository(): container.bootstrap() repo = container.force_fetch(AIFuncRepository) - result = repo.scan(str(aifuncs.__name__), recursive=True, save=False) + result = repo.scan(str(aifuncs_demo.__name__), recursive=True, save=False) assert len(result) > 1 From e7fa71b37fd1dcf30a4d97ed6d557317210548f6 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 17 Nov 2024 21:56:12 +0800 Subject: [PATCH 089/148] dev: import llmlite is super slow, check it later --- ghostos/framework/llms/openai_driver.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index 49d5a93b..6964fa7c 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -20,8 +20,6 @@ ) from ghostos.container import Bootstrapper, Container -import litellm - __all__ = [ 'OpenAIDriver', 'OpenAIAdapter', 'OpenAIDriverBootstrapper', 'LitellmAdapter', 'LiteLLMDriver', @@ -205,6 +203,7 @@ class LitellmAdapter(OpenAIAdapter): """ def _chat_completion(self, chat: Prompt, stream: bool) -> ChatCompletion: + import litellm messages = chat.get_messages() messages = self.parse_message_params(messages) response = litellm.completion( From 3387bdbe6e8fe21db224de2a6f0e97b438490c37 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 17 Nov 2024 21:57:27 +0800 Subject: [PATCH 090/148] dev: add run console script --- .ghostos/configs/ghostos_conf.yml | 2 + examples/agents/jojo.py | 8 ++++ ghostos/abcd/concepts.py | 28 +++++------ ghostos/abcd/ghosts.py | 4 +- ghostos/abcd/utils.py | 4 +- ghostos/bootstrap.py | 2 + ghostos/core/messages/transport.py | 14 ++++-- .../framework/ghostos/conversation_impl.py | 8 ++-- ghostos/framework/ghostos/ghostos_impl.py | 4 +- ghostos/framework/ghostos/session_impl.py | 6 +-- ghostos/framework/ghostos/shell_impl.py | 17 +++---- ghostos/framework/ghostos/subtasks_impl.py | 4 +- ghostos/ghosts/chatbot/simplest.py | 21 +++----- ghostos/ghosts/moss_agent/moss_agent.py | 2 +- ghostos/prototypes/ghostfunc/prepare.py | 1 - ghostos/scripts/cli/run_aifunc.py | 2 +- ghostos/scripts/cli/run_console.py | 45 +++++++++++++++++ ghostos/scripts/cli/utils.py | 48 +++++++++++++++++++ pyproject.toml | 1 + 19 files changed, 160 insertions(+), 61 deletions(-) create mode 100644 .ghostos/configs/ghostos_conf.yml create mode 100644 examples/agents/jojo.py create mode 100644 ghostos/scripts/cli/run_console.py diff --git a/.ghostos/configs/ghostos_conf.yml b/.ghostos/configs/ghostos_conf.yml new file mode 100644 index 00000000..db7062ff --- /dev/null +++ b/.ghostos/configs/ghostos_conf.yml @@ -0,0 +1,2 @@ +shells: + console: {} \ No newline at end of file diff --git a/examples/agents/jojo.py b/examples/agents/jojo.py new file mode 100644 index 00000000..d770c19d --- /dev/null +++ b/examples/agents/jojo.py @@ -0,0 +1,8 @@ +from ghostos.ghosts.chatbot import Chatbot + +__ghost__ = Chatbot( + name="jojo", + description="a chatbot for baseline test", + persona="you are an LLM-driven cute girl, named jojo", + instruction="remember talk to user with user's language." +) diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index 35e2097f..0cf7859a 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -68,13 +68,13 @@ class Ghost(Identical, EntityClass, ABC): 4. driver is """ - Artifact: ClassVar[Optional[Type]] = None + ArtifactType: ClassVar[Optional[Type]] = None """ the model of the ghost's artifact, is completing during runtime""" - Context: ClassVar[Optional[Type[Context]]] = None + ContextType: ClassVar[Optional[Type[ContextType]]] = None """ the model of the ghost's context, is completing during runtime'""" - Driver: Type[GhostDriver] = None + DriverType: ClassVar[Optional[Type[GhostDriver]]] = None """ separate ghost's methods to the driver class, make sure the ghost is simple and clear to other ghost""" @@ -108,7 +108,7 @@ def make_task_id(self, parent_scope: Scope) -> str: return md5(scope_ids) @abstractmethod - def get_artifact(self, session: Session) -> Optional[G.Artifact]: + def get_artifact(self, session: Session) -> Optional[G.ArtifactType]: """ generate the ghost goal from session_state may be the Goal Model is a SessionStateValue that bind to it. @@ -261,7 +261,7 @@ def send_event(self, event: Event) -> None: def sync( self, ghost: G, - context: Optional[G.Context] = None, + context: Optional[G.ContextType] = None, ) -> Conversation[G]: """ create a top-level conversation with a ghost. @@ -274,11 +274,11 @@ def sync( def call( self, ghost: G, - context: Optional[G.Context] = None, + context: Optional[G.ContextType] = None, instructions: Optional[Iterable[Message]] = None, timeout: float = 0.0, stream: Optional[Stream] = None, - ) -> Tuple[Union[G.Artifact, None], TaskState]: + ) -> Tuple[Union[G.ArtifactType, None], TaskState]: """ run a ghost task until it stopped, """ @@ -329,18 +329,18 @@ def thread(self) -> GoThreadInfo: pass @abstractmethod - def get_artifact(self) -> Tuple[Union[G.Artifact, None], TaskState]: + def get_artifact(self) -> Tuple[Union[G.ArtifactType, None], TaskState]: pass @abstractmethod - def ask(self, query: str, user_name: str = "") -> Receiver: + def talk(self, query: str, user_name: str = "") -> Receiver: pass @abstractmethod def respond( self, inputs: Iterable[Message], - context: Optional[G.Context] = None, + context: Optional[G.ContextType] = None, history: Optional[List[Message]] = None, ) -> Receiver: """ @@ -525,7 +525,7 @@ def get_context(self) -> Optional[Prompter]: pass @abstractmethod - def get_artifact(self) -> G.Artifact: + def get_artifact(self) -> G.ArtifactType: """ :return: the current state of the ghost goal """ @@ -587,7 +587,7 @@ def create_tasks(self, *tasks: GoTaskStruct) -> None: pass @abstractmethod - def call(self, ghost: G, ctx: G.Context) -> G.Artifact: + def call(self, ghost: G, ctx: G.ContextType) -> G.ArtifactType: """ 创建一个子任务, 阻塞并等待它完成. :param ghost: @@ -704,7 +704,7 @@ def send( self, name: str, *messages: MessageKind, - ctx: Optional[Ghost.Context] = None, + ctx: Optional[Ghost.ContextType] = None, ) -> None: """ send message to an existing subtask @@ -720,7 +720,7 @@ def create( self, ghost: Ghost, instruction: str = "", - ctx: Optional[Ghost.Context] = None, + ctx: Optional[Ghost.ContextType] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, ) -> None: diff --git a/ghostos/abcd/ghosts.py b/ghostos/abcd/ghosts.py index 1335b856..88ade0f9 100644 --- a/ghostos/abcd/ghosts.py +++ b/ghostos/abcd/ghosts.py @@ -21,8 +21,6 @@ class Agent(Ghost, ABC): - system configurations, like thread truncating / authorities / welcome craft etc. """ - Artifact = None - @abstractmethod def __identifier__(self) -> Identifier: pass @@ -42,7 +40,7 @@ class Thought(BaseModel, Ghost, ABC): Thought is a micro unit to processing thinking with current context; the Goal of the Thought is to produce a decision or suggestion, add them to the context. """ - Artifact: ClassVar = str + ArtifactType: ClassVar = str @abstractmethod def __identifier__(self) -> Identifier: diff --git a/ghostos/abcd/utils.py b/ghostos/abcd/utils.py index d326ed6a..67959eba 100644 --- a/ghostos/abcd/utils.py +++ b/ghostos/abcd/utils.py @@ -15,8 +15,8 @@ def get_ghost_driver_type(ghost: Ghost) -> Type[GhostDriver]: """ get ghost driver instance by default protocol """ - if ghost.Driver is not None: - return ghost.Driver + if ghost.DriverType is not None: + return ghost.DriverType name = ghost.__class__.__name__ module_name = ghost.__class__.__module__ import_path = f"{module_name}:{name}Driver" diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 381473ec..b12030d7 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -6,6 +6,7 @@ from ghostos.container import Container, Provider, Contracts from ghostos.prototypes.ghostfunc import init_ghost_func, GhostFunc + import dotenv import os @@ -78,6 +79,7 @@ 'default_application_providers', 'GHOSTOS_VERSION', + 'get_ghostos', ] GHOSTOS_VERSION_KEY = "ghostos_version" diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index 28b373da..ee09afe9 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -1,4 +1,4 @@ -from typing import Iterable, Optional, Callable, Tuple +from typing import Iterable, Optional, Callable, Tuple, List from typing_extensions import Protocol from collections import deque @@ -104,7 +104,7 @@ def close(self): pass @abstractmethod - def wait(self): + def wait(self) -> List[Message]: pass def __enter__(self): @@ -126,6 +126,7 @@ def __init__(self, alive: Callable[[], bool], idle: float = 0.1, complete_only: self._check_alive = alive self._idle = idle self._chunks = deque() + self._completes = [] self._closed = False self._done = False self._error: Optional[Message] = None @@ -166,6 +167,8 @@ def add(self, message: Message) -> bool: else: if message.is_complete() or not self._complete_only: self._chunks.append(message) + if message.is_complete(): + self._completes.append(message.get_copy()) return True def cancel(self): @@ -182,9 +185,13 @@ def done(self) -> bool: def error(self) -> Optional[Message]: return self._error - def wait(self): + def wait(self) -> List[Message]: while not self._done and not self._closed and not self._error: time.sleep(self._idle) + completes = self._completes.copy() + if self._error is not None: + completes.append(self._error) + return completes def close(self): if self._closed: @@ -192,6 +199,7 @@ def close(self): self._done = True self._error = None self._chunks = [] + self._completes = [] del self._check_alive diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index 3cece201..5b6e77d3 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -55,7 +55,7 @@ def __init__( shell_id=task.shell_id, process_id=task.process_id, task_id=task.task_id, - parent_task_id=task.parent_task_id, + parent_task_id=task.parent, ) self._pool = self._container.force_fetch(Pool) logger = container.force_fetch(LoggerItf) @@ -84,20 +84,20 @@ def thread(self) -> GoThreadInfo: thread_id = task.thread_id return self._threads.get_thread(thread_id, create=True) - def get_artifact(self) -> Tuple[Union[Ghost.Artifact, None], TaskState]: + def get_artifact(self) -> Tuple[Union[Ghost.ArtifactType, None], TaskState]: task = self.task() session = self._create_session(task, self._locker, None) with session: return session.get_artifact(), TaskState(session.task.state) - def ask(self, query: str, user_name: str = "") -> Receiver: + def talk(self, query: str, user_name: str = "") -> Receiver: message = Role.USER.new(content=query, name=user_name) return self.respond([message]) def respond( self, inputs: Iterable[Message], - context: Optional[Ghost.Context] = None, + context: Optional[Ghost.ContextType] = None, history: Optional[List[Message]] = None, ) -> Receiver: self._validate_closed() diff --git a/ghostos/framework/ghostos/ghostos_impl.py b/ghostos/framework/ghostos/ghostos_impl.py index 9a1ad6c1..bd307f65 100644 --- a/ghostos/framework/ghostos/ghostos_impl.py +++ b/ghostos/framework/ghostos/ghostos_impl.py @@ -16,14 +16,14 @@ class GhostOSConfig(YamlConfig): - relative_path = "ghostos.yml" + relative_path = "ghostos_conf.yml" shells: Dict[str, ShellConf] = Field( description="the shell configurations", ) class GhostOSImpl(GhostOS): - contracts: Contracts([ + contracts: Contracts = Contracts([ GoProcesses, GoTasks, GoThreads, diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 320cf314..4c42da4b 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -72,7 +72,7 @@ def __init__( ) self.ghost: G = get_entity(self.task.meta, Ghost) - self.ghost_driver: GhostDriver[G] = self.ghost.Driver(self.ghost) + self.ghost_driver: GhostDriver[G] = self.ghost.DriverType(self.ghost) identifier = get_identifier(self.ghost) variables = container.force_fetch(Variables) self._message_parser = MessageKindParser( @@ -205,7 +205,7 @@ def get_context(self) -> Optional[Prompter]: return None return get_entity(self.task.context, Prompter) - def get_artifact(self) -> Ghost.Artifact: + def get_artifact(self) -> Ghost.ArtifactType: return self.ghost_driver.get_artifact(self) def refresh(self) -> bool: @@ -269,7 +269,7 @@ def create_threads(self, *threads: GoThreadInfo) -> None: for t in threads: self._saving_threads[t.id] = t - def call(self, ghost: Ghost, ctx: Ghost.Context) -> Ghost.Artifact: + def call(self, ghost: Ghost, ctx: Ghost.ContextType) -> Ghost.ArtifactType: self._validate_alive() shell = self.container.force_fetch(Shell) return shell.call(ghost, ctx) diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index d0a43a1d..9826153e 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -22,9 +22,6 @@ class ShellConf(BaseModel): - persona: str = Field( - description="the persona of the shell root agents", - ) max_session_steps: int = Field( default=10, ) @@ -81,7 +78,7 @@ def send_event(self, event: Event) -> None: notify = task.depth > 0 self._eventbus.send_event(event, notify) - def sync(self, ghost: Ghost, context: Optional[Ghost.Context] = None) -> Conversation: + def sync(self, ghost: Ghost, context: Optional[Ghost.ContextType] = None) -> Conversation: driver = get_ghost_driver(ghost) task_id = driver.make_task_id(self._scope) task = self._tasks.get_task(task_id) @@ -91,9 +88,7 @@ def sync(self, ghost: Ghost, context: Optional[Ghost.Context] = None) -> Convers if context is not None: task.context = to_entity_meta(context) conversation = self.sync_task(task, throw=True, is_background=False) - if context is not None: - return conversation - raise RuntimeError(f'Cannot sync ghost') + return conversation def sync_task( self, @@ -123,11 +118,11 @@ def sync_task( def call( self, ghost: Ghost, - context: Optional[Ghost.Context] = None, + context: Optional[Ghost.ContextType] = None, instructions: Optional[Iterable[Message]] = None, timeout: float = 0.0, stream: Optional[Stream] = None, - ) -> Tuple[Union[Ghost.Artifact, None], TaskState]: + ) -> Tuple[Union[Ghost.ArtifactType, None], TaskState]: def send_message(receiver: Receiver): with receiver: @@ -152,13 +147,13 @@ def send_message(receiver: Receiver): r = conversation.respond_event(e) send_message(r) else: - conversation.ask("continue to fulfill your task or fail") + conversation.talk("continue to fulfill your task or fail") return conversation.get_artifact() def create_root_task( self, ghost: Ghost, - context: Optional[Ghost.Context], + context: Optional[Ghost.ContextType], ) -> GoTaskStruct: task_id = uuid() id_ = get_identifier(ghost) diff --git a/ghostos/framework/ghostos/subtasks_impl.py b/ghostos/framework/ghostos/subtasks_impl.py index 5eb6d305..be783919 100644 --- a/ghostos/framework/ghostos/subtasks_impl.py +++ b/ghostos/framework/ghostos/subtasks_impl.py @@ -42,7 +42,7 @@ def send( self, name: str, *messages: Subtasks.MessageKind, - ctx: Optional[Ghost.Context] = None, + ctx: Optional[Ghost.ContextType] = None, ) -> None: subtasks = self.get_subtasks() if name not in subtasks: @@ -63,7 +63,7 @@ def create( self, ghost: Ghost, instruction: str = "", - ctx: Optional[Ghost.Context] = None, + ctx: Optional[Ghost.ContextType] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, ) -> None: diff --git a/ghostos/ghosts/chatbot/simplest.py b/ghostos/ghosts/chatbot/simplest.py index 25cdd02c..0166f46b 100644 --- a/ghostos/ghosts/chatbot/simplest.py +++ b/ghostos/ghosts/chatbot/simplest.py @@ -1,4 +1,4 @@ -from typing import Union, Iterable +from typing import Union, Iterable, ClassVar from ghostos.abcd import Agent, GhostDriver, Session, Operator from ghostos.abcd.thoughts import LLMThought @@ -8,10 +8,10 @@ from ghostos.entity import ModelEntity from ghostos.prompter import TextPrmt, Prompter from ghostos.identifier import Identifier -from pydantic import Field +from pydantic import BaseModel, Field -class Chatbot(Agent, ModelEntity): +class Chatbot(ModelEntity, Agent): """ simplest chatbot that can chat only """ @@ -19,10 +19,11 @@ class Chatbot(Agent, ModelEntity): description: str = Field(description="description of the chatbot") persona: str = Field(description="persona of the chatbot") instruction: str = Field(description="instruction of the chatbot") - llm_api: str = Field(description="llm api of the chatbot") + llm_api: str = Field(default="", description="llm api of the chatbot") - Artifact = None - Context = None + ArtifactType: ClassVar = None + ContextType: ClassVar = None + DriverType: ClassVar = None def __identifier__(self) -> Identifier: return Identifier( @@ -59,11 +60,3 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: if op is not None: return op return session.taskflow().wait() - - -__ghost__ = Chatbot( - name="jojo", - description="a chatbot for baseline test", - persona="you are an LLM-driven cute girl, named jojo", - instruction="remember talk to user with user's language." -) diff --git a/ghostos/ghosts/moss_agent/moss_agent.py b/ghostos/ghosts/moss_agent/moss_agent.py index 98345d6c..19efd7ea 100644 --- a/ghostos/ghosts/moss_agent/moss_agent.py +++ b/ghostos/ghosts/moss_agent/moss_agent.py @@ -27,7 +27,7 @@ class MossAgent(BaseModel, Agent): Basic Agent that turn a python module into a conversational agent. """ - Artifact = None + ArtifactType = None """ subclass of MossAgent could have a GoalType, default is None""" moss_module: str = Field(description="Moss module name for the agent") diff --git a/ghostos/prototypes/ghostfunc/prepare.py b/ghostos/prototypes/ghostfunc/prepare.py index 41e1dd2b..e433e94e 100644 --- a/ghostos/prototypes/ghostfunc/prepare.py +++ b/ghostos/prototypes/ghostfunc/prepare.py @@ -3,7 +3,6 @@ from ghostos.core.moss import moss_container, MossCompiler from ghostos.core.llms import LLMs - from ghostos.framework.configs import ConfigsByStorageProvider from ghostos.framework.storage import FileStorageProvider from ghostos.framework.llms import ConfigBasedLLMsProvider diff --git a/ghostos/scripts/cli/run_aifunc.py b/ghostos/scripts/cli/run_aifunc.py index 82ad6560..3301da0b 100644 --- a/ghostos/scripts/cli/run_aifunc.py +++ b/ghostos/scripts/cli/run_aifunc.py @@ -59,7 +59,7 @@ def main(): # argument check args = argv[1:] if len(args) <= 0: - raise ValueError("At least one argument (python file or module) is required") + raise ValueError("At least one argument (python filename or modulename) is required") filename_or_modulename = args[0] found = find_aifunc_by_name(filename_or_modulename) if found.aifunc is None: diff --git a/ghostos/scripts/cli/run_console.py b/ghostos/scripts/cli/run_console.py new file mode 100644 index 00000000..4c6d324e --- /dev/null +++ b/ghostos/scripts/cli/run_console.py @@ -0,0 +1,45 @@ +from ghostos.abcd import Ghost + +from ghostos.scripts.cli.utils import ( + check_ghostos_workspace_exists, + parse_args_modulename_or_filename, get_or_create_module_from_name, +) + +from ghostos.bootstrap import make_app_container, get_ghostos + + +def get_ghost() -> Ghost: + filename_or_modulename, args = parse_args_modulename_or_filename() + found = get_or_create_module_from_name(filename_or_modulename, "ghostos.temp.agent") + + if found.value is not None: + if not isinstance(found.value, Ghost): + raise SystemExit(f"{found.value} is not a Ghost object") + ghost = found.value + elif "__ghost__" in found.module.__dict__: + ghost = found.module.__dict__["__ghost__"] + if not isinstance(ghost, Ghost): + raise SystemExit(f"{filename_or_modulename} __ghost__ is not a Ghost object") + else: + raise SystemExit(f"cant find ghost instance at {filename_or_modulename}") + return ghost + + +def main(): + workspace_dir = check_ghostos_workspace_exists() + ghost = get_ghost() + container = make_app_container(workspace_dir) + ghostos = get_ghostos(container) + shell = ghostos.create_shell( + "console", + "console", + ) + conversation = shell.sync(ghost) + with conversation: + print(conversation.task()) + exit(0) + receiver = conversation.talk("hello") + with receiver: + messages = receiver.wait() + print(messages) + shell.close() diff --git a/ghostos/scripts/cli/utils.py b/ghostos/scripts/cli/utils.py index 54154e53..9ceeddac 100644 --- a/ghostos/scripts/cli/utils.py +++ b/ghostos/scripts/cli/utils.py @@ -1,7 +1,11 @@ import sys +from typing import Tuple, List, NamedTuple, Any, Optional +from types import ModuleType from ghostos.bootstrap import expect_workspace_dir from ghostos.contracts.logger import get_console_logger +from ghostos.helpers import create_module, import_from_path +import inspect def check_ghostos_workspace_exists() -> str: @@ -12,3 +16,47 @@ def check_ghostos_workspace_exists() -> str: logger.info("run `ghostos init` to create workspace") sys.exit(0) return app_dir + + +def parse_args_modulename_or_filename() -> Tuple[str, List[str]]: + from sys import argv + # argument check + args = argv[1:] + if len(args) <= 0: + raise ValueError("At least one argument (python filename or modulename) is required") + filename_or_modulename = args[0] + return filename_or_modulename, args + + +class Found(NamedTuple): + value: Optional[Any] + module: ModuleType + filename: str + is_temp: bool + + +ACCEPTED_FILE_EXTENSIONS = (".py", ".py3") + + +def get_or_create_module_from_name( + filename_or_modulename: str, + temp_modulename: str, +) -> Found: + from os import path + _, extension = path.splitext(filename_or_modulename) + if extension in ACCEPTED_FILE_EXTENSIONS: + filename = path.abspath(filename_or_modulename) + module = create_module(temp_modulename, filename) + is_temp = True + value = None + else: + imported = import_from_path(filename_or_modulename) + if isinstance(imported, ModuleType): + module = imported + value = None + else: + module = inspect.getmodule(imported) + value = imported + filename = module.__file__ + is_temp = False + return Found(value, module, filename, is_temp) diff --git a/pyproject.toml b/pyproject.toml index 07313eb9..e810a5a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ clear_runtime = "ghostos.scripts.clear_runtime:main" # web app cli hello = "ghostos.scripts.cli.run_helloworld:main" aifunc = "ghostos.scripts.cli.run_aifunc:main" +console = "ghostos.scripts.cli.run_console:main" [build-system] requires = ["poetry-core"] From 7a796e25f575d6bd87c67e9bf595024a0bbd1f0a Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 18 Nov 2024 00:22:27 +0800 Subject: [PATCH 091/148] dev: script debug test pass through and fix some bugs. left small details in future --- .ghostos/logging.yml | 15 +- .ghostos/runtime/logs/.gitignore | 2 + ghostos/abcd/concepts.py | 5 +- ghostos/abcd/thoughts.py | 2 +- ghostos/abcd/utils.py | 32 +- ghostos/bootstrap.py | 14 +- ghostos/contracts/pool.py | 4 +- ghostos/core/ghostos.py | 351 ------------------ ghostos/core/messages/message.py | 7 +- ghostos/core/messages/transport.py | 81 ++-- .../framework/ghostos/conversation_impl.py | 24 +- ghostos/framework/ghostos/ghostos_impl.py | 8 +- ghostos/framework/ghostos/session_impl.py | 7 +- ghostos/framework/ghostos/shell_impl.py | 20 +- ghostos/framework/messengers/defaults.py | 6 + ghostos/framework/threads/storage_threads.py | 4 + ghostos/ghosts/moss_agent/moss_agent.py | 2 +- ghostos/helpers/time.py | 2 +- ghostos/scripts/clear_runtime.py | 16 + ghostos/scripts/cli/run_console.py | 8 +- tests/contracts/test_pool.py | 18 + .../core/messages/test_arr_stream_receiver.py | 49 ++- tests/helpers/test_timeleft.py | 7 + 23 files changed, 220 insertions(+), 464 deletions(-) create mode 100644 .ghostos/runtime/logs/.gitignore delete mode 100644 ghostos/core/ghostos.py create mode 100644 tests/contracts/test_pool.py create mode 100644 tests/helpers/test_timeleft.py diff --git a/.ghostos/logging.yml b/.ghostos/logging.yml index 0bcb312f..53589133 100644 --- a/.ghostos/logging.yml +++ b/.ghostos/logging.yml @@ -5,14 +5,16 @@ version: 1 formatters: default: format: "%(asctime)s - %(name)s - %(levelname)s: %(message)s" - ghost: - format: "%(asctime)s - %(name)s - %(levelname)s: %(message)s - %(trace)s" handlers: - debug_file: + debug: class: logging.FileHandler formatter: default filename: debug.log + ghostos_log: + class: logging.handlers.RotatingFileHandler + formatter: default + filename: ".ghostos/runtime/logs/ghostos.log" console: class: logging.StreamHandler level: DEBUG @@ -20,12 +22,9 @@ handlers: stream: ext://sys.stdout loggers: - debug: - handlers: [ console ] - level: DEBUG console: handlers: [ console ] level: DEBUG ghostos: - handlers: [ console ] - level: INFO + handlers: [ ghostos_log, console ] + level: DEBUG diff --git a/.ghostos/runtime/logs/.gitignore b/.ghostos/runtime/logs/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/.ghostos/runtime/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index 0cf7859a..fe43d29a 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -215,8 +215,9 @@ def create_shell( self, name: str, shell_id: str, + *, + providers: Optional[List[Provider]] = None, process_id: Optional[str] = None, - *providers: Provider ) -> Shell: pass @@ -468,7 +469,7 @@ class Session(Generic[G], ABC): """ instance_count: ClassVar[int] = 0 - stream: Stream + upstream: Stream scope: Scope """the running scope of the session""" diff --git a/ghostos/abcd/thoughts.py b/ghostos/abcd/thoughts.py index d48faee0..43c9d937 100644 --- a/ghostos/abcd/thoughts.py +++ b/ghostos/abcd/thoughts.py @@ -67,7 +67,7 @@ def think(self, session: Session, prompt: Prompt) -> Tuple[Prompt, Optional[Oper prompt = action.update_prompt(prompt) llm_api = self.get_llm_api(session) - streaming = not session.stream.completes_only() + streaming = not session.upstream.completes_only() items = llm_api.deliver_chat_completion(prompt, streaming) messages, callers = session.respond(items, self.message_stage) prompt.added.extend(messages) diff --git a/ghostos/abcd/utils.py b/ghostos/abcd/utils.py index 67959eba..61503866 100644 --- a/ghostos/abcd/utils.py +++ b/ghostos/abcd/utils.py @@ -47,6 +47,7 @@ def is_ghost(value) -> bool: def fire_session_event(session: Session, event: Event) -> Optional[Operator]: event, op = session.parse_event(event) if op is not None: + session.logger.info("session event is intercepted and op %s is returned", op) return op if event is None: # if event is intercepted, stop the run. @@ -67,19 +68,18 @@ def destroy(self): def run_session_event(session: Session, event: Event, max_step: int) -> None: - with session: - op = InitOperator(event) - step = 0 - while op is not None: - step += 1 - if step > max_step: - raise RuntimeError(f"Max step {max_step} reached") - if not session.refresh(): - raise RuntimeError("Session refresh failed") - session.logger.info("start session op %s", op) - next_op = op.run(session) - session.logger.info("done session op %s", op) - op.destroy() - # session do save after each op - session.save() - op = next_op + op = InitOperator(event) + step = 0 + while op is not None: + step += 1 + if step > max_step: + raise RuntimeError(f"Max step {max_step} reached") + if not session.refresh(): + raise RuntimeError("Session refresh failed") + session.logger.info("start session op %s", op) + next_op = op.run(session) + session.logger.info("done session op %s", op) + op.destroy() + # session do save after each op + session.save() + op = next_op diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index b12030d7..eabf4392 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -1,10 +1,8 @@ from typing import List, Optional, Tuple from os.path import dirname, join - from ghostos.abcd import GhostOS - +from ghostos.contracts.logger import config_logging from ghostos.container import Container, Provider, Contracts - from ghostos.prototypes.ghostfunc import init_ghost_func, GhostFunc import dotenv @@ -241,22 +239,20 @@ def default_application_providers( # --- system bootstrap --- # def make_app_container( workspace_path: str, + logger_conf_path: str = "logging.yml", dotenv_file_path: str = ".env", app_providers: Optional[List[Provider]] = None, app_contracts: Optional[Contracts] = None, ) -> Container: """ make application global container - :param workspace_path: - :param dotenv_file_path: - :param app_providers: - :param app_contracts: - :return: """ # load env from dotenv file dotenv.load_dotenv(dotenv_path=join(workspace_path, dotenv_file_path)) # default logger name for GhostOS application - logger_name = os.environ.get("LoggerName", "debug") + logger_name = os.environ.get("LoggerName", "ghostos") + logger_filename = join(workspace_path, logger_conf_path) + config_logging(logger_filename) if app_providers is None: app_providers = default_application_providers(root_dir=workspace_path, logger_name=logger_name) diff --git a/ghostos/contracts/pool.py b/ghostos/contracts/pool.py index fe4516a8..b0adc1a3 100644 --- a/ghostos/contracts/pool.py +++ b/ghostos/contracts/pool.py @@ -54,4 +54,6 @@ def contract(self) -> Type: return Pool def factory(self, con: Container) -> Optional[Pool]: - return DefaultPool(self.size) + p = DefaultPool(self.size) + con.add_shutdown(p.shutdown) + return p diff --git a/ghostos/core/ghostos.py b/ghostos/core/ghostos.py deleted file mode 100644 index bec494e9..00000000 --- a/ghostos/core/ghostos.py +++ /dev/null @@ -1,351 +0,0 @@ -from typing import Optional -from abc import ABC, abstractmethod -from ghostos.entity import EntityMeta -from ghostos.core.messages import Stream -from ghostos.core.runtime import EventBus, Event, GoTasks, GoTaskStruct, GoProcess, GoProcesses -from ghostos.core.ghosts import Ghost, GhostConf, Inputs -from ghostos.contracts.logger import LoggerItf -from ghostos.contracts.shutdown import Shutdown -from ghostos.container import Container - - -class GhostOS(ABC): - """ - Ghost 自身的操作系统. - """ - - @abstractmethod - def container(self) -> Container: - """ - global default container. - """ - pass - - @abstractmethod - def register(self, ghost_conf: GhostConf) -> None: - """ - register a ghost_conf into the container. - :param ghost_conf: the meta of the ghost conf shall be able to create ghost in this ghost os. - """ - pass - - @abstractmethod - def get_ghost_meta(self, ghost_id: str) -> Optional[EntityMeta]: - """ - get ghost meta by ghost id - """ - pass - - @abstractmethod - def get_or_create_process( - self, *, - ghost_meta: EntityMeta, - session_id: str, - process_id: Optional[str] = None, - task_id: Optional[str] = None, - ) -> Optional[GoProcess]: - """ - get a process from session_id, if not exists, create one. - :param ghost_meta: to create ghost instance. - :param session_id: session_id is the ghost instance id. - :param process_id: - :param task_id: - :return: - """ - pass - - @abstractmethod - def make_ghost( - self, *, - upstream: Stream, - process: GoProcess, - task: Optional[GoTaskStruct] = None, - task_id: Optional[str] = None, - ) -> Ghost: - """ - make a ghost instance. - :param upstream: upstream that ghost sending messages to. Each round of thought shall send a Final package. - once upstream is stopped, the ghost will stop as well. - :param process: process to make ghost instance. - :param task: if task is None, ghost will instance on the process main task. - :param task_id: if task_id is not None, ghost will find the target task to instance on. - """ - pass - - def on_inputs( - self, - inputs: Inputs, - upstream: Stream, - is_async: bool = False, - ) -> str: - """ - handle and inputs by ghostos. GhostOS will: - 1. check the inputs, intercept it if necessary - 2. wrap the inputs to a event - 3. send event to event bus - 4. handle event immediately if get the task's lock - - :param inputs: inputs to a ghost instance. - :param upstream: the stream that ghost sending messages to. - :param is_async: if is_async, ghost would not run, but send event only. - :return: main task id - """ - pass - - def background_run(self, upstream: Stream) -> Optional[Event]: - """ - 尝试从 EventBus 中获取一个 task 的信号, 并运行它. - """ - pass - - @abstractmethod - def handle_ghost_event(self, *, ghost: Ghost, event: Event) -> None: - """ - use ghost to handle event which belongs to the ghost session.task() - """ - pass - - @abstractmethod - def on_error(self, error: Exception) -> bool: - """ - :param error: handle error - :return: raise? - """ - pass - - @abstractmethod - def shutdown(self) -> None: - """ - graceful shutdown. - """ - pass - - -class AbsGhostOS(GhostOS, ABC): - """ - GhostOS abstract base class. - """ - - @abstractmethod - def container(self) -> Container: - """ - 全局默认的 container. - """ - pass - - def _logger(self) -> LoggerItf: - """ - return logger instance - """ - return self.container().force_fetch(LoggerItf) - - def _eventbus(self) -> EventBus: - """ - 返回全局的 EventBus. - """ - return self.container().force_fetch(EventBus) - - def _processes(self) -> GoProcesses: - return self.container().force_fetch(GoProcesses) - - def _tasks(self) -> GoTasks: - return self.container().force_fetch(GoTasks) - - @abstractmethod - def make_ghost( - self, *, - upstream: Stream, - process: GoProcess, - task: Optional[GoTaskStruct] = None, - task_id: Optional[str] = None, - ) -> Ghost: - """ - 使用 Session 实例化当前的 Ghost. - """ - pass - - def get_or_create_process( - self, *, - ghost_meta: EntityMeta, - session_id: str, - process_id: Optional[str] = None, - task_id: Optional[str] = None, - ) -> Optional[GoProcess]: - processes = self._processes() - proc = processes.get_session_process(session_id) - if proc is None or (process_id and process_id != proc.pid): - proc = GoProcess.new( - session_id=session_id, - ghost_meta=ghost_meta, - process_id=process_id, - main_task_id=task_id, - ) - return proc - - def on_inputs( - self, - inputs: Inputs, - upstream: Stream, - is_async: bool = False, - ) -> str: - """ - 处理同步请求. deliver 的实现应该在外部. - 这个方法是否异步执行, 也交给外部判断. - :param inputs: 同步请求的参数. - :param upstream: 对上游输出的 output - :param is_async: 是否异步运行. - :returns: task_id - """ - # 获取基本参数. - session_id = inputs.session_id - ghost_meta = self.get_ghost_meta(inputs.ghost_id) - if ghost_meta is None: - raise NotImplementedError(f"ghost {inputs.ghost_id} does not defined") - # 寻找已经存在的进程. - proc = self.get_or_create_process( - ghost_meta=ghost_meta, - session_id=session_id, - process_id=inputs.process_id, - task_id=inputs.task_id, - ) - - # 生成 ghost 实例. - ghost = self.make_ghost(upstream=upstream, process=proc, task_id=inputs.task_id) - try: - # pre-process input. stateless. if event is None, inputs was intercepted. - event = ghost.on_inputs(inputs) - if event is None: - return "" - - # 发送事件. - eventbus = self._eventbus() - if not is_async: - self.handle_ghost_event(ghost=ghost, event=event) - ghost.done() - else: - ghost.done() - eventbus.send_event(event, notify=True) - return event.task_id - except Exception as e: - ghost.fail(e) - if self.on_error(e): - raise - finally: - ghost.destroy() - - def background_run(self, upstream: Stream) -> Optional[Event]: - """ - 尝试从 EventBus 中获取一个 task 的信号, 并运行它. - """ - try: - # 暂时不传递 timeout. - return self._background_run(upstream) - except Exception as e: - if self.on_error(e): - raise - - def _background_run(self, upstream: Stream) -> Optional[Event]: - """ - 尝试从 eventbus 里 pop 一个事件, 然后运行. - 外部系统应该管理所有资源分配, 超时的逻辑. - """ - eventbus = self._eventbus() - # at least one success. - task_id = eventbus.pop_task_notification() - # 没有读取到任何全局任务. - if task_id is None: - return None - return self._background_run_task(upstream=upstream, task_id=task_id) - - def _background_run_task( - self, *, - upstream: Stream, - task_id: str, - ) -> Optional[Event]: - """ - 指定一个 task id, 尝试运行它的事件. - :param upstream: - :param task_id: 指定的 task id - :return: continue? - """ - tasks = self._tasks() - eventbus = self._eventbus() - task = tasks.get_task(task_id, lock=True) - if task is None: - return None - lock = task.lock - locked = lock is not None - # task 没有抢到锁. - if not locked: - eventbus.notify_task(task_id) - return None - # 先创建 session. - processes = self._processes() - proc = processes.get_process(task.process_id) - # process is quited - if proc.quited: - self._eventbus().clear_task(task_id) - return None - ghost = self.make_ghost(upstream=upstream, process=proc, task=task) - try: - if not ghost.session().refresh_lock(): - return None - - e = eventbus.pop_task_event(task_id) - if e is None: - return None - self.handle_ghost_event(ghost=ghost, event=e) - ghost.done() - eventbus.notify_task(task_id) - return e - except Exception as err: - ghost.fail(err) - eventbus.notify_task(task_id) - raise - finally: - # 任何时间都要解锁. - ghost.destroy() - - def handle_ghost_event(self, *, ghost: Ghost, event: Event) -> None: - """ - 使用 ghost 实例运行一个事件. - :param ghost: - :param event: - :return: - """ - # 先按需做初始化. - ghost.utils().initialize() - # bind origin event - self._handle_ghost_event(ghost=ghost, event=event) - - @staticmethod - def _handle_ghost_event(ghost: Ghost, event: Event) -> None: - # 然后才真正运行逻辑. - op, max_op = ghost.init_operator(event) - count = 0 - while op is not None: - if count > max_op: - # todo: too much operator shall raise an error. - raise RuntimeError(f"stackoverflow") - # todo: log op - _next = op.run(ghost) - count += 1 - op = _next - # 结束运行. - ghost.save() - - @abstractmethod - def destroy(self) -> None: - """ - 垃圾回收的方法. - """ - pass - - def shutdown(self) -> None: - """ - 优雅退出的机制. - """ - shutdown = self.container().get(Shutdown) - if shutdown is not None: - shutdown.shutdown() - self.destroy() diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index a9be8731..3aa3e5f0 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -78,8 +78,7 @@ def new( memory: Optional[str] = None, name: Optional[str] = None, ) -> "Message": - chunk = not self.is_protocol_type(self.value) - return Message(content=content, memory=memory, name=name, type=self.value, role=role, chunk=chunk) + return Message(content=content, memory=memory, name=name, type=self.value, role=role).as_tail() def new_assistant( self, *, @@ -106,7 +105,7 @@ def new_user( @classmethod def final(cls): - return Message(type=cls.FINAL.value, role=Role.ASSISTANT.value, chunk=False) + return Message(type=cls.FINAL.value, role="").as_tail() def match(self, message: "Message") -> bool: return message.type == self.value @@ -407,7 +406,7 @@ def is_complete(self) -> bool: """ complete message is not a chunk one """ - return self.seq == "complete" + return self.seq == "complete" or MessageType.is_protocol_type(self.type) def is_head(self) -> bool: return self.seq == "head" diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index ee09afe9..60d4aad6 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -9,6 +9,8 @@ __all__ = ["Stream", "Receiver", "ArrayReceiver", "ArrayStream", "new_arr_connection"] +from ghostos.helpers import Timeleft + class Stream(Protocol): """ @@ -60,11 +62,15 @@ def fail(self, error: str) -> bool: def error(self) -> Optional[Message]: pass + @abstractmethod + def closed(self) -> bool: + pass + def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb) -> Optional[bool]: - if not self.alive(): + if self.closed(): return None intercept = None if exc_val is not None: @@ -92,7 +98,7 @@ def fail(self, error: str) -> bool: pass @abstractmethod - def done(self) -> bool: + def closed(self) -> bool: pass @abstractmethod @@ -111,7 +117,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb) -> Optional[bool]: - if self.done(): + if self.closed(): return None intercept = None if exc_val is not None: @@ -122,11 +128,15 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> Optional[bool]: class ArrayReceiver(Receiver): - def __init__(self, alive: Callable[[], bool], idle: float = 0.1, complete_only: bool = False): - self._check_alive = alive + def __init__( + self, + timeleft: Timeleft, + idle: float = 0.1, + complete_only: bool = False, + ): + self._timeleft = timeleft self._idle = idle self._chunks = deque() - self._completes = [] self._closed = False self._done = False self._error: Optional[Message] = None @@ -135,15 +145,13 @@ def __init__(self, alive: Callable[[], bool], idle: float = 0.1, complete_only: def recv(self) -> Iterable[Message]: if self._closed: raise RuntimeError("Receiver is closed") - alive = self._check_alive while not self._done: if len(self._chunks) > 0: item = self._chunks.popleft() yield item continue - is_alive = alive() - if not is_alive: - self._error = MessageType.ERROR.new(content="Receiver is closed") + if not self._timeleft.alive(): + self._error = MessageType.ERROR.new(content=f"Timeout after {self._timeleft.passed()}") self._done = True break if self._idle: @@ -155,59 +163,59 @@ def recv(self) -> Iterable[Message]: yield self._error def add(self, message: Message) -> bool: - if self._closed or self._done: - return False - if not self._check_alive(): + if self._closed: return False if MessageType.is_protocol_message(message): self._done = True if MessageType.ERROR.match(message): self._error = message return True + + elif self._done or not self._timeleft.alive(): + return False else: if message.is_complete() or not self._complete_only: self._chunks.append(message) - if message.is_complete(): - self._completes.append(message.get_copy()) return True def cancel(self): self._done = True def fail(self, error: str) -> bool: + if self._error is not None: + return False self._done = True self._error = MessageType.ERROR.new(content=error) return False - def done(self) -> bool: - return self._done + def closed(self) -> bool: + return self._closed def error(self) -> Optional[Message]: return self._error def wait(self) -> List[Message]: - while not self._done and not self._closed and not self._error: - time.sleep(self._idle) - completes = self._completes.copy() - if self._error is not None: - completes.append(self._error) + items = list(self.recv()) + completes = [] + for item in items: + if item.is_complete(): + completes.append(item) return completes def close(self): if self._closed: return + self._closed = True self._done = True - self._error = None self._chunks = [] - self._completes = [] - del self._check_alive + self._timeleft = None class ArrayStream(Stream): def __init__(self, receiver: ArrayReceiver, complete_only: bool): self._receiver = receiver - self._alive = not receiver.done() + self._alive = not receiver.closed() self._closed = False self._error: Optional[Message] = None self._complete_only = complete_only @@ -238,31 +246,34 @@ def completes_only(self) -> bool: def alive(self) -> bool: if not self._alive: return False - if self._receiver.done(): + if self._receiver.closed(): self._alive = False return self._alive def close(self): if self._closed: return - if self._alive: + self._closed = True + if self._error: + self._receiver.add(self._error) + else: self._receiver.add(MessageType.final()) self._alive = False - self._closed = True del self._receiver def fail(self, error: str) -> bool: if self._error is not None: return False self._error = MessageType.ERROR.new(content=error) - if self._alive: - self._receiver.add(self._error) self._alive = False return False def error(self) -> Optional[Message]: return self._error + def closed(self) -> bool: + return self._closed + def new_arr_connection( *, @@ -279,12 +290,6 @@ def new_arr_connection( """ from ghostos.helpers import Timeleft timeleft = Timeleft(timeout) - - def alive_check() -> bool: - if not timeleft.alive(): - return False - return True - - receiver = ArrayReceiver(alive_check, idle, complete_only) + receiver = ArrayReceiver(timeleft, idle, complete_only) stream = ArrayStream(receiver, complete_only) return stream, receiver diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index 5b6e77d3..e96ba177 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -91,6 +91,7 @@ def get_artifact(self) -> Tuple[Union[Ghost.ArtifactType, None], TaskState]: return session.get_artifact(), TaskState(session.task.state) def talk(self, query: str, user_name: str = "") -> Receiver: + self._logger.info("talk to user %s", user_name) message = Role.USER.new(content=query, name=user_name) return self.respond([message]) @@ -129,15 +130,22 @@ def _validate_closed(self): raise RuntimeError(f"Conversation is closed") def _submit_session_event(self, event: Event, stream: Stream) -> None: - with stream: - task = self._tasks.get_task(event.task_id) - session = self._create_session(task, self._locker, stream) - with session: - try: + self._logger.debug("submit session event") + try: + with stream: + task = self._tasks.get_task(event.task_id) + session = self._create_session(task, self._locker, stream) + self._logger.debug( + f"create session from event id %s, task_id is %s", + event.event_id, task.task_id, + ) + with session: run_session_event(session, event, self._conf.max_session_step) - self._eventbus.notify_task(event.task_id) - except Exception as e: - self.fail(error=e) + except Exception as e: + self._logger.exception(e) + self.fail(error=e) + finally: + self._eventbus.notify_task(event.task_id) def _create_session( self, diff --git a/ghostos/framework/ghostos/ghostos_impl.py b/ghostos/framework/ghostos/ghostos_impl.py index bd307f65..dbb0346d 100644 --- a/ghostos/framework/ghostos/ghostos_impl.py +++ b/ghostos/framework/ghostos/ghostos_impl.py @@ -1,4 +1,4 @@ -from typing import Optional, Dict +from typing import Optional, Dict, List from ghostos.abcd import GhostOS, Shell from ghostos.core.runtime import GoProcesses, GoProcess, GoThreads, GoTasks, EventBus @@ -53,8 +53,9 @@ def create_shell( self, name: str, shell_id: str, + *, + providers: Optional[List[Provider]] = None, process_id: Optional[str] = None, - *providers: Provider, ) -> Shell: if name not in self._ghostos_config.shells: raise NotImplementedError(f"Shell `{name}` not implemented") @@ -68,11 +69,12 @@ def create_shell( # prepare container container = Container(parent=self._container) + providers = providers or [] return ShellImpl( config=shell_conf, container=container, process=process, - *providers, + providers=providers, ) diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 4c42da4b..36d3d552 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -63,7 +63,7 @@ def __init__( shell_id=task.shell_id, process_id=task.process_id, task_id=task.task_id, - parent_task_id=task.parent_task_id, + parent_task_id=task.parent, ) logger = container.force_fetch(LoggerItf) self.logger = wrap_logger( @@ -72,7 +72,7 @@ def __init__( ) self.ghost: G = get_entity(self.task.meta, Ghost) - self.ghost_driver: GhostDriver[G] = self.ghost.DriverType(self.ghost) + self.ghost_driver: GhostDriver[G] = get_ghost_driver(self.ghost) identifier = get_identifier(self.ghost) variables = container.force_fetch(Variables) self._message_parser = MessageKindParser( @@ -371,12 +371,13 @@ def __exit__(self, exc_type, exc_val, exc_tb): else: self.save() self.destroy() + return None def fail(self, err: Optional[Exception]) -> bool: if self._failed: return True self._failed = True - self.logger.error("Session failed: %s", err) + self.logger.exception("Session failed: %s", err) if self.upstream is not None: message = MessageType.ERROR.new(content=str(err)) self.upstream.deliver(message) diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index 9826153e..8ca57e44 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -1,7 +1,8 @@ import time from typing import Union, Optional, Iterable, List, Tuple, TypeVar -from ghostos.container import Container +from ghostos.contracts.logger import LoggerItf +from ghostos.container import Container, Provider from ghostos.abcd import Shell, Conversation, Ghost, Scope, Background from ghostos.abcd.utils import get_ghost_driver from ghostos.core.messages import Message, Receiver @@ -10,7 +11,6 @@ GoTasks, TaskState, GoTaskStruct, ) from ghostos.core.messages import Stream -from ghostos.container import Provider from ghostos.helpers import uuid, Timeleft from ghostos.identifier import get_identifier from ghostos.entity import to_entity_meta @@ -41,17 +41,14 @@ def __init__( config: ShellConf, container: Container, process: GoProcess, - *providers: Provider, + providers: List[Provider], ): self._conf = config # prepare container for provider in providers: container.register(provider) self._container = container - # bind self - self._container.set(Shell, self) - self._container.set(ShellImpl, self) - self._container.set(ShellConf, config) + self._shell_id = process.shell_id self._process_id = process.process_id self._scope = Scope( @@ -64,7 +61,12 @@ def __init__( self._workers: List[Thread] = [] self._closed = False self._background_started = False + self._logger = container.force_fetch(LoggerItf) # bootstrap the container. + # bind self + self._container.set(Shell, self) + self._container.set(ShellImpl, self) + self._container.set(ShellConf, config) self._container.bootstrap() def container(self) -> Container: @@ -81,9 +83,13 @@ def send_event(self, event: Event) -> None: def sync(self, ghost: Ghost, context: Optional[Ghost.ContextType] = None) -> Conversation: driver = get_ghost_driver(ghost) task_id = driver.make_task_id(self._scope) + self._logger.debug("sync ghost with task id %s", task_id) + task = self._tasks.get_task(task_id) if task is None: task = self.create_root_task(ghost, context) + self._logger.info("create root task task id %s for ghost", task_id) + task.meta = to_entity_meta(ghost) if context is not None: task.context = to_entity_meta(context) diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index c21ce330..c00194d2 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -86,3 +86,9 @@ def error(self) -> Optional[Message]: if self._upstream is not None: return self._upstream.error() return None + + def closed(self) -> bool: + return self._upstream is None or self._upstream.closed() + + + diff --git a/ghostos/framework/threads/storage_threads.py b/ghostos/framework/threads/storage_threads.py index 508ff817..a56ca22d 100644 --- a/ghostos/framework/threads/storage_threads.py +++ b/ghostos/framework/threads/storage_threads.py @@ -26,6 +26,10 @@ def __init__( def get_thread(self, thread_id: str, create: bool = False) -> Optional[GoThreadInfo]: path = self._get_thread_filename(thread_id) if not self._storage.exists(path): + if create: + thread = GoThreadInfo(id=thread_id) + self.save_thread(thread) + return thread return None content = self._storage.get(path) data = yaml.safe_load(content) diff --git a/ghostos/ghosts/moss_agent/moss_agent.py b/ghostos/ghosts/moss_agent/moss_agent.py index 19efd7ea..399ac400 100644 --- a/ghostos/ghosts/moss_agent/moss_agent.py +++ b/ghostos/ghosts/moss_agent/moss_agent.py @@ -129,7 +129,7 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: # call llm llm = self.get_llmapi(session) - messages = llm.deliver_chat_completion(prompt, not session.stream.completes_only()) + messages = llm.deliver_chat_completion(prompt, not session.upstream.completes_only()) messages, callers = session.respond(messages, remember=True) # handle actions diff --git a/ghostos/helpers/time.py b/ghostos/helpers/time.py index 0c272174..883a3e5c 100644 --- a/ghostos/helpers/time.py +++ b/ghostos/helpers/time.py @@ -16,7 +16,7 @@ def left(self) -> float: return timeleft def alive(self) -> bool: - return self.timeout < 0 or self.passed() < self.timeout + return self.timeout <= 0 or self.passed() < self.timeout def passed(self) -> float: now = time.time() diff --git a/ghostos/scripts/clear_runtime.py b/ghostos/scripts/clear_runtime.py index c3f03dfa..017d54d6 100644 --- a/ghostos/scripts/clear_runtime.py +++ b/ghostos/scripts/clear_runtime.py @@ -67,6 +67,14 @@ def main(): "--cache", "-c", action="store_true", ) + parser.add_argument( + "--prompts", "-m", + action="store_true", + ) + parser.add_argument( + "--logs", "-l", + action="store_true", + ) from ghostos.bootstrap import workspace_dir runtime_dir = join(workspace_dir, "runtime") parsed = parser.parse_args(sys.argv[1:]) @@ -89,6 +97,14 @@ def main(): cleared = clear_directory(join(runtime_dir, "cache"), recursive=True) done += 1 print(f"clear runtime/cache files: {cleared}") + if _all or parsed.prompts: + cleared = clear_directory(join(runtime_dir, "prompts"), recursive=True) + done += 1 + print(f"clear runtime/cache files: {cleared}") + if _all or parsed.logs: + cleared = clear_directory(join(runtime_dir, "logs"), recursive=True) + done += 1 + print(f"clear runtime/cache files: {cleared}") if not done: print(f"no files cleared. please check arguments by '-h' option") diff --git a/ghostos/scripts/cli/run_console.py b/ghostos/scripts/cli/run_console.py index 4c6d324e..fc768f34 100644 --- a/ghostos/scripts/cli/run_console.py +++ b/ghostos/scripts/cli/run_console.py @@ -1,10 +1,8 @@ from ghostos.abcd import Ghost - from ghostos.scripts.cli.utils import ( check_ghostos_workspace_exists, parse_args_modulename_or_filename, get_or_create_module_from_name, ) - from ghostos.bootstrap import make_app_container, get_ghostos @@ -35,11 +33,11 @@ def main(): "console", ) conversation = shell.sync(ghost) + exit(0) with conversation: - print(conversation.task()) - exit(0) receiver = conversation.talk("hello") with receiver: messages = receiver.wait() - print(messages) + for item in messages: + print(item.get_content()) shell.close() diff --git a/tests/contracts/test_pool.py b/tests/contracts/test_pool.py new file mode 100644 index 00000000..3a7d5b6d --- /dev/null +++ b/tests/contracts/test_pool.py @@ -0,0 +1,18 @@ +from ghostos.contracts.pool import DefaultPool + + +def test_default_pool(): + class Foo: + count = 0 + + def go(self): + self.count += 1 + + pool = DefaultPool(4) + foo = Foo() + pool.submit(foo.go) + pool.submit(foo.go) + pool.submit(foo.go) + pool.submit(foo.go) + pool.shutdown() + assert foo.count == 4 diff --git a/tests/core/messages/test_arr_stream_receiver.py b/tests/core/messages/test_arr_stream_receiver.py index 94ef5f19..0fbe362c 100644 --- a/tests/core/messages/test_arr_stream_receiver.py +++ b/tests/core/messages/test_arr_stream_receiver.py @@ -1,5 +1,5 @@ from typing import Iterable -from ghostos.core.messages.transport import new_arr_connection, Stream, Receiver +from ghostos.core.messages.transport import new_arr_connection, Stream from ghostos.core.messages.message import Message from threading import Thread import time @@ -16,7 +16,7 @@ def iter_content(content: str, gap: float) -> Iterable[Message]: def test_new_connection_baseline(): stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) assert stream.alive() - assert not retriever.done() + assert not retriever.closed() content = "hello world, ha ha ha ha" def send_data(s: Stream, c: str): @@ -59,9 +59,10 @@ def send_data(s: Stream, c: str): t.start() with retriever: messages = list(retriever.recv()) - assert retriever.done() + assert retriever.closed() assert retriever.error() is not None assert not stream.alive() + assert len(messages) > 0 t.join() @@ -101,9 +102,11 @@ def send_data(s: Stream, c: str): t.start() with retriever: messages = list(retriever.recv()) - assert retriever.done() - assert retriever.error() is not None - assert not stream.alive() + + assert retriever.closed() + assert retriever.error() is not None + assert not stream.alive() + assert messages[-1] is retriever.error() t.join() @@ -122,3 +125,37 @@ def send_data(s: Stream, c: str): assert messages[len(content)].is_complete() assert messages[len(content)].content == content assert messages[3].get_seq() == "chunk" + + +def test_new_connection_wait(): + stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + content = "hello world" + + def send_data(s: Stream, c: str): + with s: + s.send(iter_content(c, 0.02)) + + t = Thread(target=send_data, args=(stream, content)) + t.start() + with retriever: + retriever.wait() + t.join() + + +def test_new_connection_with_pool(): + from ghostos.contracts.pool import DefaultPool + pool = DefaultPool(10) + stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + content = "hello world" + + def send_data(s: Stream, c: str): + with s: + s.send(iter_content(c, 0.02)) + + pool.submit(send_data, stream, content) + + with retriever: + messages = retriever.wait() + assert len(messages) == 1 + assert retriever.error() is None + pool.shutdown(wait=True) diff --git a/tests/helpers/test_timeleft.py b/tests/helpers/test_timeleft.py new file mode 100644 index 00000000..bd83fdda --- /dev/null +++ b/tests/helpers/test_timeleft.py @@ -0,0 +1,7 @@ +from ghostos.helpers import Timeleft + + +def test_timeleft_with_zero(): + left = Timeleft(0) + assert left.alive() + assert left.alive() From 2e62d13e8f016e7b25212ce4c425598f51af7e54 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 18 Nov 2024 16:10:54 +0800 Subject: [PATCH 092/148] fix: fix stream and messenger duplicated complete message, and logs --- .ghostos/logging.yml | 2 +- ghostos/abcd/__init__.py | 9 +- ghostos/abcd/concepts.py | 5 +- ghostos/bootstrap.py | 4 - ghostos/container.py | 3 +- ghostos/contracts/logger.py | 4 + ghostos/core/messages/__init__.py | 2 +- ghostos/core/messages/message.py | 13 +- ghostos/core/messages/pipeline.py | 32 +- ghostos/core/messages/transport.py | 16 +- .../core/messages/{helpers.py => utils.py} | 0 ghostos/core/moss/impl.py | 8 + ghostos/core/runtime/__init__.py | 1 - ghostos/core/runtime/messenger.py | 43 --- ghostos/core/runtime/tasks.py | 10 +- ghostos/core/runtime/threads.py | 5 +- .../framework/ghostos/conversation_impl.py | 3 + ghostos/framework/ghostos/session_impl.py | 7 +- ghostos/framework/ghostos/shell_impl.py | 4 +- ghostos/framework/logger/named.py | 7 +- ghostos/framework/messengers/__init__.py | 2 +- ghostos/framework/messengers/defaults.py | 51 ++- ghostos/framework/session/__init__.py | 1 - ghostos/framework/session/basic.py | 330 ------------------ ghostos/framework/tasks/storage_tasks.py | 6 +- ghostos/helpers/__init__.py | 2 +- ghostos/helpers/{time.py => timeutils.py} | 12 +- .../core/messages/test_arr_stream_receiver.py | 57 +-- tests/core/messages/test_pipeline.py | 37 ++ tests/core/moss/examples/test_baseline.py | 5 +- tests/framework/messenger/test_messenger.py | 18 +- tests/python/test_pydantic.py | 9 + 32 files changed, 236 insertions(+), 472 deletions(-) rename ghostos/core/messages/{helpers.py => utils.py} (100%) delete mode 100644 ghostos/core/runtime/messenger.py delete mode 100644 ghostos/framework/session/__init__.py delete mode 100644 ghostos/framework/session/basic.py rename ghostos/helpers/{time.py => timeutils.py} (65%) create mode 100644 tests/core/messages/test_pipeline.py diff --git a/.ghostos/logging.yml b/.ghostos/logging.yml index 53589133..408a3d8b 100644 --- a/.ghostos/logging.yml +++ b/.ghostos/logging.yml @@ -4,7 +4,7 @@ version: 1 formatters: default: - format: "%(asctime)s - %(name)s - %(levelname)s: %(message)s" + format: "%(asctime)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)" handlers: debug: diff --git a/ghostos/abcd/__init__.py b/ghostos/abcd/__init__.py index 19393093..407a3ff3 100644 --- a/ghostos/abcd/__init__.py +++ b/ghostos/abcd/__init__.py @@ -1,8 +1,9 @@ from ghostos.abcd.concepts import ( - GhostOS, Ghost, GhostDriver, Operator, - Session, Scope, StateValue, Action, Shell, - Background, - Conversation, Context, + GhostOS, Ghost, GhostDriver, Shell, + Operator, Action, + Session, Scope, StateValue, Messenger, + Background, Conversation, + Context, Taskflow, Subtasks, ) from ghostos.abcd.ghosts import Agent diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index fe43d29a..a8bb7bb8 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -51,8 +51,9 @@ """ __all__ = ( - "Ghost", "Session", "GhostDriver", "GhostOS", "Operator", "StateValue", "Action", - "Shell", "Scope", "Conversation", "Background", + "Ghost", "GhostDriver", "GhostOS", "Shell", "Conversation", "Background", + "Operator", "Action", + "Session", "Messenger", "StateValue", "Scope", "Taskflow", "Subtasks", "Context", ) diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index eabf4392..c44ce8c1 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -239,7 +239,6 @@ def default_application_providers( # --- system bootstrap --- # def make_app_container( workspace_path: str, - logger_conf_path: str = "logging.yml", dotenv_file_path: str = ".env", app_providers: Optional[List[Provider]] = None, app_contracts: Optional[Contracts] = None, @@ -251,9 +250,6 @@ def make_app_container( dotenv.load_dotenv(dotenv_path=join(workspace_path, dotenv_file_path)) # default logger name for GhostOS application logger_name = os.environ.get("LoggerName", "ghostos") - logger_filename = join(workspace_path, logger_conf_path) - config_logging(logger_filename) - if app_providers is None: app_providers = default_application_providers(root_dir=workspace_path, logger_name=logger_name) if app_contracts is None: diff --git a/ghostos/container.py b/ghostos/container.py index c716d616..aceb2005 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -168,6 +168,7 @@ def __init__(self, parent: Optional[Container] = None, inherit: bool = True): Container.instance_count += 1 def __del__(self): + self.destroy() Container.instance_count -= 1 def _inherit(self, parent: Container): @@ -175,7 +176,7 @@ def _inherit(self, parent: Container): inherit none singleton provider from parent """ for provider in parent.providers(recursively=True): - if not provider.inheritable() and not isinstance(provider, Bootstrapper): + if provider.inheritable() and not isinstance(provider, Bootstrapper): self._register(provider) def bootstrap(self) -> None: diff --git a/ghostos/contracts/logger.py b/ghostos/contracts/logger.py index 5f8014f6..6f10d5b1 100644 --- a/ghostos/contracts/logger.py +++ b/ghostos/contracts/logger.py @@ -3,6 +3,7 @@ from logging.config import dictConfig from logging import getLogger, LoggerAdapter, Logger from typing import Protocol, Optional +from os import path import yaml __all__ = [ @@ -110,6 +111,9 @@ def config_logging(conf_path: str) -> None: configurate logging by yaml config :param conf_path: absolute path of yaml config file """ + if not path.exists(conf_path): + return + with open(conf_path) as f: content = f.read() data = yaml.safe_load(content) diff --git a/ghostos/core/messages/__init__.py b/ghostos/core/messages/__init__.py index 3a0971d4..68d9e945 100644 --- a/ghostos/core/messages/__init__.py +++ b/ghostos/core/messages/__init__.py @@ -13,5 +13,5 @@ CompletionUsagePayload, ) from ghostos.core.messages.buffers import Buffer, Flushed -from ghostos.core.messages.helpers import copy_messages +from ghostos.core.messages.utils import copy_messages from ghostos.core.messages.transport import Stream, Receiver, new_arr_connection diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 3aa3e5f0..1c5d7f9c 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -1,6 +1,7 @@ from __future__ import annotations import enum import time +from datetime import datetime from typing import Optional, Dict, Set, Iterable, Union, List, Any, ClassVar, Type from typing_extensions import Self, Literal from abc import ABC, abstractmethod @@ -229,7 +230,7 @@ def new_head( """ if msg_id is None: msg_id = uuid() - created = round(time.time(), 4) + created = round(time.time(), 3) return cls( role=role, name=name, @@ -342,8 +343,8 @@ def as_head(self, copy: bool = True) -> Self: item = self if not item.msg_id: item.msg_id = uuid() - # if not self.created: - # item.created = time.time() + if not self.created: + item.created = round(time.time(), 3) if item.seq == "chunk": item.seq = "head" return item @@ -411,6 +412,9 @@ def is_complete(self) -> bool: def is_head(self) -> bool: return self.seq == "head" + def is_chunk(self) -> bool: + return self.seq == "chunk" + def get_seq(self) -> SeqType: return self.seq @@ -420,6 +424,9 @@ def dump(self) -> Dict: """ return self.model_dump(exclude_defaults=True) + def get_created(self) -> datetime: + return datetime.fromtimestamp(self.created) + def __str__(self): return self.get_content() diff --git a/ghostos/core/messages/pipeline.py b/ghostos/core/messages/pipeline.py index ad624946..20df562a 100644 --- a/ghostos/core/messages/pipeline.py +++ b/ghostos/core/messages/pipeline.py @@ -2,7 +2,7 @@ from typing_extensions import Self from abc import ABC, abstractmethod from ghostos.core.messages.message import Message, MessageType -from ghostos.core.messages.helpers import iter_messages +from ghostos.core.messages.utils import iter_messages class Pipe(ABC): @@ -27,6 +27,7 @@ def pipeline(pipes: Iterable[Pipe], messages: Iterable[Message]) -> Iterable[Mes outputs = messages for pipe in ordered: outputs = pipe.across(messages) + messages = outputs yield from outputs @@ -39,32 +40,35 @@ def new(self) -> Self: return SequencePipe() def across(self, messages: Iterable[Message]) -> Iterable[Message]: - head: Optional[Message] = None + buffer: Optional[Message] = None final: Optional[Message] = None for item in messages: if MessageType.is_protocol_message(item): final = item break - if head is None: + if buffer is None: if item.is_complete(): - yield item + buffer = item + continue else: - head = item.as_head() - yield head.get_copy() + # yield head + buffer = item.as_head() + yield buffer.get_copy() + continue else: - patched = head.patch(item) + patched = buffer.patch(item) if patched: if patched.is_complete(): - head = patched - yield patched.get_copy() + buffer = patched + continue else: yield item.get_copy() else: - yield head.as_tail() - head = item.as_head() - yield head.get_copy() - if head is not None: - yield head.as_tail(copy=False) + yield buffer.as_tail() + buffer = item.as_head() + continue + if buffer is not None: + yield buffer.as_tail(copy=False) if final is not None: yield final diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index 60d4aad6..0ccd6347 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -136,7 +136,7 @@ def __init__( ): self._timeleft = timeleft self._idle = idle - self._chunks = deque() + self._streaming = deque() self._closed = False self._done = False self._error: Optional[Message] = None @@ -146,8 +146,8 @@ def recv(self) -> Iterable[Message]: if self._closed: raise RuntimeError("Receiver is closed") while not self._done: - if len(self._chunks) > 0: - item = self._chunks.popleft() + if len(self._streaming) > 0: + item = self._streaming.popleft() yield item continue if not self._timeleft.alive(): @@ -156,9 +156,9 @@ def recv(self) -> Iterable[Message]: break if self._idle: time.sleep(self._idle) - if len(self._chunks) > 0: - yield from self._chunks - self._chunks = [] + if len(self._streaming) > 0: + yield from self._streaming + self._streaming.clear() if self._error is not None: yield self._error @@ -175,7 +175,7 @@ def add(self, message: Message) -> bool: return False else: if message.is_complete() or not self._complete_only: - self._chunks.append(message) + self._streaming.append(message) return True def cancel(self): @@ -207,7 +207,7 @@ def close(self): return self._closed = True self._done = True - self._chunks = [] + self._streaming.clear() self._timeleft = None diff --git a/ghostos/core/messages/helpers.py b/ghostos/core/messages/utils.py similarity index 100% rename from ghostos/core/messages/helpers.py rename to ghostos/core/messages/utils.py diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index d4e8c28d..f561ad9f 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -30,8 +30,12 @@ def __init__(self, *, container: Container, pycontext: Optional[PyContext] = Non } self._injections: Dict[str, Any] = {} self._attr_prompts: List = [] + self._compiled = False self._destroyed = False + def __del__(self): + self.destroy() + def container(self) -> Container: return self._container @@ -89,6 +93,7 @@ def _new_runtime(self, module: ModuleType) -> "MossRuntime": attr_prompts = {} for attr_name, value in self._attr_prompts: attr_prompts[attr_name] = value + self._compiled = True return MossRuntimeImpl( container=self._container, pycontext=self._pycontext.model_copy(deep=True), @@ -115,6 +120,8 @@ def destroy(self) -> None: return self._destroyed = True # container 先不 destroy. + if not self._compiled: + self._container.destroy() del self._container del self._pycontext del self._predefined_locals @@ -179,6 +186,7 @@ def __init__( def __del__(self): MossRuntime.instance_count -= 1 + self.destroy() def _compile_moss(self) -> Moss: moss_type = self.moss_type() diff --git a/ghostos/core/runtime/__init__.py b/ghostos/core/runtime/__init__.py index dda2298a..570b21ea 100644 --- a/ghostos/core/runtime/__init__.py +++ b/ghostos/core/runtime/__init__.py @@ -4,7 +4,6 @@ ) from ghostos.core.runtime.threads import GoThreads, GoThreadInfo, thread_to_prompt, Turn from ghostos.core.runtime.processes import GoProcess, GoProcesses -from ghostos.core.runtime.messenger import Messenger, Buffed from ghostos.core.runtime.events import Event, EventBus, EventTypes from ghostos.core.runtime.thread_history import ThreadHistory from ghostos.core.runtime.runtime import Runtime diff --git a/ghostos/core/runtime/messenger.py b/ghostos/core/runtime/messenger.py deleted file mode 100644 index 8e38e420..00000000 --- a/ghostos/core/runtime/messenger.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import NamedTuple, List, Tuple -from abc import ABC, abstractmethod -from ghostos.core.messages.message import Message, Caller, Role -from ghostos.core.messages.transport import Stream - -__all__ = ['Messenger', 'Buffed'] - - -class Buffed(NamedTuple): - messages: List[Message] - """ the sent messages, all chunks are joined""" - - callers: List[Caller] - """ the parsed callers from sent message""" - - -class Messenger(Stream, ABC): - """ - Messenger is a bridge of message streams - Messenger finish when the flush method is called. - Each messenger can nest sub messengers, when sub messenger is finished, - the parent messenger is not finished until the flush is called. - - why this is an abstract base class? - there may be more abilities during streaming are needed, - this project can only provide a basic one. - """ - - def say(self, content: str): - """ - syntactic sugar - """ - message = Role.ASSISTANT.new(content=content) - self.deliver(message) - - @abstractmethod - def flush(self) -> Tuple[List[Message], List[Caller]]: - """ - flush the buffed messages, finish the streaming of this messenger. - the message buffer shall join all the chunks to message item. - after the messenger is flushed, it can not send any new message. - """ - pass diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index 1dfda8a5..06f16f47 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -6,6 +6,7 @@ from ghostos.identifier import Identifier, Identical from ghostos.entity import EntityMeta from ghostos.core.messages import Payload +from ghostos.helpers import timestamp from contextlib import contextmanager __all__ = [ @@ -136,11 +137,11 @@ class GoTaskStruct(BaseModel): # --- time related --- # created: int = Field( - default_factory=lambda: int(round(time.time(), 0)), + default_factory=timestamp, description="The time the task was created.", ) updated: int = Field( - default=0, + default_factory=timestamp, description="The time the task was updated.", ) @@ -194,7 +195,9 @@ def add_child( self.children.append(task_id) child = self.new( task_id=task_id, + shell_id=self.shell_id, process_id=self.process_id, + depth=self.depth + 1, name=name, description=description, meta=meta, @@ -235,7 +238,7 @@ def new_turn(self) -> Self: """ return self.model_copy( update={ - "updated": round(time.time(), 4), + "updated": timestamp(), "turns": self.turns + 1, }, deep=True, @@ -357,7 +360,6 @@ def get_task_briefs(self, task_ids: List[str]) -> Dict[str, TaskBrief]: """ 获取多个任务的摘要信息. :param task_ids: - :param states: :return: """ pass diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index e0ec4e21..2e8c5e27 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -1,12 +1,11 @@ from typing import Optional, List, Iterable, Dict, Any, Self -import time from abc import ABC, abstractmethod from pydantic import BaseModel, Field from ghostos.core.messages import Message, copy_messages, Role from ghostos.core.moss.pycontext import PyContext from ghostos.core.llms import Prompt from ghostos.core.runtime.events import Event, EventTypes -from ghostos.helpers import uuid +from ghostos.helpers import uuid, timestamp from contextlib import contextmanager __all__ = [ @@ -37,7 +36,7 @@ class Turn(BaseModel): description="The PyContext instance", ) created: int = Field( - default_factory=lambda: int(round(time.time(), 0)), + default_factory=timestamp, ) extra: Dict[str, Any] = Field(default_factory=dict, description="extra information") diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index e96ba177..0d8c6616 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -178,6 +178,9 @@ def fail(self, error: Exception) -> bool: self.close() return False + def __del__(self): + self.close() + def close(self): if self._closed: return diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 36d3d552..81c6e6e4 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -1,7 +1,8 @@ from typing import Optional, List, Iterable, Tuple, TypeVar, Dict, Union, Any from ghostos.abcd import ( - Session, Ghost, GhostDriver, Shell, Scope, Taskflow, Operator, Subtasks + Session, Ghost, GhostDriver, Shell, Scope, Taskflow, Operator, Subtasks, + Messenger, ) from ghostos.abcd import get_ghost_driver from ghostos.core.messages import ( @@ -11,7 +12,7 @@ TaskBrief, GoTaskStruct, TaskLocker, TaskPayload, GoTasks, TaskState, EventBus, Event, EventTypes, GoThreads, - Messenger, GoThreadInfo, + GoThreadInfo, ) from ghostos.prompter import Prompter from ghostos.contracts.logger import wrap_logger, LoggerItf @@ -99,6 +100,7 @@ def __init__( def __del__(self): # for gc test Session.instance_count -= 1 + self.destroy() def _bootstrap(self): self.contracts.validate(self.container) @@ -387,7 +389,6 @@ def destroy(self) -> None: if self._destroyed: return self._destroyed = True - self.locker.release() del self.locker self.container.destroy() del self.container diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index 8ca57e44..2ba1e605 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -87,7 +87,7 @@ def sync(self, ghost: Ghost, context: Optional[Ghost.ContextType] = None) -> Con task = self._tasks.get_task(task_id) if task is None: - task = self.create_root_task(ghost, context) + task = self.create_root_task(task_id, ghost, context) self._logger.info("create root task task id %s for ghost", task_id) task.meta = to_entity_meta(ghost) @@ -158,10 +158,10 @@ def send_message(receiver: Receiver): def create_root_task( self, + task_id: str, ghost: Ghost, context: Optional[Ghost.ContextType], ) -> GoTaskStruct: - task_id = uuid() id_ = get_identifier(ghost) context_meta = to_entity_meta(context) if context else None task = GoTaskStruct.new( diff --git a/ghostos/framework/logger/named.py b/ghostos/framework/logger/named.py index 8dd27d3c..dc59f05e 100644 --- a/ghostos/framework/logger/named.py +++ b/ghostos/framework/logger/named.py @@ -26,5 +26,8 @@ def contract(self) -> Type[LoggerItf]: def factory(self, con: Container) -> Optional[LoggerItf]: logging.captureWarnings(True) - origin = logging.LoggerAdapter(logging.getLogger(self.logger_name)) - return origin + if self.logger_name in logging.Logger.manager.loggerDict: + logger = logging.getLogger(self.logger_name) + origin = logging.LoggerAdapter(logger) + return origin + return get_console_logger(name=self.logger_name) diff --git a/ghostos/framework/messengers/__init__.py b/ghostos/framework/messengers/__init__.py index da1d8e87..f162f3cd 100644 --- a/ghostos/framework/messengers/__init__.py +++ b/ghostos/framework/messengers/__init__.py @@ -1,2 +1,2 @@ -from ghostos.core.runtime import Messenger +from ghostos.abcd import Messenger from ghostos.framework.messengers.defaults import DefaultMessenger diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index c00194d2..47cd65f2 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -1,5 +1,5 @@ from typing import Optional, Iterable, List, Tuple -from ghostos.core.runtime.messenger import Messenger +from ghostos.abcd.concepts import Messenger from ghostos.core.messages import ( Message, Payload, Role, Stream, Caller, @@ -26,17 +26,41 @@ def __init__( self._assistant_name = name self._role = role if role else Role.ASSISTANT.value self._payloads = payloads - self._sent_messages = [] + self._sent_message_ids = [] + self._sent_messages = {} + self._sending: Optional[Message] = None self._sent_callers = [] self._stage = stage + self._destroyed = False def flush(self) -> Tuple[List[Message], List[Caller]]: - messages = self._sent_messages - callers = self._sent_callers + messages = [] + callers = [] + if self._sending is not None: + self._sent_message_ids.append(self._sending.msg_id) + self._sent_messages[self._sending.msg_id] = self._sending + message_ids = set(self._sent_message_ids) + for msg_id in message_ids: + message = self._sent_messages[msg_id] + messages.append(message) + if message.callers: + callers.extend(message.callers) + self.destroy() + return messages, callers + + def __del__(self): + self.destroy() + + def destroy(self): + if self._destroyed: + return + self._destroyed = True del self._upstream del self._sent_messages + del self._sent_message_ids del self._sent_callers - return messages, callers + del self._sending + del self._payloads def send(self, messages: Iterable[Message]) -> bool: messages = self.buffer(messages) @@ -48,20 +72,28 @@ def send(self, messages: Iterable[Message]) -> bool: def buffer(self, messages: Iterable[Message]) -> Iterable[Message]: messages = SequencePipe().across(messages) for item in messages: + # add message info if item.is_complete() or item.is_head(): item.name = self._assistant_name item.stage = self._stage if not item.role: item.role = self._role + # create buffer in case upstream is cancel + if item.is_head(): + self._sending = item.get_copy() + if item.is_chunk() and self._sending: + self._sending = self._sending.patch(item) if item.is_complete(): + # add payload to complete one if self._payloads: for payload in self._payloads: payload.set(item) - self._sent_messages.append(item) - if len(item.callers) > 0: - self._sent_callers.extend(item.callers) + # buffer outputs + self._sent_message_ids.append(item.msg_id) + self._sent_messages[item.msg_id] = item + self._sending = None # skip chunk if self._upstream and self._upstream.completes_only() and not item.is_complete(): @@ -89,6 +121,3 @@ def error(self) -> Optional[Message]: def closed(self) -> bool: return self._upstream is None or self._upstream.closed() - - - diff --git a/ghostos/framework/session/__init__.py b/ghostos/framework/session/__init__.py deleted file mode 100644 index 5f1e2176..00000000 --- a/ghostos/framework/session/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ghostos.framework.session.basic import BasicSession \ No newline at end of file diff --git a/ghostos/framework/session/basic.py b/ghostos/framework/session/basic.py deleted file mode 100644 index b9aa7834..00000000 --- a/ghostos/framework/session/basic.py +++ /dev/null @@ -1,330 +0,0 @@ -from typing import Optional, Callable, List, Iterable, Dict -from ghostos.core.messages import ( - MessageKind, Role, Stream, MessageKindParser, MessageType, - Buffer, Payload, Attachment, Message, -) -from ghostos.core.runtime import ( - Session, - GoProcess, GoProcesses, - GoThreadInfo, GoThreads, - GoTaskStruct, GoTasks, TaskPayload, TaskState, - Messenger, - Event, EventBus, EventTypes, - TaskBrief, -) -from ghostos.core.llms import FunctionalToken -from ghostos.framework.messengers import DefaultMessenger -from ghostos.helpers import uuid -from ghostos.contracts.logger import LoggerItf -from ghostos.contracts.pool import Pool - - -class FutureCall: - def __init__(self, future_id: str, bus: EventBus, event: Event, notify: bool = True): - self.bus = bus - self.event = event - self.future_id = future_id - self.notify = notify - - def run(self, callback: Callable[[], Iterable[MessageKind]]) -> None: - try: - messages = list(callback()) - except Exception as e: - messages = [Role.new_system("", memory=str(e))] - if len(messages) > 0: - self.event.messages = messages - self.bus.send_event(self.event, notify=self.notify) - del self.bus - del self.event - - -class BasicSession(Session): - - def __init__( - self, *, - ghost_name: str, - ghost_role: str, - upstream: Stream, - eventbus: EventBus, - pool: Pool, - processes: GoProcesses, - tasks: GoTasks, - threads: GoThreads, - logger: LoggerItf, - # 当前任务信息. - process: GoProcess, - task: GoTaskStruct, - thread: GoThreadInfo, - ): - self._pool = pool - self._upstream = upstream - self._logger = logger - self._tasks: GoTasks = tasks - self._processes: GoProcesses = processes - self._ghost_name: str = ghost_name - self._message_role: str = ghost_role - self._threads: GoThreads = threads - self._eventbus: EventBus = eventbus - # 需要管理的状态. - self._task: GoTaskStruct = task - self._process: GoProcess = process - self._creating: List[GoTaskStruct] = [] - self._thread: GoThreadInfo = thread - self._firing_events: List[Event] = [] - self._fetched_task_briefs: Dict[str, TaskBrief] = {} - - def id(self) -> str: - return self._task.shell_id - - def alive(self) -> bool: - return ( - not self._upstream.stopped() - and self._task.lock is not None - ) - - def refresh_lock(self) -> bool: - lock = self._task.lock if self._task.lock else "" - lock = self._tasks.refresh_task_lock(self._task.task_id, lock) - if lock: - self._task.lock = lock - return True - return False - - def process(self) -> "GoProcess": - return self._process - - def task(self) -> "GoTaskStruct": - return self._task - - def thread(self) -> "GoThreadInfo": - return self._thread - - def messenger( - self, *, - sending: bool = True, - saving: bool = True, - thread: Optional[GoThreadInfo] = None, - name: Optional[str] = None, - buffer: Optional[Buffer] = None, - payloads: Optional[Iterable[Payload]] = None, - attachments: Optional[Iterable[Attachment]] = None, - functional_tokens: Optional[Iterable[FunctionalToken]] = None - ) -> "Messenger": - payload = TaskPayload.from_task(self._task) - if payloads is None: - payloads = [] - payloads.append(payload) - name = name if name else self._assistant_name() - thread = thread if thread else self._thread - - messenger = DefaultMessenger( - upstream=self._upstream, - saving=saving, - thread=thread, - buffer=buffer, - name=name, - payloads=payloads, - attachments=attachments, - role=self._message_role, - logger=self._logger, - functional_tokens=functional_tokens, - ) - return messenger - - def _assistant_name(self) -> str: - if self._task.assistant: - return self._task.assistant.name - return self._ghost_name - - def send_messages(self, *messages: MessageKind, role: str = Role.ASSISTANT.value) -> List[Message]: - parser = MessageKindParser(self._message_role) - outputs = parser.parse(messages) - messenger = self.messenger() - messenger.send(outputs) - sent, callers = messenger.flush() - self._logger.info(f"send message by session [send_messages], sent: {len(sent)}, callers: {len(callers)}") - return sent - - def update_task(self, task: "GoTaskStruct", thread: Optional["GoThreadInfo"], update_history: bool) -> None: - self._task = task - if thread is not None: - self._task.thread_id = thread.id - self._thread = thread.get_updated_copy() - if update_history: - self._thread = self._thread.get_updated_copy() - - def update_thread(self, thread: "GoThreadInfo", update_history: bool) -> None: - if update_history: - thread = thread.get_updated_copy() - self._thread = thread - - def create_tasks(self, *tasks: "GoTaskStruct") -> None: - self._creating.extend(tasks) - - def fire_events(self, *events: "Event") -> None: - extending = [] - from_task_name = self._task.name - from_task_id = self._task.task_id - for e in events: - if e.task_id == self._task.parent: - e.callback = True - e.from_task_id = from_task_id - e.from_task_name = from_task_name - extending.append(e) - self._firing_events.extend(extending) - - def future(self, name: str, call: Callable[[], Iterable[MessageKind]], reason: str) -> None: - future_id = uuid() - # 增加一个消息. - system = MessageType.DEFAULT.new_system( - content=f"async call `{name}` with id `{future_id}`, wait for future callback.", - ) - self.send_messages(system) - event = EventTypes.ROTATE.new( - task_id=self._task.task_id, - from_task_id=self._task.task_id, - messages=[], - ) - # 让异步任务全局执行. - notify = self._process.main_task_id != self._task.task_id - future = FutureCall(future_id, self._eventbus, event, notify=notify) - self._pool.submit(future.run) - - def _do_quit(self) -> None: - main_task_id = self._process.main_task_id - task = self._tasks.get_task(main_task_id, False) - self._firing_events = [] - for task_id in task.children: - event = EventTypes.KILL.new( - task_id=task_id, - messages=[], - from_task_id=self._task.task_id, - reason="the process is quited" - ) - self._firing_events.append(event) - self._do_fire_events() - - - def _do_create_tasks(self) -> None: - if self._creating: - self._tasks.save_task(*self._creating) - self._creating = [] - - def _do_fire_events(self) -> None: - if not self._firing_events: - return - process = self._process - bus = self._eventbus - main_task_id = process.main_task_id - for e in self._firing_events: - # all the sub-tasks need notification - notify = e.task_id != main_task_id - self._logger.info(f"fire event {e.type}: eid {e.event_id}; task_id {e.task_id}") - bus.send_event(e, notify) - self._firing_events = [] - - def _do_finish_task_and_thread(self) -> None: - self._task.thread_id = self._thread.id - task = self._task - # 回收掉完成的任务. - if task.children and task.too_much_children(): - children = self.get_task_briefs(children=True) - idx = 0 - max_idx = len(children) - 1 - while task.too_much_children() and idx < max_idx: - idx += 1 - child = children[idx] - if child.is_overdue() or TaskState.is_dead(child.task_state): - task.remove_child(child.task_id) - task.new_turn() - self._task = task - self._fetched_task_briefs = {} - self._thread = self._thread.get_updated_copy() - self._tasks.save_task(task) - self._threads.save_thread(self._thread) - - def get_task_briefs(self, *task_ids, children: bool = False) -> "List[TaskBrief]": - ids = set(task_ids) - result = [] - if children: - for task_id in self._task.children: - ids.add(task_id) - if not ids: - return result - - fetch = [] - for task_id in ids: - if task_id in self._fetched_task_briefs: - result.append(self._fetched_task_briefs[task_id]) - else: - fetch.append(task_id) - if fetch: - briefs = self._tasks.get_task_briefs(fetch) - for task_brief in briefs: - result.append(task_brief) - self._fetched_task_briefs[task_brief.task_id] = task_brief - return result - - def tasks(self) -> GoTasks: - return self._tasks - - def processes(self) -> GoProcesses: - return self._processes - - def threads(self) -> GoThreads: - return self._threads - - def eventbus(self) -> EventBus: - return self._eventbus - - def update_process(self, process: "GoProcess") -> None: - self._process = process - - def quit(self) -> None: - if self._process.main_task_id != self._task.task_id: - raise RuntimeError( - f"only main task {self._process.main_task_id} is able to quit process, not {self._task.task_id}" - ) - self._process.quited = True - - def destroy(self) -> None: - del self._upstream - del self._logger - del self._task - del self._tasks - del self._thread - del self._threads - del self._process - del self._processes - del self._firing_events - del self._fetched_task_briefs - del self._pool - - def save(self) -> None: - with self._eventbus.transaction(): - with self._tasks.transaction(): - with self._threads.transaction(): - with self._processes.transaction(): - if self._process.quited: - self._do_quit() - else: - self._do_create_tasks() - self._do_finish_task_and_thread() - self._do_fire_events() - if self._process.main_task_id == self._task.task_id: - self._processes.save_process(process=self._process) - - def fail(self, err: Optional[Exception]) -> None: - # 暂时只做解开锁. - locked = self._task.lock - if locked: - self._tasks.unlock_task(self._task.task_id, locked) - self._task.lock = None - self._upstream.deliver(MessageType.ERROR.new(content=str(err))) - self._logger.error(err) - - def done(self) -> None: - locked = self._task.lock - if locked: - self._tasks.unlock_task(self._task.task_id, locked) - self._upstream.deliver(MessageType.final()) diff --git a/ghostos/framework/tasks/storage_tasks.py b/ghostos/framework/tasks/storage_tasks.py index 1da4bb56..69227f12 100644 --- a/ghostos/framework/tasks/storage_tasks.py +++ b/ghostos/framework/tasks/storage_tasks.py @@ -1,5 +1,5 @@ import time -from typing import Optional, List, Iterable, Dict, Type, TypedDict +from typing import Optional, List, Iterable, Type, TypedDict import yaml from ghostos.core.runtime import TaskState, TaskBrief, GoTaskStruct, GoTasks from ghostos.contracts.workspace import Workspace @@ -7,7 +7,7 @@ from ghostos.contracts.storage import Storage from ghostos.container import Provider, Container from ghostos.core.runtime.tasks import TaskLocker -from ghostos.helpers import uuid +from ghostos.helpers import uuid, timestamp __all__ = ['StorageGoTasksImpl', 'StorageTasksImplProvider', 'WorkspaceTasksProvider'] @@ -77,7 +77,7 @@ def save_task(self, *tasks: GoTaskStruct) -> None: filename = self._get_task_filename(task.task_id) data = task.model_dump(exclude_defaults=True) content = yaml.safe_dump(data) - task.updated = int(time.time()) + task.updated = timestamp() self._storage.put(filename, content.encode('utf-8')) @staticmethod diff --git a/ghostos/helpers/__init__.py b/ghostos/helpers/__init__.py index bb3fbf14..93beed6b 100644 --- a/ghostos/helpers/__init__.py +++ b/ghostos/helpers/__init__.py @@ -19,7 +19,7 @@ create_and_bind_module, ) from ghostos.helpers.io import BufferPrint -from ghostos.helpers.time import Timeleft +from ghostos.helpers.timeutils import Timeleft, timestamp_datetime, timestamp from ghostos.helpers.hashes import md5, sha1, sha256 from ghostos.helpers.trans import gettext, ngettext, get_current_locale, GHOSTOS_DOMAIN diff --git a/ghostos/helpers/time.py b/ghostos/helpers/timeutils.py similarity index 65% rename from ghostos/helpers/time.py rename to ghostos/helpers/timeutils.py index 883a3e5c..45d94521 100644 --- a/ghostos/helpers/time.py +++ b/ghostos/helpers/timeutils.py @@ -1,7 +1,7 @@ -from typing import Tuple +from datetime import datetime import time -__all__ = ['Timeleft'] +__all__ = ['Timeleft', 'timestamp_datetime', 'timestamp'] class Timeleft: @@ -21,3 +21,11 @@ def alive(self) -> bool: def passed(self) -> float: now = time.time() return now - self.start + + +def timestamp_datetime() -> datetime: + return datetime.fromtimestamp(int(time.time())) + + +def timestamp() -> int: + return int(time.time()) diff --git a/tests/core/messages/test_arr_stream_receiver.py b/tests/core/messages/test_arr_stream_receiver.py index 0fbe362c..c516c452 100644 --- a/tests/core/messages/test_arr_stream_receiver.py +++ b/tests/core/messages/test_arr_stream_receiver.py @@ -1,5 +1,6 @@ from typing import Iterable from ghostos.core.messages.transport import new_arr_connection, Stream +from ghostos.core.messages.pipeline import SequencePipe from ghostos.core.messages.message import Message from threading import Thread import time @@ -41,31 +42,6 @@ def send_data(s: Stream, c: str): t.join() -def test_new_connection_timeout(): - stream, retriever = new_arr_connection(timeout=0.2, idle=0.2, complete_only=False) - content = "hello world" - - def send_data(s: Stream, c: str): - error = None - try: - with s: - s.send(iter_content(c, 1)) - except RuntimeError as e: - error = e - finally: - assert error is not None - - t = Thread(target=send_data, args=(stream, content)) - t.start() - with retriever: - messages = list(retriever.recv()) - assert retriever.closed() - assert retriever.error() is not None - assert not stream.alive() - assert len(messages) > 0 - t.join() - - def test_new_connection_complete_only(): stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=True) content = "hello world" @@ -142,6 +118,37 @@ def send_data(s: Stream, c: str): t.join() +def test_new_connection_recv_with_sequence(): + stream, retriever = new_arr_connection(timeout=0, idle=0.1, complete_only=False) + content = "hello world" + + def send_data(s: Stream, c: str): + with s: + messages = SequencePipe().across(iter_content(c, 0.02)) + s.send(messages) + + send_data(stream, content) + + got = retriever.recv() + assert len(list(got)) == len(content) + 1 + + +def test_new_connection_wait_with_sequence(): + stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + content = "hello world" + + def send_data(s: Stream, c: str): + with s: + messages = SequencePipe().across(iter_content(c, 0.02)) + messages = list(messages) + s.send(messages) + + send_data(stream, content) + + got = retriever.wait() + assert len(got) == 1 + + def test_new_connection_with_pool(): from ghostos.contracts.pool import DefaultPool pool = DefaultPool(10) diff --git a/tests/core/messages/test_pipeline.py b/tests/core/messages/test_pipeline.py new file mode 100644 index 00000000..9656d9a9 --- /dev/null +++ b/tests/core/messages/test_pipeline.py @@ -0,0 +1,37 @@ +from typing import Iterable +from ghostos.core.messages import Message +from ghostos.core.messages.pipeline import SequencePipe, pipeline + + +def test_multi_sequence_pipes(): + content = "hello world" + + def iter_content(c: str) -> Iterable[Message]: + for char in c: + yield Message.new_chunk(content=char) + + messages = iter_content(content) + parsed = pipeline([SequencePipe(), SequencePipe(), SequencePipe()], messages) + got = list(parsed) + assert len(got) == len(content) + 1 + assert got[0].is_head() + assert got[0].created > 0 + assert got[0].content == "h" + assert got[-1].is_complete() + assert got[-2].is_chunk() + + +def test_multi_sequence_pipe_with_tail(): + content = "hello world" + + def iter_content(c: str) -> Iterable[Message]: + for char in c: + yield Message.new_chunk(content=char) + + messages = iter_content(content) + messages = SequencePipe().across(messages) + messages = list(messages) + assert len(messages) == len(content) + 1 + messages = SequencePipe().across(messages) + messages = list(messages) + assert len(messages) == len(content) + 1 diff --git a/tests/core/moss/examples/test_baseline.py b/tests/core/moss/examples/test_baseline.py index b662abce..05f2885c 100644 --- a/tests/core/moss/examples/test_baseline.py +++ b/tests/core/moss/examples/test_baseline.py @@ -95,6 +95,7 @@ def test_baseline_exec(): # 最后成功销毁. runtime.destroy() + container.destroy() def test_baseline_in_test_mode(): @@ -121,6 +122,7 @@ def test_baseline_in_test_mode(): result = runtime.execute(target="test_main", local_args=["moss"]) assert result.returns == 3 assert result.pycontext.get_prop("hello") == "world" + container.destroy() def test_baseline_with_pycontext_code(): @@ -131,13 +133,14 @@ def test_baseline_with_pycontext_code(): line = "print('hello')" compiler.join_context(PyContext(module=baseline.__name__, code=line)) assert line in compiler.pycontext_code() + container.destroy() def test_moss_gc(): from threading import Thread from gc import collect container = moss_container() - assert Container.instance_count == 2 + assert Container.instance_count < 10 def run(c: Container): compiler = container.force_fetch(MossCompiler) diff --git a/tests/framework/messenger/test_messenger.py b/tests/framework/messenger/test_messenger.py index 7f817a6b..e6439904 100644 --- a/tests/framework/messenger/test_messenger.py +++ b/tests/framework/messenger/test_messenger.py @@ -1,5 +1,5 @@ from ghostos.framework.messengers import DefaultMessenger -from ghostos.core.messages import Message +from ghostos.core.messages import Message, new_arr_connection def test_default_messenger_baseline(): @@ -13,3 +13,19 @@ def test_default_messenger_baseline(): messages, callers = messenger.flush() assert len(messages) == 1 assert len(callers) == 0 + + +def test_messenger_with_upstream(): + stream, receiver = new_arr_connection() + messenger = DefaultMessenger(stream) + items = [] + content = "hello world" + for c in content: + msg = Message.new_chunk(content=c) + items.append(msg) + with stream: + messenger.send(items) + flushed, _ = messenger.flush() + messages = receiver.wait() + assert len(flushed) == 1 + assert len(messages) == 1 diff --git a/tests/python/test_pydantic.py b/tests/python/test_pydantic.py index 2252460d..1b45e574 100644 --- a/tests/python/test_pydantic.py +++ b/tests/python/test_pydantic.py @@ -2,6 +2,7 @@ from pydantic.errors import PydanticSchemaGenerationError from typing import TypedDict, Required, Iterable, List, Optional, Protocol from typing_extensions import Literal +from datetime import datetime def test_pydantic_new_typed_dict() -> None: @@ -189,3 +190,11 @@ class Bar(BaseModel): except PydanticSchemaGenerationError as e: err = e assert err is not None + + +def test_datetime_model(): + class Foo(BaseModel): + time: datetime = Field(default_factory=datetime.now) + + f = Foo() + assert f.time.timestamp() > 0 From 074a0b91e6ea24c629f19936e6922df491daded8 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 18 Nov 2024 18:05:03 +0800 Subject: [PATCH 093/148] dev: run console app success, bugs to fix later --- ghostos/abcd/concepts.py | 6 +- ghostos/contracts/logger.py | 19 +- ghostos/core/messages/message.py | 4 + .../framework/ghostos/conversation_impl.py | 4 + ghostos/framework/ghostos/shell_impl.py | 19 +- ghostos/framework/logger/named.py | 4 +- ghostos/prototypes/console/__init__.py | 195 +------------- ghostos/prototypes/console/app.py | 239 ++++++++++-------- ghostos/scripts/cli/run_console.py | 16 +- tests/python/test_pydantic.py | 17 +- tests/python/test_slice.py | 6 + 11 files changed, 195 insertions(+), 334 deletions(-) diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index a8bb7bb8..632027cf 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -1,6 +1,6 @@ from __future__ import annotations from typing import ( - Type, Generic, Protocol, ClassVar, TypeVar, + Type, Generic, Protocol, ClassVar, TypeVar, Callable, Tuple, Optional, Iterable, List, Self, Union, Dict, Any ) @@ -305,6 +305,10 @@ def run_background_event(self, background: Optional[Background] = None) -> Union def background_run(self, worker: int = 4, background: Optional[Background] = None) -> None: pass + @abstractmethod + def submit(self, caller: Callable, *args, **kwargs): + pass + @abstractmethod def close(self): pass diff --git a/ghostos/contracts/logger.py b/ghostos/contracts/logger.py index 6f10d5b1..fe89935c 100644 --- a/ghostos/contracts/logger.py +++ b/ghostos/contracts/logger.py @@ -7,7 +7,7 @@ import yaml __all__ = [ - 'LoggerItf', 'config_logging', 'get_logger', 'get_console_logger', + 'LoggerItf', 'config_logging', 'get_logger', 'get_console_logger', 'get_debug_logger', 'wrap_logger', ] @@ -127,7 +127,7 @@ def get_console_logger( ) -> LoggerItf: logger = getLogger(name) if not logger.hasHandlers(): - logger.setLevel(logging.DEBUG) + logger.setLevel(logging.INFO) _console_handler = logging.StreamHandler() if debug: _console_handler.setLevel(logging.DEBUG) @@ -137,6 +137,21 @@ def get_console_logger( return LoggerAdapter(logger, extra=extra) +def get_debug_logger( + name: str = "__ghostos_debug__", + extra: Optional[dict] = None, + debug: bool = False, +) -> LoggerItf: + logger = getLogger(name) + if not logger.hasHandlers(): + logger.setLevel(logging.INFO) + _debug_file_handler = logging.FileHandler("debug.log") + if debug: + _debug_file_handler.setLevel(logging.DEBUG) + logger.addHandler(_debug_file_handler) + return LoggerAdapter(logger, extra=extra) + + class PleshakovFormatter(logging.Formatter): # copy from # https://stackoverflow.com/questions/384076/how-can-i-color-python-logging-output diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 1c5d7f9c..aba75710 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -115,6 +115,10 @@ def match(self, message: "Message") -> bool: def is_final(cls, pack: "Message") -> bool: return pack.type == cls.FINAL.value + @classmethod + def is_text(cls, message: Message) -> bool: + return message.type == cls.TEXT.value or message.type == cls.DEFAULT.value + @classmethod def is_protocol_message(cls, message: Optional["Message"]) -> bool: if message is None: diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index 0d8c6616..a73ada92 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -117,6 +117,10 @@ def respond_event( timeout: float = 0.0, ) -> Receiver: self._validate_closed() + # complete task_id + if not event.task_id: + event.task_id = self._scope.task_id + stream, retriever = new_arr_connection( timeout=timeout, idle=self._conf.message_receiver_idle, diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index 2ba1e605..01e271e7 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -1,7 +1,8 @@ import time -from typing import Union, Optional, Iterable, List, Tuple, TypeVar +from typing import Union, Optional, Iterable, List, Tuple, TypeVar, Callable from ghostos.contracts.logger import LoggerItf +from ghostos.contracts.pool import Pool from ghostos.container import Container, Provider from ghostos.abcd import Shell, Conversation, Ghost, Scope, Background from ghostos.abcd.utils import get_ghost_driver @@ -58,7 +59,6 @@ def __init__( ) self._eventbus = container.force_fetch(EventBus) self._tasks = container.force_fetch(GoTasks) - self._workers: List[Thread] = [] self._closed = False self._background_started = False self._logger = container.force_fetch(LoggerItf) @@ -218,15 +218,18 @@ def on_event(e: Event, r: Receiver) -> None: finally: self._eventbus.notify_task(self._scope.task_id) + def submit(self, caller: Callable, *args, **kwargs): + pool = self.container().force_fetch(Pool) + pool.submit(caller, *args, **kwargs) + def background_run(self, worker: int = 4, background: Optional[Background] = None) -> None: self._validate_closed() if self._background_started: raise RuntimeError(f'background run already started') for i in range(worker): - t = Thread(target=self._run_background_worker, args=(background,)) - t.start() - self._workers.append(t) + pool = self.container().force_fetch(Pool) + pool.submit(self._run_background_worker, background) def _run_background_worker(self, background: Optional[Background] = None): def is_stopped() -> bool: @@ -267,11 +270,9 @@ def close(self): if self._closed: return self._closed = True - if self._workers: - for t in self._workers: - t.join() + pool = self.container().force_fetch(Pool) + pool.shutdown() self._container.destroy() del self._container - del self._workers del self._eventbus del self._tasks diff --git a/ghostos/framework/logger/named.py b/ghostos/framework/logger/named.py index dc59f05e..544a1eb0 100644 --- a/ghostos/framework/logger/named.py +++ b/ghostos/framework/logger/named.py @@ -1,7 +1,7 @@ from typing import Optional, Type from ghostos.container import Provider, Container -from ghostos.contracts.logger import LoggerItf, get_console_logger +from ghostos.contracts.logger import LoggerItf, get_debug_logger import logging __all__ = ['NamedLoggerProvider'] @@ -30,4 +30,4 @@ def factory(self, con: Container) -> Optional[LoggerItf]: logger = logging.getLogger(self.logger_name) origin = logging.LoggerAdapter(logger) return origin - return get_console_logger(name=self.logger_name) + return get_debug_logger(name=self.logger_name) diff --git a/ghostos/prototypes/console/__init__.py b/ghostos/prototypes/console/__init__.py index 4b2e7c00..d921a401 100644 --- a/ghostos/prototypes/console/__init__.py +++ b/ghostos/prototypes/console/__init__.py @@ -1,194 +1 @@ -from os.path import join, dirname -from typing import Optional, List -from ghostos.core import GhostOS -from ghostos.framework.ghostos import demo_ghostos, DemoGhostOS -from ghostos.framework.ghosts.demo import DemoGhostConf -from ghostos.prototypes.console.app import ConsolePrototype -from ghostos.core.ghosts import Thought -from ghostos.helpers import get_calling_modulename, import_from_path, md5, uuid -from ghostos.container import Provider - -__all__ = [ - 'ConsoleApp', - 'ConsolePrototype', - 'quick_new_console_app', - 'demo_console_app', -] - - -class ConsoleApp: - """ - Create a GhostOS Console app with a ghostos. - New demo from specific directory as default. - """ - - def __init__( - self, - ghostos: GhostOS = None, - ): - self._ghostos = ghostos - # status - self._ran_thought = False - self._ran_console = False - - @classmethod - def new_demo( - cls, - *, - root_dir: str, - logger_conf_path: str, - logger_name: str = "debug", - conf_path: str = "configs", - runtime_path: str = "runtime", - processes_path: str = "processes", - tasks_path: str = "tasks", - threads_path: str = "threads", - llm_conf_path: str = "llms_conf.yml", - source_path: str = "src", - ): - ghostos = DemoGhostOS( - root_dir=root_dir, - logger_conf_path=logger_conf_path, - logger_name=logger_name, - config_path=conf_path, - runtime_path=runtime_path, - source_path=source_path, - processes_path=processes_path, - tasks_path=tasks_path, - threads_path=threads_path, - llm_config_path=llm_conf_path, - ) - return cls(ghostos) - - def with_providers(self, providers: List[Provider]): - """ - register global providers - :param providers: provide contracts and implementations. - """ - for provider in providers: - self._ghostos.container().register(provider) - - def run_console( - self, - ghost_id: str, - *, - username: str = "BrightRed", - on_create_message: Optional[str] = None, - debug: bool = False, - session_id: Optional[str] = None, - welcome_user_message: Optional[str] = None, - ): - """ - :param ghost_id: should exist in configs/ghosts.yml - :param username: the username, default is my name haha. - :param on_create_message: the message to send to the assistant as default. - :param debug: if debug is True, render more verbosely. - :param session_id: if given, the console will start in the same session by the id. - :param welcome_user_message: if on_create_message is None, use welcome_user_message let agent welcome first - """ - if self._ran_console: - return - self._ran_console = True - ghostos = demo_ghostos - console_impl = ConsolePrototype( - ghostos=ghostos, - ghost_id=ghost_id, - username=username, - debug=debug, - on_create_message=on_create_message, - session_id=session_id, - welcome_user_message=welcome_user_message, - ) - console_impl.run() - self._ran_console = False - - def run_thought( - self, - thought: Thought, - *, - instruction: Optional[str] = None, - username: str = "BrightRed", - ghost_name: str = "GhostOSDemo", - debug: bool = False, - meta_prompt: str = "", - long_term_session: bool = False, - welcome_user_message: Optional[str] = None, - ): - """ - Run a thought instead of run a defined Ghost. - :param thought: root thought of the ghost - :param instruction: the init instruction send to assistant - :param username: name of the console's user. default is my name hahaha. - :param ghost_name: ghost name - :param debug: if debug is True, render more verbosely. - :param meta_prompt: define the meta prompt of the ghost. I leave it empty. - :param long_term_session: if true, session id is always related to the calling module. - :param welcome_user_message: if on_create_message is None, use welcome_user_message let agent welcome first - :return: - """ - if self._ran_thought: - return - self._ran_thought = True - modulename = get_calling_modulename(1) - module = import_from_path(modulename) - file = module.__file__ - ghost_id = md5(file) - ghost_conf = DemoGhostConf( - id=ghost_id, - name=ghost_name, - thought_meta=thought.to_entity_meta(), - meta_prompt=meta_prompt, - ) - self._ghostos.register(ghost_conf) - if long_term_session: - session_id = ghost_id - else: - session_id = uuid() - console_impl = ConsolePrototype( - ghostos=self._ghostos, - ghost_id=ghost_id, - username=username, - on_create_message=instruction, - debug=debug, - session_id=session_id, - welcome_user_message=welcome_user_message, - ) - console_impl.run() - self._ran_thought = False - - -demo_dir = join(dirname(dirname(dirname(__file__))), "demo") - -demo_console_app = ConsoleApp.new_demo( - root_dir=demo_dir, - logger_conf_path=join(demo_dir, "configs/logging.yml"), -) -""" default app instance for testing convenient""" - -__app__ = None - - -def quick_new_console_app( - current_file: str, - dirname_times: int = 0, - logging_conf: str = "configs/logging.yml", - **kwargs, -) -> ConsoleApp: - """ - quick to create a console app based on root_dir. only once shall be called globally. - :param current_file: current file name, usually __file__ - :param dirname_times: depth from current_file to root dir - :param logging_conf: - :return: - """ - global __app__ - if __app__ is not None: - return __app__ - root_dir = current_file - for i in range(dirname_times): - root_dir = dirname(root_dir) - - logger_conf_path = join(root_dir, logging_conf) - app = ConsoleApp.new_demo(root_dir=root_dir, logger_conf_path=logger_conf_path, **kwargs) - __app__ = app - return app +from ghostos.prototypes.console.app import ConsoleApp diff --git a/ghostos/prototypes/console/app.py b/ghostos/prototypes/console/app.py index f002310d..ac09cdad 100644 --- a/ghostos/prototypes/console/app.py +++ b/ghostos/prototypes/console/app.py @@ -1,115 +1,162 @@ from __future__ import annotations -from ghostos.core.ghostos import GhostOS -import time import asyncio -from typing import Optional, List +from typing import Optional -from ghostos.core.messages import Message, Role, MessageType -from ghostos.abcd import GhostOS +from ghostos.abcd import GhostOS, Ghost, Background +from ghostos.contracts.logger import get_console_logger +from ghostos.core.messages import Message, Role, MessageType, Receiver from ghostos.framework.messages import TaskPayload from ghostos.helpers import uuid -from threading import Thread +from ghostos.core.runtime import Event from queue import Queue, Empty from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.shortcuts import PromptSession +from threading import Lock from rich.console import Console from rich.panel import Panel from rich.markdown import Markdown +from rich.status import Status -__all__ = ['ConsolePrototype'] +__all__ = ['ConsoleApp'] -class ConsolePrototype: +class ConsoleApp(Background): def __init__( self, *, ghostos: GhostOS, - ghost_id: str, + ghost: Ghost, username: str, debug: bool = False, - on_create_message: Optional[str] = None, - session_id: Optional[str] = None, + shell_name: str = "console", + shell_id: Optional[str] = None, process_id: Optional[str] = None, - task_id: Optional[str] = None, - background_num: int = 4, + worker_num: int = 4, welcome_user_message: Optional[str] = None, + on_create_message: Optional[str] = None, ): self._os = ghostos - self._ghost_id = ghost_id - self._on_create_message = on_create_message + self._ghost = ghost self._username = username + self._shell_name = shell_name + self._shell_id = shell_id if shell_id else uuid() self._process_id = process_id - self._task_id = task_id - self._session_id = session_id if session_id else uuid() - session = PromptSession("\n\n<<< ", ) - self._prompt_session = session self._console = Console() + self._logger = get_console_logger() + self._closed = False self._stopped = False self._main_queue = Queue() + self._thread_locker = Lock() self._debug = debug - self._threads: List[Thread] = [] - self._background_num = background_num + self._worker_num = worker_num if not welcome_user_message: welcome_user_message = "the conversation is going to begin, please welcome user and introduce your self" self._welcome_user_message = welcome_user_message + self._on_create_message = on_create_message self._main_task_id = "" + session = PromptSession("\n\n<<< ", ) + self._prompt_session = session + self._shell = self._os.create_shell( + self._shell_name, + self._shell_id, + process_id=self._process_id, + ) + self._conversation = self._shell.sync(self._ghost) + + def __del__(self): + self.close() + self._conversation.close() + self._shell.close() def run(self): - for i in range(self._background_num): - background_run_task = Thread(target=self._start_background) - background_run_task.start() - self._threads.append(background_run_task) - print_output_task = Thread(target=self._print_output) - print_output_task.start() - self._threads.append(print_output_task) + # self._shell.background_run(self._worker_num) + self._shell.submit(self._print_output) asyncio.run(self._main()) + def close(self): + if self._closed: + return + self._closed = True + self._stopped = True + self._main_queue.put(None) + self._console.print("start exiting") + self._conversation.close() + self._console.print("conversation closed") + self._shell.close() + self._console.print("ghostos shell shutdown") + self._console.print("Exit, Bye!") + exit(0) + + async def _main(self): + self._welcome() + self._console.print("waiting for agent say hi...") + message = Role.new_system( + self._welcome_user_message, + ) + receiver = self._conversation.respond([message]) + self.output_receiver(receiver) + + with patch_stdout(raw=True): + await self._loop() + self._console.print("Quitting event loop. Bye.") + def _print_output(self): while not self._stopped: try: - message = self._main_queue.get(block=True, timeout=1) + message = self._main_queue.get(block=True) + if message is None: + self.close() + return if not isinstance(message, Message): raise ValueError(f"Expected Message, got {message}") self._print_message(message) except Empty: continue - def _start_background(self): - while not self._stopped: - handled = self._os.background_run(stream) - if not handled: - time.sleep(1) - elif not self._debug: - self._console.print( - f"handled event {handled.type}: task_id {handled.task_id}; event_id {handled.event_id};") - else: - self._console.print(Panel( - Markdown(f"```json\n{handled.model_dump_json(indent=2)}\n```"), - title="handle event", - border_style="yellow", - )) + def output_receiver(self, receiver: Receiver): + with self._thread_locker: + status = Status("receiving", console=self._console) + with status: + with receiver: + buffer = None + for message in receiver.recv(): + if self._stopped: + return - async def _main(self): - self._welcome() - if self._on_create_message: - self._console.print( - Panel( - Markdown(self._on_create_message), - title="on_created instruction", - border_style="green", - ) - ) - self._main_task_id = self._on_input(self._on_create_message) - else: - self._console.print("waiting for agent say hi...") - message = Role.new_system( - self._welcome_user_message, - ) - self._main_task_id = self._on_message_input(message) - with patch_stdout(raw=True): - await self._loop() - self._console.print("Quitting event loop. Bye.") + if message.is_complete(): + buffer = None + self._main_queue.put(message) + elif buffer is None: + buffer = message.as_head() + else: + patched = buffer.patch(message) + if patched: + buffer = patched + else: + buffer = message.as_head() + if buffer: + status.update(buffer.content[-30:]) + else: + status.update("") + + def output_event(self, event: Event): + self._json_output(event.model_dump_json(indent=2, exclude_defaults=True)) + + def on_error(self, error: Exception) -> bool: + self._logger.exception(error) + self.close() + return False + + def on_event(self, event: Event, retriever: Receiver) -> None: + self._logger.info(f"Received event {event.event_id} for task {event.task_id}") + self.output_receiver(retriever) + + def stopped(self) -> bool: + return self._stopped + + def halt(self) -> int: + return 0 async def _loop(self): session = self._prompt_session @@ -122,12 +169,12 @@ async def _loop(self): self._console.print(Markdown("\n----\n")) self._on_input(text) except (EOFError, KeyboardInterrupt): - self._exit() + self.close() except Exception: self._console.print_exception() - self._exit() + self.close() - def _on_input(self, text: str) -> str: + def _on_input(self, text: str) -> None: """ :return: task_id """ @@ -135,34 +182,18 @@ def _on_input(self, text: str) -> str: content=text, name=self._username, ) - return self._on_message_input(message) + self._on_message_input(message) - def _on_message_input(self, message: Message) -> str: + def _on_message_input(self, message: Message) -> None: """ :return: task_id """ - inputs_ = Inputs( - trace_id=uuid(), - session_id=self._session_id, - ghost_id=self._ghost_id, - messages=[message], - process_id=self._process_id, - task_id=self._task_id, - ) - stream = self._stream() - if not self._debug: - self._console.print(f"push input event id: {inputs_.trace_id}") - else: - self._console.print(Panel( - Markdown(f"```json\n{inputs_.model_dump_json(indent=2)}\n```"), - title="push input event", - border_style="yellow", - )) - return self._os.on_inputs(inputs_, stream, is_async=True) + receiver = self._conversation.respond([message]) + self.output_receiver(receiver) def _intercept_text(self, text: str) -> bool: if text == "/exit": - self._exit() + self.close() return False @staticmethod @@ -181,27 +212,14 @@ def _welcome(self) -> None: ---- """)) - def _exit(self): - self._stopped = True - _continue = True - self._console.print("start exiting") - while _continue: - try: - self._main_queue.get_nowait() - except Empty: - break - self._console.print("stop queue") - self._console.print("queue closed") - for t in self._threads: - t.join() - self._console.print("threads joined") - self._os.shutdown() - self._console.print("ghostos shutdown") - self._console.print("Exit, Bye!") - exit(0) - def _print_message(self, message: Message): - if self._debug: + if not message.is_complete(): + return + + if message.is_empty(): + return + + if not MessageType.is_text(message): self._console.print( Panel( self._json_output(message.model_dump_json(exclude_defaults=True, indent=2)), @@ -209,8 +227,8 @@ def _print_message(self, message: Message): border_style="green", ) ) - if message.is_empty(): return + content = message.content # some message is not visible to user if not content: @@ -225,10 +243,7 @@ def _print_message(self, message: Message): f"> thread_id: {payload.thread_id}", f"> task_name: {payload.task_name}\n\n", ]) - if "" in content: - content = content.replace("", "\n```python\n# \n", ) - if "" in content: - content = content.replace("", "\n# \n```\n", ) + markdown = self._markdown_output(prefix + content) # border style if MessageType.ERROR.match(message): diff --git a/ghostos/scripts/cli/run_console.py b/ghostos/scripts/cli/run_console.py index fc768f34..3ad6fdbf 100644 --- a/ghostos/scripts/cli/run_console.py +++ b/ghostos/scripts/cli/run_console.py @@ -4,6 +4,7 @@ parse_args_modulename_or_filename, get_or_create_module_from_name, ) from ghostos.bootstrap import make_app_container, get_ghostos +from ghostos.prototypes.console import ConsoleApp def get_ghost() -> Ghost: @@ -28,16 +29,5 @@ def main(): ghost = get_ghost() container = make_app_container(workspace_dir) ghostos = get_ghostos(container) - shell = ghostos.create_shell( - "console", - "console", - ) - conversation = shell.sync(ghost) - exit(0) - with conversation: - receiver = conversation.talk("hello") - with receiver: - messages = receiver.wait() - for item in messages: - print(item.get_content()) - shell.close() + app = ConsoleApp(ghostos=ghostos, ghost=ghost, username="") + app.run() diff --git a/tests/python/test_pydantic.py b/tests/python/test_pydantic.py index 1b45e574..367018c3 100644 --- a/tests/python/test_pydantic.py +++ b/tests/python/test_pydantic.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field from pydantic.errors import PydanticSchemaGenerationError -from typing import TypedDict, Required, Iterable, List, Optional, Protocol +from typing import TypedDict, Required, Iterable, List, Optional, ClassVar, Type from typing_extensions import Literal from datetime import datetime @@ -198,3 +198,18 @@ class Foo(BaseModel): f = Foo() assert f.time.timestamp() > 0 + + +def test_model_with_subclass_define(): + class Foo(BaseModel): + foo: int = 123 + BarType: ClassVar[Optional[Type]] = None + + class Foo2(Foo): + class BarType(BaseModel): + bar: int = 123 + + bar: BarType = Field(default_factory=BarType) + + foo2 = Foo2() + assert foo2.bar.bar == 123 diff --git a/tests/python/test_slice.py b/tests/python/test_slice.py index da14963c..c0b777f3 100644 --- a/tests/python/test_slice.py +++ b/tests/python/test_slice.py @@ -54,3 +54,9 @@ def test_sort_dicts(): values = sorted(cases, key=lambda x: x['a'], reverse=True) actual = [c['a'] for c in values] assert actual == [4, 3, 2, 1] + + +def test_arr_tail(): + arr = [1, 2, 3, 4] + assert arr[-2:] == [3, 4] + assert arr[-20:] == [1, 2, 3, 4] From 8e6c4bf43259a9f0b1cf6928ab025dd97329a630 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 18 Nov 2024 21:42:12 +0800 Subject: [PATCH 094/148] dev: console app is successfully running --- ghostos/abcd/concepts.py | 9 +++ ghostos/abcd/thoughts.py | 2 + ghostos/abcd/utils.py | 1 + ghostos/contracts/logger.py | 2 + ghostos/core/llms/llm.py | 2 +- ghostos/core/llms/prompt.py | 6 +- ghostos/framework/ghostos/ghostos_impl.py | 5 ++ ghostos/framework/llms/openai_driver.py | 90 ++++++++++++----------- ghostos/framework/llms/providers.py | 6 +- ghostos/ghosts/chatbot/simplest.py | 30 +++++++- ghostos/prototypes/console/app.py | 4 +- 11 files changed, 107 insertions(+), 50 deletions(-) diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index 632027cf..4891512c 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -11,6 +11,7 @@ from ghostos.core.runtime import ( TaskState, ) +from ghostos.core.llms import Prompt from ghostos.core.runtime.events import Event from ghostos.core.runtime.tasks import GoTaskStruct, TaskBrief from ghostos.core.runtime.threads import GoThreadInfo @@ -141,6 +142,14 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: """ pass + @abstractmethod + def truncate(self, session: Session) -> GoThreadInfo: + pass + + @abstractmethod + def prompt(self, session: Session) -> Prompt: + pass + class Context(Payload, DataPrompter, ABC): """ diff --git a/ghostos/abcd/thoughts.py b/ghostos/abcd/thoughts.py index 43c9d937..81d6235f 100644 --- a/ghostos/abcd/thoughts.py +++ b/ghostos/abcd/thoughts.py @@ -68,9 +68,11 @@ def think(self, session: Session, prompt: Prompt) -> Tuple[Prompt, Optional[Oper llm_api = self.get_llm_api(session) streaming = not session.upstream.completes_only() + session.logger.info(f"start llm thinking on prompt {prompt.id}") items = llm_api.deliver_chat_completion(prompt, streaming) messages, callers = session.respond(items, self.message_stage) prompt.added.extend(messages) + session.logger.info(f"llm thinking on prompt {prompt.id} is done") for caller in callers: if caller.name in self.actions: diff --git a/ghostos/abcd/utils.py b/ghostos/abcd/utils.py index 61503866..704d9f29 100644 --- a/ghostos/abcd/utils.py +++ b/ghostos/abcd/utils.py @@ -53,6 +53,7 @@ def fire_session_event(session: Session, event: Event) -> Optional[Operator]: # if event is intercepted, stop the run. return None driver = get_ghost_driver(session.ghost) + session.thread = driver.truncate(session) return driver.on_event(session, event) diff --git a/ghostos/contracts/logger.py b/ghostos/contracts/logger.py index fe89935c..4e047ddd 100644 --- a/ghostos/contracts/logger.py +++ b/ghostos/contracts/logger.py @@ -148,6 +148,8 @@ def get_debug_logger( _debug_file_handler = logging.FileHandler("debug.log") if debug: _debug_file_handler.setLevel(logging.DEBUG) + formatter = logging.Formatter(fmt="%(asctime)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)") + _debug_file_handler.setFormatter(formatter) logger.addHandler(_debug_file_handler) return LoggerAdapter(logger, extra=extra) diff --git a/ghostos/core/llms/llm.py b/ghostos/core/llms/llm.py index 07c80376..e6cb1a89 100644 --- a/ghostos/core/llms/llm.py +++ b/ghostos/core/llms/llm.py @@ -31,7 +31,7 @@ def get_model(self) -> ModelConf: pass @abstractmethod - def parse_chat(self, chat: Prompt) -> Prompt: + def parse_prompt(self, prompt: Prompt) -> Prompt: """ parse chat by llm api default logic. Functional tokens for example. this method is used to test. diff --git a/ghostos/core/llms/prompt.py b/ghostos/core/llms/prompt.py index 1c32b54d..e693a75f 100644 --- a/ghostos/core/llms/prompt.py +++ b/ghostos/core/llms/prompt.py @@ -12,6 +12,7 @@ from pydantic import BaseModel, Field from ghostos import helpers from ghostos.core.messages import Message, Role, Payload +from .configs import ModelConf from .tools import LLMFunc, FunctionalToken __all__ = [ @@ -46,6 +47,9 @@ class Prompt(BaseModel): output: List[Message] = Field(default_factory=list) error: Optional[str] = Field(default=None, description="error message") created: float = Field(default_factory=lambda: round(time.time(), 4)) + model: Optional[ModelConf] = Field(default=None, description="model conf") + run_start: float = Field(default=0.0, description="start time") + run_end: float = Field(default=0.0, description="end time") def system_prompt(self) -> str: contents = [] @@ -137,7 +141,7 @@ def filter_stages(self, stages: Optional[List[str]] = None) -> Self: stages = set(stages) copied = self.model_copy(deep=True) if stages: - copied.history = join_messages_by_stages([], stages, *copied.history) + copied.history = join_messages_by_stages([], stages, *copied.history) copied.inputs = join_messages_by_stages([], stages, *copied.inputs) copied.added = join_messages_by_stages([], stages, *copied.added) return copied diff --git a/ghostos/framework/ghostos/ghostos_impl.py b/ghostos/framework/ghostos/ghostos_impl.py index dbb0346d..0d35f582 100644 --- a/ghostos/framework/ghostos/ghostos_impl.py +++ b/ghostos/framework/ghostos/ghostos_impl.py @@ -42,6 +42,7 @@ def __init__( ): self.contracts.validate(container) self._container = container + self._logger = self._container.force_fetch(LoggerItf) self._processes = container.force_fetch(GoProcesses) self._configs = container.force_fetch(Configs) self._ghostos_config = self._configs.get(GhostOSConfig) @@ -63,8 +64,12 @@ def create_shell( process = self._processes.get_process(shell_id) if process is None: process = GoProcess.new(shell_id=shell_id, process_id=process_id) + self._logger.info(f"Created shell `{shell_id}` process `{process_id}`") elif process_id is not None and process.process_id != process_id: process = GoProcess.new(shell_id=shell_id, process_id=process_id) + self._logger.info(f"Created shell `{shell_id}` new process `{process_id}`") + else: + self._logger.info(f"get shell `{shell_id}` new process `{process.process_id}`") self._processes.save_process(process) # prepare container diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index 6964fa7c..16b187ad 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -1,5 +1,5 @@ from typing import List, Iterable, Union, Optional - +import time from openai import OpenAI from httpx import Client from httpx_socks import SyncProxyTransport @@ -8,7 +8,7 @@ from openai.types.chat.chat_completion_stream_options_param import ChatCompletionStreamOptionsParam from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam from openai.types.chat.chat_completion_chunk import ChatCompletionChunk - +from ghostos.contracts.logger import LoggerItf from ghostos.core.messages import ( Message, OpenAIMessageParser, DefaultOpenAIMessageParser, CompletionUsagePayload, Role, @@ -75,12 +75,14 @@ def __init__( model_conf: ModelConf, parser: OpenAIMessageParser, storage: PromptStorage, + logger: LoggerItf, # deprecated: functional_token_prompt: Optional[str] = None, ): self._service = service_conf self._model = model_conf self._storage: PromptStorage = storage + self._logger = logger http_client = None if service_conf.proxy: transport = SyncProxyTransport.from_url(service_conf.proxy) @@ -109,32 +111,38 @@ def text_completion(self, prompt: str) -> str: def parse_message_params(self, messages: List[Message]) -> List[ChatCompletionMessageParam]: return list(self._parser.parse_message_list(messages)) - def _chat_completion(self, chat: Prompt, stream: bool) -> Union[ChatCompletion, Iterable[ChatCompletionChunk]]: - chat = self.parse_chat(chat) + def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion, Iterable[ChatCompletionChunk]]: + prompt = self.parse_prompt(prompt) include_usage = ChatCompletionStreamOptionsParam(include_usage=True) if stream else NOT_GIVEN - messages = chat.get_messages() + messages = prompt.get_messages() messages = self.parse_message_params(messages) if not messages: raise AttributeError("empty chat!!") - return self._client.chat.completions.create( - messages=messages, - model=self._model.model, - function_call=chat.get_openai_function_call(), - functions=chat.get_openai_functions(), - tools=chat.get_openai_tools(), - max_tokens=self._model.max_tokens, - temperature=self._model.temperature, - n=self._model.n, - timeout=self._model.timeout, - stream=stream, - stream_options=include_usage, - **self._model.kwargs, - ) + try: + prompt.run_start = round(time.time(), 4) + self._logger.info(f"start chat completion for prompt {prompt.id}") + return self._client.chat.completions.create( + messages=messages, + model=self._model.model, + function_call=prompt.get_openai_function_call(), + functions=prompt.get_openai_functions(), + tools=prompt.get_openai_tools(), + max_tokens=self._model.max_tokens, + temperature=self._model.temperature, + n=self._model.n, + timeout=self._model.timeout, + stream=stream, + stream_options=include_usage, + **self._model.kwargs, + ) + finally: + self._logger.info(f"end chat completion for prompt {prompt.id}") + prompt.run_end = round(time.time(), 4) - def chat_completion(self, chat: Prompt) -> Message: + def chat_completion(self, prompt: Prompt) -> Message: try: - message: ChatCompletion = self._chat_completion(chat, stream=False) - chat.output = [message] + message: ChatCompletion = self._chat_completion(prompt, stream=False) + prompt.output = [message] pack = self._parser.from_chat_completion(message.choices[0].message) # add completion usage self._model.set(pack) @@ -146,16 +154,16 @@ def chat_completion(self, chat: Prompt) -> Message: pack.chunk = False return pack except Exception as e: - chat.error = str(e) + prompt.error = str(e) raise finally: - self._storage.save(chat) + self._storage.save(prompt) - def chat_completion_chunks(self, chat: Prompt) -> Iterable[Message]: + def chat_completion_chunks(self, prompt: Prompt) -> Iterable[Message]: try: - chunks: Iterable[ChatCompletionChunk] = self._chat_completion(chat, stream=True) + chunks: Iterable[ChatCompletionChunk] = self._chat_completion(prompt, stream=True) messages = self._parser.from_chat_completion_chunks(chunks) - prompt_payload = PromptPayload.from_prompt(chat) + prompt_payload = PromptPayload.from_prompt(prompt) output = [] for chunk in messages: yield chunk @@ -163,20 +171,16 @@ def chat_completion_chunks(self, chat: Prompt) -> Iterable[Message]: self._model.set(chunk) prompt_payload.set(chunk) output.append(chunk) - chat.output = output + prompt.output = output except Exception as e: - chat.error = str(e) + prompt.error = str(e) + raise finally: - self._storage.save(chat) + self._storage.save(prompt) - def parse_chat(self, chat: Prompt) -> Prompt: - # if not chat.functional_tokens: - # return chat - # prompt = FunctionalTokenPrompt(self._functional_token_prompt) - # content = prompt.format_tokens(chat.functional_tokens) - # message = MessageType.DEFAULT.new_system(content=content) - # chat.system.append(message) - return chat + def parse_prompt(self, prompt: Prompt) -> Prompt: + prompt.model = self._model + return prompt class OpenAIDriver(LLMDriver): @@ -184,17 +188,18 @@ class OpenAIDriver(LLMDriver): adapter """ - def __init__(self, storage: PromptStorage, parser: Optional[OpenAIMessageParser] = None): + def __init__(self, storage: PromptStorage, logger: LoggerItf, parser: Optional[OpenAIMessageParser] = None): if parser is None: parser = DefaultOpenAIMessageParser(None, None) self._parser = parser self._storage = storage + self._logger = logger def driver_name(self) -> str: return OPENAI_DRIVER_NAME def new(self, service: ServiceConf, model: ModelConf) -> LLMApi: - return OpenAIAdapter(service, model, self._parser, self._storage) + return OpenAIAdapter(service, model, self._parser, self._storage, self._logger) class LitellmAdapter(OpenAIAdapter): @@ -245,8 +250,9 @@ class OpenAIDriverBootstrapper(Bootstrapper): def bootstrap(self, container: Container) -> None: llms = container.force_fetch(LLMs) + logger = container.force_fetch(LoggerItf) storage = container.force_fetch(PromptStorage) - openai_driver = OpenAIDriver(storage) - lite_llm_driver = LiteLLMDriver(storage) + openai_driver = OpenAIDriver(storage, logger) + lite_llm_driver = LiteLLMDriver(storage, logger) llms.register_driver(openai_driver) llms.register_driver(lite_llm_driver) diff --git a/ghostos/framework/llms/providers.py b/ghostos/framework/llms/providers.py index 0ab60978..fae49ee7 100644 --- a/ghostos/framework/llms/providers.py +++ b/ghostos/framework/llms/providers.py @@ -7,6 +7,7 @@ from ghostos.framework.llms.openai_driver import OpenAIDriver, LiteLLMDriver from ghostos.framework.llms.prompt_storage_impl import PromptStorageImpl from ghostos.contracts.workspace import Workspace +from ghostos.contracts.logger import LoggerItf __all__ = ['ConfigBasedLLMsProvider', 'PromptStorageInWorkspaceProvider'] @@ -35,11 +36,12 @@ class LLMsYamlConfig(YamlConfig, LLMsConfig): configs = con.force_fetch(Configs) storage = con.force_fetch(PromptStorage) + logger = con.force_fetch(LoggerItf) parser = con.get(OpenAIMessageParser) conf = configs.get(LLMsYamlConfig) - openai_driver = OpenAIDriver(storage, parser) - lite_llm_driver = LiteLLMDriver(storage, parser) + openai_driver = OpenAIDriver(storage, logger, parser) + lite_llm_driver = LiteLLMDriver(storage, logger, parser) # register default drivers. llms = LLMsImpl(conf=conf, default_driver=openai_driver) diff --git a/ghostos/ghosts/chatbot/simplest.py b/ghostos/ghosts/chatbot/simplest.py index 0166f46b..d42578c4 100644 --- a/ghostos/ghosts/chatbot/simplest.py +++ b/ghostos/ghosts/chatbot/simplest.py @@ -1,10 +1,11 @@ from typing import Union, Iterable, ClassVar from ghostos.abcd import Agent, GhostDriver, Session, Operator -from ghostos.abcd.thoughts import LLMThought +from ghostos.abcd.thoughts import LLMThought, Thought from ghostos.container import Provider -from ghostos.core.runtime import Event +from ghostos.core.runtime import Event, GoThreadInfo from ghostos.core.messages import Role +from ghostos.core.llms import Prompt from ghostos.entity import ModelEntity from ghostos.prompter import TextPrmt, Prompter from ghostos.identifier import Identifier @@ -20,6 +21,7 @@ class Chatbot(ModelEntity, Agent): persona: str = Field(description="persona of the chatbot") instruction: str = Field(description="instruction of the chatbot") llm_api: str = Field(default="", description="llm api of the chatbot") + history_turns: int = Field(default=20, description="history turns of thread max turns") ArtifactType: ClassVar = None ContextType: ClassVar = None @@ -51,11 +53,35 @@ def get_system_prompter(self) -> Prompter: ) def on_event(self, session: Session, event: Event) -> Union[Operator, None]: + method = getattr(self, f"on_{event.type}", None) + if method is not None: + return method(session, event) + return self.default_handle_event(session, event) + + def thought(self, session: Session) -> Thought: thought = LLMThought(llm_api=self.ghost.llm_api) + return thought + def prompt(self, session: Session) -> Prompt: system_prompter = self.get_system_prompter() system_message = Role.SYSTEM.new(content=system_prompter.get_prompt(session.container)) prompt = session.thread.to_prompt([system_message]) + return prompt + + def truncate(self, session: Session) -> GoThreadInfo: + thread = session.thread + thread.history = thread.history[-self.ghost.history_turns:] + return thread + + def default_handle_event(self, session: Session, event: Event) -> Union[Operator, None]: + # update session thread + session.thread.new_turn(event) + # get thought + thought = self.thought(session) + # get prompt + prompt = self.prompt(session) + + # take action prompt, op = thought.think(session, prompt) if op is not None: return op diff --git a/ghostos/prototypes/console/app.py b/ghostos/prototypes/console/app.py index ac09cdad..62bc94ad 100644 --- a/ghostos/prototypes/console/app.py +++ b/ghostos/prototypes/console/app.py @@ -40,7 +40,7 @@ def __init__( self._ghost = ghost self._username = username self._shell_name = shell_name - self._shell_id = shell_id if shell_id else uuid() + self._shell_id = shell_id if shell_id else shell_name self._process_id = process_id self._console = Console() self._logger = get_console_logger() @@ -51,7 +51,7 @@ def __init__( self._debug = debug self._worker_num = worker_num if not welcome_user_message: - welcome_user_message = "the conversation is going to begin, please welcome user and introduce your self" + welcome_user_message = "the conversation is going to begin, please welcome user" self._welcome_user_message = welcome_user_message self._on_create_message = on_create_message self._main_task_id = "" From 263f70a7c3336540387064f3e6a592abcc86cf09 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 18 Nov 2024 22:05:28 +0800 Subject: [PATCH 095/148] dev: create memory abstract class designing --- ghostos/abcd/memory.py | 54 +++++++++++++++++++++++++ ghostos/framework/cache/storage_impl.py | 30 ++++++++++++++ ghostos/scripts/clear_runtime.py | 4 +- 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 ghostos/abcd/memory.py create mode 100644 ghostos/framework/cache/storage_impl.py diff --git a/ghostos/abcd/memory.py b/ghostos/abcd/memory.py new file mode 100644 index 00000000..5849609a --- /dev/null +++ b/ghostos/abcd/memory.py @@ -0,0 +1,54 @@ +from abc import ABC, abstractmethod +from typing import Type, Iterable +from ghostos.entity import EntityClass +from ghostos.identifier import Identifier, Identical +from .concepts import Scope + + +class Memory(EntityClass, Identical, ABC): + + @abstractmethod + def make_id(self, scope: Scope) -> str: + """ + memory instance is unique to a certain scope by generate unique id from scope ids. + :param scope: + :return: + """ + pass + + +class Memories(ABC): + + @abstractmethod + def match(self, memory_type: Type[Memory]) -> bool: + pass + + @abstractmethod + def remember(self, memory: Memory) -> str: + """ + remember a memory by session scope + :param memory: memory object + :return: memory id + """ + pass + + @abstractmethod + def find(self, memory_id: str, expect: Type[Memory]) -> Memory: + """ + find memory by id + :param memory_id: + :param expect: + :return: + """ + pass + + @abstractmethod + def recall(self, query: str, top_k: int = 10) -> Iterable[Identifier]: + pass + + +class MemoryRepository(Memories, ABC): + + @abstractmethod + def register(self, driver: Memories) -> None: + pass diff --git a/ghostos/framework/cache/storage_impl.py b/ghostos/framework/cache/storage_impl.py new file mode 100644 index 00000000..7721aa19 --- /dev/null +++ b/ghostos/framework/cache/storage_impl.py @@ -0,0 +1,30 @@ +from typing import Optional + +from ghostos.contracts.cache import Cache + + +class StorageCacheImpl(Cache): + + def lock(self, key: str, overdue: int = 0) -> bool: + pass + + def unlock(self, key: str) -> bool: + pass + + def set(self, key: str, val: str, exp: int = 0) -> bool: + pass + + def get(self, key: str) -> Optional[str]: + pass + + def expire(self, key: str, exp: int) -> bool: + pass + + def set_member(self, key: str, member: str, value: str) -> bool: + pass + + def get_member(self, key: str, member: str) -> Optional[str]: + pass + + def remove(self, *keys: str) -> int: + pass \ No newline at end of file diff --git a/ghostos/scripts/clear_runtime.py b/ghostos/scripts/clear_runtime.py index 017d54d6..9e9b3f14 100644 --- a/ghostos/scripts/clear_runtime.py +++ b/ghostos/scripts/clear_runtime.py @@ -100,11 +100,11 @@ def main(): if _all or parsed.prompts: cleared = clear_directory(join(runtime_dir, "prompts"), recursive=True) done += 1 - print(f"clear runtime/cache files: {cleared}") + print(f"clear runtime/prompts files: {cleared}") if _all or parsed.logs: cleared = clear_directory(join(runtime_dir, "logs"), recursive=True) done += 1 - print(f"clear runtime/cache files: {cleared}") + print(f"clear runtime/logs files: {cleared}") if not done: print(f"no files cleared. please check arguments by '-h' option") From 5dd1ad5a7cd3f69c6347d2011bcb1e7186228742 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 18 Nov 2024 22:35:31 +0800 Subject: [PATCH 096/148] dev: console app add background run. streamlit app to go --- ghostos/abcd/memory.py | 17 ++++++++++++----- ghostos/prototypes/console/app.py | 1 + 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/ghostos/abcd/memory.py b/ghostos/abcd/memory.py index 5849609a..904c635e 100644 --- a/ghostos/abcd/memory.py +++ b/ghostos/abcd/memory.py @@ -2,25 +2,32 @@ from typing import Type, Iterable from ghostos.entity import EntityClass from ghostos.identifier import Identifier, Identical -from .concepts import Scope +from ghostos.helpers import generate_import_path +from .concepts import Session, Scope class Memory(EntityClass, Identical, ABC): + """ + memory element + """ @abstractmethod - def make_id(self, scope: Scope) -> str: + def make_id(self, session: Session) -> str: """ - memory instance is unique to a certain scope by generate unique id from scope ids. - :param scope: + memory instance is unique to session, usually create unique id from session scope :return: """ pass + @classmethod + def memory_kind(cls) -> str: + return generate_import_path(cls) + class Memories(ABC): @abstractmethod - def match(self, memory_type: Type[Memory]) -> bool: + def match(self, memory_kind: str) -> bool: pass @abstractmethod diff --git a/ghostos/prototypes/console/app.py b/ghostos/prototypes/console/app.py index 62bc94ad..ebe476ce 100644 --- a/ghostos/prototypes/console/app.py +++ b/ghostos/prototypes/console/app.py @@ -72,6 +72,7 @@ def __del__(self): def run(self): # self._shell.background_run(self._worker_num) self._shell.submit(self._print_output) + self._shell.background_run(self._worker_num, self) asyncio.run(self._main()) def close(self): From 6972ae5e44b11d54c104c9ee0c1cd6fed6d677da Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 19 Nov 2024 01:35:03 +0800 Subject: [PATCH 097/148] dev: create streamlit ghost chat script and page. fulfill it tommorow --- ghostos/core/llms/prompt.py | 7 +-- ghostos/framework/ghostos/ghostos_impl.py | 5 +- ghostos/framework/llms/openai_driver.py | 6 +-- .../streamlitapp/cli/run_ghost_chat.py | 48 +++++++++++++++++++ ghostos/prototypes/streamlitapp/main.py | 6 +-- .../streamlitapp/pages/ghosts/chat.py | 48 +++++++++++++++++++ .../prototypes/streamlitapp/pages/router.py | 22 ++++++++- .../prototypes/streamlitapp/utils/route.py | 2 +- ghostos/scripts/cli/run_console.py | 21 +------- ghostos/scripts/cli/run_streamlit_ghost.py | 34 +++++++++++++ ghostos/scripts/cli/utils.py | 25 ++++++++++ pyproject.toml | 1 + tests/framework/llms/test_llms_config.py | 2 + tests/framework/llms/test_prompt_storage.py | 2 + 14 files changed, 197 insertions(+), 32 deletions(-) create mode 100644 ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py create mode 100644 ghostos/prototypes/streamlitapp/pages/ghosts/chat.py create mode 100644 ghostos/scripts/cli/run_streamlit_ghost.py diff --git a/ghostos/core/llms/prompt.py b/ghostos/core/llms/prompt.py index e693a75f..9d3fa11c 100644 --- a/ghostos/core/llms/prompt.py +++ b/ghostos/core/llms/prompt.py @@ -12,6 +12,7 @@ from pydantic import BaseModel, Field from ghostos import helpers from ghostos.core.messages import Message, Role, Payload +from ghostos.helpers import timestamp from .configs import ModelConf from .tools import LLMFunc, FunctionalToken @@ -46,10 +47,10 @@ class Prompt(BaseModel): # system info output: List[Message] = Field(default_factory=list) error: Optional[str] = Field(default=None, description="error message") - created: float = Field(default_factory=lambda: round(time.time(), 4)) + created: int = Field(default_factory=timestamp) model: Optional[ModelConf] = Field(default=None, description="model conf") - run_start: float = Field(default=0.0, description="start time") - run_end: float = Field(default=0.0, description="end time") + run_start: int = Field(default=0, description="start time") + run_end: int = Field(default=0, description="end time") def system_prompt(self) -> str: contents = [] diff --git a/ghostos/framework/ghostos/ghostos_impl.py b/ghostos/framework/ghostos/ghostos_impl.py index 0d35f582..bfe59017 100644 --- a/ghostos/framework/ghostos/ghostos_impl.py +++ b/ghostos/framework/ghostos/ghostos_impl.py @@ -59,8 +59,9 @@ def create_shell( process_id: Optional[str] = None, ) -> Shell: if name not in self._ghostos_config.shells: - raise NotImplementedError(f"Shell `{name}` not implemented") - shell_conf = self._ghostos_config.shells[name] + shell_conf = ShellConf() + else: + shell_conf = self._ghostos_config.shells[name] process = self._processes.get_process(shell_id) if process is None: process = GoProcess.new(shell_id=shell_id, process_id=process_id) diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index 16b187ad..bd6a2284 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -1,5 +1,4 @@ from typing import List, Iterable, Union, Optional -import time from openai import OpenAI from httpx import Client from httpx_socks import SyncProxyTransport @@ -9,6 +8,7 @@ from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam from openai.types.chat.chat_completion_chunk import ChatCompletionChunk from ghostos.contracts.logger import LoggerItf +from ghostos.helpers import timestamp from ghostos.core.messages import ( Message, OpenAIMessageParser, DefaultOpenAIMessageParser, CompletionUsagePayload, Role, @@ -119,7 +119,7 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion if not messages: raise AttributeError("empty chat!!") try: - prompt.run_start = round(time.time(), 4) + prompt.run_start = timestamp() self._logger.info(f"start chat completion for prompt {prompt.id}") return self._client.chat.completions.create( messages=messages, @@ -137,7 +137,7 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion ) finally: self._logger.info(f"end chat completion for prompt {prompt.id}") - prompt.run_end = round(time.time(), 4) + prompt.run_end = timestamp() def chat_completion(self, prompt: Prompt) -> Message: try: diff --git a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py new file mode 100644 index 00000000..4af6bfe5 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py @@ -0,0 +1,48 @@ +from ghostos.helpers import create_and_bind_module +from ghostos.scripts.cli.run_streamlit_ghost import RunGhostChatApp +from ghostos.bootstrap import make_app_container, get_ghostos +from ghostos.prototypes.streamlitapp.main import main_run +from ghostos.prototypes.streamlitapp.pages.router import default_router, GhostChatPage +from ghostos.prototypes.streamlitapp.utils.session import Singleton +from ghostos.contracts.logger import get_console_logger +import streamlit as st +import sys +import json + +if len(sys.argv) < 2: + raise SystemExit(f"invalid RunAIFuncApp arguments") + + +def bootstrap(): + logger = get_console_logger() + run_aifunc_app_arg = sys.argv[1] + data = json.loads(run_aifunc_app_arg) + + app_arg = RunGhostChatApp(**data) + + if app_arg.is_temp: + # create temp module + logger.debug(f"Create Temp module {app_arg.modulename}") + create_and_bind_module(app_arg.modulename, app_arg.filename) + + # bootstrap container + logger.debug(f"generate ghostos app container at workspace {app_arg.workspace_dir}") + container = make_app_container(app_arg.workspace_dir) + + # bound route. + page_route = GhostChatPage(ghost_meta=app_arg.ghost_meta) + # initialize router and set aifunc is default + router = default_router().with_current(page_route) + + ghostos = get_ghostos(container) + shell = ghostos.create_shell("ghostos_streamlit_app", "ghostos_streamlit_app") + + return [ + Singleton(container), + Singleton(router), + Singleton(ghostos), + Singleton(shell), + ] + + +main_run(bootstrap) diff --git a/ghostos/prototypes/streamlitapp/main.py b/ghostos/prototypes/streamlitapp/main.py index 58918aae..22ef023f 100644 --- a/ghostos/prototypes/streamlitapp/main.py +++ b/ghostos/prototypes/streamlitapp/main.py @@ -71,8 +71,8 @@ def main_run(bootstrap: BOOTSTRAP) -> None: with st.sidebar: router.render_homepage() # render page links - with st.expander(label=_("Navigator"), expanded=False, icon=":material/menu:"): - router.render_navigator(use_container_width=True) + # with st.expander(label=_("Navigator"), expanded=False, icon=":material/menu:"): + # router.render_navigator(use_container_width=True) # with helper mode toggle # open_navigator = st.button( # label=_("GhostOS Navigator"), @@ -89,7 +89,7 @@ def main_run(bootstrap: BOOTSTRAP) -> None: label=_("Debug Mode"), tips=_("switch debug mode at every page"), ) - st.subheader(_("page menu")) + st.subheader(_("Menu")) # global navigator dialog # if open_navigator: diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py b/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py new file mode 100644 index 00000000..7caa2e3c --- /dev/null +++ b/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py @@ -0,0 +1,48 @@ +import streamlit as st +from ghostos.prototypes.streamlitapp.pages.router import GhostChatPage +from ghostos.identifier import get_identifier +from ghostos.helpers import gettext as _ + + +def main(): + ghost_chat_page = GhostChatPage.get_or_bind(st.session_state) + ghost = ghost_chat_page.get_ghost() + id_ = get_identifier(ghost) + + input_type = "chat" + with st.sidebar: + st.button("Chat", type="primary", icon=":material/chat:", use_container_width=True) + if st.button("Image Input", use_container_width=True): + input_type = "image" + if st.button("Textarea Input", use_container_width=True): + input_type = "text" + if st.button("File Input", use_container_width=True): + input_type = "file" + st.button("Video Shortcut Input", use_container_width=True) + st.divider() + st.button("Ghost Setting", type="secondary", use_container_width=True) + if st.button("Context Setting", type="secondary", use_container_width=True): + open_test_dialog() + st.button("Task Info", type="secondary", use_container_width=True) + st.button("Thread Info", type="secondary", use_container_width=True) + st.button("Subtasks", type="secondary", use_container_width=True) + + st.subheader(_("You are talking to: ") + id_.name) + if id_.description: + st.caption(id_.description) + + for i in range(10): + with st.chat_message("assistant"): + st.write("hello world") + + if input_type == "chat": + if inputs := st.chat_input(): + st.write(inputs) + elif input_type == "image": + if inputs := st.file_uploader("Choose an image...", type="jpg"): + st.write(inputs) + + +@st.dialog("test") +def open_test_dialog(): + st.write("hello world") diff --git a/ghostos/prototypes/streamlitapp/pages/router.py b/ghostos/prototypes/streamlitapp/pages/router.py index 949abe37..de97b309 100644 --- a/ghostos/prototypes/streamlitapp/pages/router.py +++ b/ghostos/prototypes/streamlitapp/pages/router.py @@ -2,6 +2,8 @@ from ghostos.prototypes.streamlitapp.utils.route import Route, Router, Link from ghostos.core.messages import Message from ghostos.core.aifunc import ExecFrame +from ghostos.abcd import Ghost +from ghostos.entity import EntityMeta, from_entity_meta, get_entity from enum import Enum from pydantic import Field @@ -9,16 +11,33 @@ class PagePath(str, Enum): HOMEPAGE = "ghostos.prototypes.streamlitapp.pages.homepage" AIFUNCS = "ghostos.prototypes.streamlitapp.pages.aifuncs" + GHOSTOS = "ghostos.prototypes.streamlitapp.pages.ghosts" def suffix(self, attr_name: str): return self.value + attr_name +# --- ghosts --- # + +class GhostChatPage(Route): + link = Link( + name="ghost_chat", + import_path=PagePath.GHOSTOS.suffix(".chat:main"), + streamlit_icon=":material/smart_toy:", + button_help="todo", + antd_icon="robot", + ) + ghost_meta: Optional[EntityMeta] = Field(default=None, description="ghost meta") + + def get_ghost(self) -> Ghost: + return get_entity(self.ghost_meta, Ghost) + + # --- home --- # class Home(Route): link = Link( - name="Home", + name="GhostOS", import_path=PagePath.HOMEPAGE.suffix(":home"), streamlit_icon=":material/home:", button_help="help", @@ -107,6 +126,7 @@ def default_router() -> Router: GhostOSHost(), AIFuncListRoute(), AIFuncDetailRoute(), + GhostChatPage(), ], home=Home.label(), navigator_page_names=[ diff --git a/ghostos/prototypes/streamlitapp/utils/route.py b/ghostos/prototypes/streamlitapp/utils/route.py index 93c8b902..d3fe4bbb 100644 --- a/ghostos/prototypes/streamlitapp/utils/route.py +++ b/ghostos/prototypes/streamlitapp/utils/route.py @@ -184,7 +184,7 @@ def __init__( self.default_menu_tree = default_menu self.default_sidebar_buttons = default_sidebar_buttons self.default_navigator_names = navigator_page_names - self.current_page = current_page if current_page is not None else self.home + self.current_page: str = current_page if current_page is not None else self.home def with_current(self, route: Route) -> Self: self.current_page = route.label() diff --git a/ghostos/scripts/cli/run_console.py b/ghostos/scripts/cli/run_console.py index 3ad6fdbf..dd3ab951 100644 --- a/ghostos/scripts/cli/run_console.py +++ b/ghostos/scripts/cli/run_console.py @@ -1,32 +1,15 @@ from ghostos.abcd import Ghost from ghostos.scripts.cli.utils import ( check_ghostos_workspace_exists, - parse_args_modulename_or_filename, get_or_create_module_from_name, + get_ghost_by_cli_argv, ) from ghostos.bootstrap import make_app_container, get_ghostos from ghostos.prototypes.console import ConsoleApp -def get_ghost() -> Ghost: - filename_or_modulename, args = parse_args_modulename_or_filename() - found = get_or_create_module_from_name(filename_or_modulename, "ghostos.temp.agent") - - if found.value is not None: - if not isinstance(found.value, Ghost): - raise SystemExit(f"{found.value} is not a Ghost object") - ghost = found.value - elif "__ghost__" in found.module.__dict__: - ghost = found.module.__dict__["__ghost__"] - if not isinstance(ghost, Ghost): - raise SystemExit(f"{filename_or_modulename} __ghost__ is not a Ghost object") - else: - raise SystemExit(f"cant find ghost instance at {filename_or_modulename}") - return ghost - - def main(): workspace_dir = check_ghostos_workspace_exists() - ghost = get_ghost() + ghost, modulename, filename, is_temp = get_ghost_by_cli_argv() container = make_app_container(workspace_dir) ghostos = get_ghostos(container) app = ConsoleApp(ghostos=ghostos, ghost=ghost, username="") diff --git a/ghostos/scripts/cli/run_streamlit_ghost.py b/ghostos/scripts/cli/run_streamlit_ghost.py new file mode 100644 index 00000000..5c65a833 --- /dev/null +++ b/ghostos/scripts/cli/run_streamlit_ghost.py @@ -0,0 +1,34 @@ +from ghostos.scripts.cli.utils import ( + check_ghostos_workspace_exists, + get_ghost_by_cli_argv, +) +from streamlit.web.cli import main_run +from ghostos.prototypes.streamlitapp import cli +from ghostos.entity import EntityMeta, to_entity_meta +from pydantic import BaseModel, Field +import sys +from os import path + + +class RunGhostChatApp(BaseModel): + modulename: str = Field(description="expect ghost modulename") + filename: str = Field(description="expect ghost filename") + is_temp: bool = Field(description="if the modulename is temp module") + workspace_dir: str = Field(description="the ghostos dir") + ghost_meta: EntityMeta + + +def main(): + # path + workspace_dir = check_ghostos_workspace_exists() + ghost, modulename, filename, is_temp = get_ghost_by_cli_argv() + args = RunGhostChatApp( + modulename=modulename, + filename=filename, + is_temp=is_temp, + workspace_dir=workspace_dir, + ghost_meta=to_entity_meta(ghost), + ) + script_path = path.join(path.dirname(cli.__file__), "run_ghost_chat.py") + args = [script_path, args.model_dump_json(), *sys.argv[1:]] + main_run(args) diff --git a/ghostos/scripts/cli/utils.py b/ghostos/scripts/cli/utils.py index 9ceeddac..dc207a9f 100644 --- a/ghostos/scripts/cli/utils.py +++ b/ghostos/scripts/cli/utils.py @@ -6,6 +6,31 @@ from ghostos.contracts.logger import get_console_logger from ghostos.helpers import create_module, import_from_path import inspect +from ghostos.abcd import Ghost + +__all__ = [ + 'get_ghost_by_cli_argv', + 'get_or_create_module_from_name', + 'check_ghostos_workspace_exists', + 'parse_args_modulename_or_filename', +] + + +def get_ghost_by_cli_argv() -> Tuple[Ghost, str, str, bool]: + filename_or_modulename, args = parse_args_modulename_or_filename() + found = get_or_create_module_from_name(filename_or_modulename, "ghostos.temp.agent") + + if found.value is not None: + if not isinstance(found.value, Ghost): + raise SystemExit(f"{found.value} is not a Ghost object") + ghost = found.value + elif "__ghost__" in found.module.__dict__: + ghost = found.module.__dict__["__ghost__"] + if not isinstance(ghost, Ghost): + raise SystemExit(f"{filename_or_modulename} __ghost__ is not a Ghost object") + else: + raise SystemExit(f"cant find ghost instance at {filename_or_modulename}") + return ghost, found.module.__name__, found.filename, found.is_temp def check_ghostos_workspace_exists() -> str: diff --git a/pyproject.toml b/pyproject.toml index e810a5a1..20f6c15d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ clear_runtime = "ghostos.scripts.clear_runtime:main" hello = "ghostos.scripts.cli.run_helloworld:main" aifunc = "ghostos.scripts.cli.run_aifunc:main" console = "ghostos.scripts.cli.run_console:main" +ghost = "ghostos.scripts.cli.run_streamlit_ghost:main" [build-system] requires = ["poetry-core"] diff --git a/tests/framework/llms/test_llms_config.py b/tests/framework/llms/test_llms_config.py index ecf7b729..9c8e118f 100644 --- a/tests/framework/llms/test_llms_config.py +++ b/tests/framework/llms/test_llms_config.py @@ -7,12 +7,14 @@ from ghostos.contracts.configs import YamlConfig, Configs from ghostos.framework.configs import ConfigsByStorageProvider from ghostos.framework.storage import MemStorage, Storage +from ghostos.framework.logger import LoggerItf, FakeLogger from ghostos.framework.llms import ConfigBasedLLMsProvider, PromptStorage, PromptStorageImpl def _prepare_container() -> Container: container = Container() storage = MemStorage() + container.set(LoggerItf, FakeLogger()) container.set(Storage, storage) container.register(ConfigsByStorageProvider('configs')) container.set(PromptStorage, PromptStorageImpl(storage.sub_storage("prompts"))) diff --git a/tests/framework/llms/test_prompt_storage.py b/tests/framework/llms/test_prompt_storage.py index 167bfa95..318a29b5 100644 --- a/tests/framework/llms/test_prompt_storage.py +++ b/tests/framework/llms/test_prompt_storage.py @@ -13,4 +13,6 @@ def test_prompt_storage_baseline(): prompts.save(prompt) got = prompts.get(id_) + assert got.inputs == prompt.inputs + assert got.id == prompt.id assert got == prompt From 25aabdacf90f4c229bcbba4b259bec98210f76b9 Mon Sep 17 00:00:00 2001 From: zhuming Date: Wed, 20 Nov 2024 00:51:27 +0800 Subject: [PATCH 098/148] dev: ghost chat is near complete, message renderer to go --- ghostos/abcd/concepts.py | 4 + ghostos/core/runtime/tasks.py | 5 +- .../framework/ghostos/conversation_impl.py | 9 +- .../streamlitapp/cli/run_ghost_chat.py | 9 +- .../streamlitapp/pages/aifuncs/detail.py | 4 +- .../streamlitapp/pages/ghosts/chat.py | 277 +++++++++++++++--- .../prototypes/streamlitapp/pages/router.py | 26 +- .../prototypes/streamlitapp/utils/session.py | 12 +- .../streamlitapp/widgets/messages.py | 4 +- .../streamlitapp/widgets/renderer.py | 53 ++++ ghostos/streamlit.py | 52 ++++ ghostos/thoughts/chat.py | 2 +- ghostos/thoughts/moss_thought.py | 2 +- .../core/messages/test_arr_stream_receiver.py | 3 +- tests/python/test_pydantic.py | 10 + 15 files changed, 408 insertions(+), 64 deletions(-) create mode 100644 ghostos/prototypes/streamlitapp/widgets/renderer.py create mode 100644 ghostos/streamlit.py diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index 4891512c..0835d982 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -351,6 +351,10 @@ def get_artifact(self) -> Tuple[Union[G.ArtifactType, None], TaskState]: def talk(self, query: str, user_name: str = "") -> Receiver: pass + @abstractmethod + def update_context(self, context: Context) -> None: + pass + @abstractmethod def respond( self, diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index 06f16f47..ed00147d 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -1,4 +1,5 @@ import time +from datetime import datetime from typing import Optional, List, ClassVar, Dict, Self from abc import ABC, abstractmethod from enum import Enum @@ -254,10 +255,6 @@ class TaskBrief(BaseModel, Identical): created: int = Field(description="the time the task was created") updated: int = Field(description="the time that task was updated") - def is_overdue(self) -> bool: - now = time.time() - return now - self.updated > self.overdue - def __identifier__(self) -> Identifier: return Identifier( id=self.id, diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index a73ada92..d938cd37 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -1,7 +1,7 @@ from typing import Optional, Iterable, List, TypeVar, Tuple, Union from ghostos.container import Container -from ghostos.abcd import Conversation, Scope, Ghost +from ghostos.abcd import Conversation, Scope, Ghost, Context from ghostos.abcd import run_session_event from ghostos.core.messages import ( Message, Role, @@ -61,6 +61,7 @@ def __init__( logger = container.force_fetch(LoggerItf) self._logger = wrap_logger(logger, self._scope.model_dump()) self._is_background = is_background + self._ctx: Optional[Context] = None self._locker = task_locker self._tasks = container.force_fetch(GoTasks) self._threads = container.force_fetch(GoThreads) @@ -95,6 +96,9 @@ def talk(self, query: str, user_name: str = "") -> Receiver: message = Role.USER.new(content=query, name=user_name) return self.respond([message]) + def update_context(self, context: Context) -> None: + self._ctx = context + def respond( self, inputs: Iterable[Message], @@ -103,6 +107,9 @@ def respond( ) -> Receiver: self._validate_closed() context_meta = to_entity_meta(context) if context is not None else None + if self._ctx is not None: + context_meta = to_entity_meta(self._ctx) + self._ctx = None event = EventTypes.INPUT.new( task_id=self._scope.task_id, messages=list(inputs), diff --git a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py index 4af6bfe5..89a0d818 100644 --- a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py +++ b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py @@ -2,9 +2,10 @@ from ghostos.scripts.cli.run_streamlit_ghost import RunGhostChatApp from ghostos.bootstrap import make_app_container, get_ghostos from ghostos.prototypes.streamlitapp.main import main_run -from ghostos.prototypes.streamlitapp.pages.router import default_router, GhostChatPage +from ghostos.prototypes.streamlitapp.pages.router import default_router, GhostChatRoute from ghostos.prototypes.streamlitapp.utils.session import Singleton from ghostos.contracts.logger import get_console_logger +from ghostos.abcd import GhostOS, Shell import streamlit as st import sys import json @@ -30,7 +31,7 @@ def bootstrap(): container = make_app_container(app_arg.workspace_dir) # bound route. - page_route = GhostChatPage(ghost_meta=app_arg.ghost_meta) + page_route = GhostChatRoute(ghost_meta=app_arg.ghost_meta) # initialize router and set aifunc is default router = default_router().with_current(page_route) @@ -40,8 +41,8 @@ def bootstrap(): return [ Singleton(container), Singleton(router), - Singleton(ghostos), - Singleton(shell), + Singleton(ghostos, GhostOS), + Singleton(shell, Shell), ] diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py index 0cbe0c74..4f665055 100644 --- a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py @@ -14,7 +14,7 @@ ) from ghostos.prototypes.streamlitapp.widgets.moss import render_pycontext from ghostos.prototypes.streamlitapp.widgets.messages import ( - render_message, + render_message_item, render_messages, ) from ghostos.core.messages import new_arr_connection @@ -118,7 +118,7 @@ def render_aifunc_execute_stream(route: AIFuncDetailRoute, fn: Type[AIFunc]): if not item.is_complete(): continue route.received.append(item) - render_message(item, debug=False) + render_message_item(item, debug=False) st.write(f"executed in {round(timeleft.passed(), 2)} seconds") diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py b/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py index 7caa2e3c..222fff03 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py @@ -1,48 +1,245 @@ import streamlit as st -from ghostos.prototypes.streamlitapp.pages.router import GhostChatPage +import time +import streamlit_react_jsonschema as srj +from ghostos.prototypes.streamlitapp.pages.router import GhostChatRoute +from ghostos.prototypes.streamlitapp.utils.session import Singleton +from ghostos.prototypes.streamlitapp.widgets.messages import ( + render_messages, + render_message_item, +) +from ghostos.prototypes.streamlitapp.widgets.renderer import render_object +from ghostos.core.runtime import GoThreadInfo +from ghostos.core.messages import Receiver, Role +from ghostos.abcd import Shell, Conversation, Context from ghostos.identifier import get_identifier from ghostos.helpers import gettext as _ +from ghostos.helpers import generate_import_path, yaml_pretty_dump +from pydantic import BaseModel, Field +import inspect + + +class ButtonInfo(BaseModel): + label: str = Field(description="The label of the subpage.") + help: str = Field(default="todo", description="The help text of the subpage.") + icon: str = Field(default=":material/thumb_up:", description="The icon of the subpage.") + + +chat = ButtonInfo( + label=_("Chat"), +) +ghost_settings = ButtonInfo( + label=_("Ghost Settings"), +) +context_settings = ButtonInfo( + label=_("Context Settings"), +) +task_info = ButtonInfo( + label=_("Task Info"), +) +thread_info = ButtonInfo( + label=_("Thread Info"), +) + +subpages = [chat, context_settings, ghost_settings, task_info, thread_info] + +chat_input_type = ButtonInfo( + label=_("Chat Input"), +) + +input_types = [chat_input_type] def main(): - ghost_chat_page = GhostChatPage.get_or_bind(st.session_state) - ghost = ghost_chat_page.get_ghost() - id_ = get_identifier(ghost) + route = GhostChatRoute.get_or_bind(st.session_state) + # create shell + ghost = route.get_ghost() + context = route.get_context() + conversation = Singleton.get(Conversation, st.session_state, force=False) + if not conversation: + shell = Singleton.get(Shell, st.session_state) + # create conversation + conversation = shell.sync(ghost, context) + Singleton(conversation, Conversation).bind(st.session_state) + + # run the pages + run_chat_page(route, conversation) + + # rebind route. + route.bind(st.session_state) - input_type = "chat" + +def run_chat_page(route: GhostChatRoute, conversation: Conversation): with st.sidebar: - st.button("Chat", type="primary", icon=":material/chat:", use_container_width=True) - if st.button("Image Input", use_container_width=True): - input_type = "image" - if st.button("Textarea Input", use_container_width=True): - input_type = "text" - if st.button("File Input", use_container_width=True): - input_type = "file" - st.button("Video Shortcut Input", use_container_width=True) - st.divider() - st.button("Ghost Setting", type="secondary", use_container_width=True) - if st.button("Context Setting", type="secondary", use_container_width=True): - open_test_dialog() - st.button("Task Info", type="secondary", use_container_width=True) - st.button("Thread Info", type="secondary", use_container_width=True) - st.button("Subtasks", type="secondary", use_container_width=True) - - st.subheader(_("You are talking to: ") + id_.name) - if id_.description: - st.caption(id_.description) - - for i in range(10): - with st.chat_message("assistant"): - st.write("hello world") - - if input_type == "chat": - if inputs := st.chat_input(): - st.write(inputs) - elif input_type == "image": - if inputs := st.file_uploader("Choose an image...", type="jpg"): - st.write(inputs) - - -@st.dialog("test") -def open_test_dialog(): - st.write("hello world") + for subpage in subpages: + button = st.button( + label=subpage.label, + help=subpage.help, + icon=subpage.icon, + use_container_width=True, + ) + if button: + route.page_type = subpage.label + if route.page_type == chat.label: + st.divider() + if st.button("Image Input", use_container_width=True): + route.input_type = "image" + if st.button("Textarea Input", use_container_width=True): + route.input_type = "text" + if st.button("File Input", use_container_width=True): + route.input_type = "file" + if st.button("Video Shortcut Input", use_container_width=True): + route.input_type = "video" + + # header + st.title("Ghost") + ghost = route.get_ghost() + id_ = get_identifier(ghost) + import_path = generate_import_path(ghost.__class__) + data = { + _("name"): id_.name, + _("desc"): id_.description, + _("class"): import_path, + } + # description + st.markdown(f""" +```yaml +{yaml_pretty_dump(data)} +``` +""") + + # body + if route.page_type == context_settings.label: + render_context_settings(route, conversation) + elif route.page_type == ghost_settings.label: + render_ghost_settings(route) + elif route.page_type == task_info.label: + render_task_info_settings(route, conversation) + elif route.page_type == thread_info.label: + render_thread_info_settings(route, conversation) + else: + render_chat(route, conversation) + + +def render_chat(route: GhostChatRoute, conversation: Conversation): + st.title(route.page_type) + if route.input_type == "any": + pass + else: + st.write("chat input") + if chat_input := st.chat_input("Your message"): + message = Role.USER.new(chat_input) + route.inputs.append(message) + route.bind(st.session_state) + chatting() + + +@st.fragment +def chatting(): + conversation = Singleton.get(Conversation, st.session_state) + thread = conversation.thread() + render_thread_messages(thread) + + while True: + route = GhostChatRoute.get(st.session_state) + # has input + if route is not None and route.inputs: + inputs = route.inputs + route.inputs = [] + route.bind(st.session_state) + + with st.chat_message("user"): + st.write("todo") + + receiver = conversation.respond(inputs) + render_receiver(receiver) + elif event := conversation.pop_event(): + receiver = conversation.respond_event(event) + render_receiver(receiver) + else: + time.sleep(0.5) + + +def render_receiver(receiver: Receiver): + receiver.wait() + st.write("todo") + + +def render_ghost_settings(route: GhostChatRoute): + ghost = route.get_ghost() + st.subheader(_(route.page_type)) + # render ghost info + if isinstance(ghost, BaseModel): + data, mod = srj.pydantic_instance_form(ghost) + if st.button("Save"): + st.write("todo saving ghosts") + else: + st.write(ghost) + source = inspect.getsource(ghost.__class__) + with st.expander("source code", expanded=False): + st.code(source) + + +def render_context_settings(route: GhostChatRoute, conversation: Conversation): + st.subheader(route.page_type) + ctx = route.get_context() + ghost = route.get_ghost() + if ctx is None and ghost.ContextType is None: + st.info("No specific Context for this Ghost") + return + if ctx is None: + if ghost.ContextType is not None: + data, submitted = srj.pydantic_form(ghost.ContextType) + if submitted and isinstance(data, Context): + conversation.update_context(data) + ctx = data + else: + data, submitted = srj.pydantic_instance_form(ctx) + if submitted and isinstance(data, Context): + conversation.update_context(data) + ctx = data + + # render prompt + if ctx is not None: + st.subheader(_("context prompt")) + try: + prompt = ctx.get_prompt(conversation.container()) + st.markdown(prompt) + except Exception as e: + st.error(e) + if ghost.ArtifactType: + st.subheader(_("Artifact")) + artifact = conversation.get_artifact() + render_object(artifact) + + +def render_task_info_settings(route: GhostChatRoute, conversation: Conversation): + from ghostos.core.runtime.tasks import TaskBrief + st.subheader(route.page_type) + task = conversation.task() + brief = TaskBrief.from_task(task) + srj.pydantic_instance_form(brief, readonly=True) + + with st.expander(_("Detail"), expanded=False): + st.write(task.model_dump(exclude_defaults=True)) + + +def render_thread_info_settings(route: GhostChatRoute, conversation: Conversation): + st.subheader(route.page_type) + + thread = conversation.thread() + + with st.expander(_("Detail"), expanded=False): + st.write(thread_info.model_dump(exclude_defaults=True)) + st.subheader(_("Thread Messages")) + render_thread_messages(thread) + + +def render_thread_messages(thread: GoThreadInfo): + turns = thread.turns() + count = 0 + for turn in turns: + count += 1 + messages = list(turn.messages()) + if messages: + st.write(f"turn {count}") + render_messages(messages) diff --git a/ghostos/prototypes/streamlitapp/pages/router.py b/ghostos/prototypes/streamlitapp/pages/router.py index de97b309..bb967994 100644 --- a/ghostos/prototypes/streamlitapp/pages/router.py +++ b/ghostos/prototypes/streamlitapp/pages/router.py @@ -2,7 +2,7 @@ from ghostos.prototypes.streamlitapp.utils.route import Route, Router, Link from ghostos.core.messages import Message from ghostos.core.aifunc import ExecFrame -from ghostos.abcd import Ghost +from ghostos.abcd import Ghost, Context from ghostos.entity import EntityMeta, from_entity_meta, get_entity from enum import Enum from pydantic import Field @@ -19,7 +19,7 @@ def suffix(self, attr_name: str): # --- ghosts --- # -class GhostChatPage(Route): +class GhostChatRoute(Route): link = Link( name="ghost_chat", import_path=PagePath.GHOSTOS.suffix(".chat:main"), @@ -28,9 +28,27 @@ class GhostChatPage(Route): antd_icon="robot", ) ghost_meta: Optional[EntityMeta] = Field(default=None, description="ghost meta") + context_meta: Optional[EntityMeta] = Field(default=None, description="context meta") + input_type: str = Field(default="Chat", description="input type") + page_type: str = Field(default="Chat", description="page type") + + inputs: List[Message] = Field(default_factory=list, description="inputs") + + __ghost__ = None def get_ghost(self) -> Ghost: - return get_entity(self.ghost_meta, Ghost) + if self.__ghost__ is None: + self.__ghost__ = get_entity(self.ghost_meta, Ghost) + return self.__ghost__ + + __context__ = None + + def get_context(self) -> Optional[Context]: + if self.context_meta is None: + return None + if self.__context__ is None: + self.__context__ = get_entity(self.context_meta, Context) + return self.__context__ # --- home --- # @@ -126,7 +144,7 @@ def default_router() -> Router: GhostOSHost(), AIFuncListRoute(), AIFuncDetailRoute(), - GhostChatPage(), + GhostChatRoute(), ], home=Home.label(), navigator_page_names=[ diff --git a/ghostos/prototypes/streamlitapp/utils/session.py b/ghostos/prototypes/streamlitapp/utils/session.py index f4c0f9cd..58a373f4 100644 --- a/ghostos/prototypes/streamlitapp/utils/session.py +++ b/ghostos/prototypes/streamlitapp/utils/session.py @@ -95,9 +95,11 @@ class Singleton: session state singleton, key is the class type """ - def __init__(self, value: object): + def __init__(self, value: object, abstract: Optional[Type] = None): self.value = value - self.key = self.gen_key(type(value)) + if abstract is None: + abstract = type(value) + self.key = self.gen_key(abstract) def bind(self, session_state: MutableMapping, force: bool = False) -> None: """ @@ -108,10 +110,12 @@ def bind(self, session_state: MutableMapping, force: bool = False) -> None: session_state[self.key] = self.value @classmethod - def get(cls, t: Type[T], session_state: MutableMapping) -> T: + def get(cls, t: Type[T], session_state: MutableMapping, force: bool = True) -> T: key = cls.gen_key(t) if key not in session_state: - raise KeyError(f'key {key} not found in session state') + if force: + raise KeyError(f'key {key} not found in session state') + return None value = session_state[key] return value diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py index ee5d82d0..f7edf6ac 100644 --- a/ghostos/prototypes/streamlitapp/widgets/messages.py +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -8,10 +8,10 @@ def render_messages(messages: Iterable[Message]): debug = get_app_conf().BoolOpts.DEBUG_MODE.get() for msg in messages: - render_message(msg, debug=debug) + render_message_item(msg, debug=debug) -def render_message(msg: Message, debug: bool): +def render_message_item(msg: Message, debug: bool): if not msg.is_complete(): return if MessageType.ERROR.match(msg): diff --git a/ghostos/prototypes/streamlitapp/widgets/renderer.py b/ghostos/prototypes/streamlitapp/widgets/renderer.py new file mode 100644 index 00000000..4b19f3e5 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/widgets/renderer.py @@ -0,0 +1,53 @@ +import streamlit as st +import streamlit_react_jsonschema as srj +from pydantic import BaseModel +from ghostos.helpers import generate_import_path, yaml_pretty_dump +from ghostos.streamlit import render_streamlit_object, StreamlitRenderer +from ghostos.prototypes.streamlitapp.utils.session import Singleton +from ghostos.container import Container +import inspect + +__all__ = ['render_object'] + + +def render_object(obj) -> None: + if obj is None: + st.info("None") + + container = Singleton.get(Container, st.session_state) + renderer = container.get(StreamlitRenderer) + if renderer and renderer.render(obj): + return + + if render_streamlit_object(obj): + return + + if inspect.isclass(obj): + source = inspect.getsource(obj) + st.subheader(f"Class {generate_import_path(obj)}") + st.code(source) + elif inspect.isfunction(obj): + source = inspect.getsource(obj) + st.subheader(f"Function {generate_import_path(obj)}") + st.code(source) + elif isinstance(obj, BaseModel): + srj.pydantic_instance_form(obj, readonly=True) + elif isinstance(obj, dict): + st.subheader("Dictionary") + st.markdown(f""" +```yaml +{yaml_pretty_dump(obj)} +``` +""") + elif isinstance(obj, list): + st.subheader("List") + st.markdown(f""" +```yaml +{yaml_pretty_dump(obj)} +``` +""") + else: + type_ = type(obj) + st.subheader(f"Type {generate_import_path(type_)}") + with st.container(border=True): + st.write(obj) diff --git a/ghostos/streamlit.py b/ghostos/streamlit.py new file mode 100644 index 00000000..b495f25c --- /dev/null +++ b/ghostos/streamlit.py @@ -0,0 +1,52 @@ +from typing import Protocol +from abc import ABC, abstractmethod + +__all__ = [ + 'StreamlitObject', 'StreamlitRenderable', + 'is_streamlit_renderable', 'render_streamlit_object', + 'StreamlitRenderer', 'GroupRenderer', +] + + +class StreamlitRenderable(Protocol): + + @abstractmethod + def __streamlit_render__(self): + pass + + +class StreamlitObject(ABC): + @abstractmethod + def __streamlit_render__(self): + pass + + +class StreamlitRenderer(ABC): + + @abstractmethod + def render(self, value) -> bool: + pass + + +class GroupRenderer(StreamlitRenderer): + def __init__(self, *renderers: StreamlitRenderer): + self.renderers = list(renderers) + + def render(self, value) -> bool: + for renderer in self.renderers: + if renderer.render(value): + return True + return False + + +def is_streamlit_renderable(obj): + return isinstance(obj, StreamlitObject) or hasattr(obj, "__streamlit_render__") + + +def render_streamlit_object(obj) -> bool: + if is_streamlit_renderable(obj): + fn = getattr(obj, "__streamlit_render__", None) + if fn is not None: + fn() + return True + return False diff --git a/ghostos/thoughts/chat.py b/ghostos/thoughts/chat.py index 354d82de..8fcba84c 100644 --- a/ghostos/thoughts/chat.py +++ b/ghostos/thoughts/chat.py @@ -29,4 +29,4 @@ def actions(self, g: Ghost, e: Event) -> Iterable[Action]: return [] def instruction(self, g: Ghost, e: Event) -> str: - return self.thought.instruction + return self.thought.show_instruction diff --git a/ghostos/thoughts/moss_thought.py b/ghostos/thoughts/moss_thought.py index 4c3452f1..2a35830c 100644 --- a/ghostos/thoughts/moss_thought.py +++ b/ghostos/thoughts/moss_thought.py @@ -89,7 +89,7 @@ def actions(self, g: Ghost, e: Event) -> Iterable[Action]: class MossThoughtDriver(BasicMossThoughtDriver, LLMThoughtDriver[MossThought]): def instruction(self, g: Ghost, e: Event) -> str: - return self.thought.instruction + return self.thought.show_instruction def on_created(self, g: Ghost, e: Event) -> Optional[Operator]: session = g.session() diff --git a/tests/core/messages/test_arr_stream_receiver.py b/tests/core/messages/test_arr_stream_receiver.py index c516c452..5bd2ed0e 100644 --- a/tests/core/messages/test_arr_stream_receiver.py +++ b/tests/core/messages/test_arr_stream_receiver.py @@ -158,11 +158,12 @@ def test_new_connection_with_pool(): def send_data(s: Stream, c: str): with s: s.send(iter_content(c, 0.02)) + s.send(iter_content(c, 0.02)) pool.submit(send_data, stream, content) with retriever: messages = retriever.wait() - assert len(messages) == 1 + assert len(messages) == 2 assert retriever.error() is None pool.shutdown(wait=True) diff --git a/tests/python/test_pydantic.py b/tests/python/test_pydantic.py index 367018c3..5dd87848 100644 --- a/tests/python/test_pydantic.py +++ b/tests/python/test_pydantic.py @@ -1,3 +1,5 @@ +import time + from pydantic import BaseModel, Field from pydantic.errors import PydanticSchemaGenerationError from typing import TypedDict, Required, Iterable, List, Optional, ClassVar, Type @@ -213,3 +215,11 @@ class BarType(BaseModel): foo2 = Foo2() assert foo2.bar.bar == 123 + + +def test_model_with_datetime(): + class Foo(BaseModel): + now: datetime = Field(default_factory=datetime.now) + + foo = Foo(now=int(time.time())) + assert foo.now.timestamp() > 0 From b3484f94d88cfb2f366d995be7f878c99081cbfa Mon Sep 17 00:00:00 2001 From: zhuming Date: Wed, 20 Nov 2024 17:44:36 +0800 Subject: [PATCH 099/148] dev: prepare for final streaming rendering --- ghostos/abcd/concepts.py | 2 +- ghostos/core/messages/transport.py | 97 ++++++++++++++++++- ghostos/core/runtime/events.py | 21 +++- ghostos/core/runtime/threads.py | 22 +---- .../framework/ghostos/conversation_impl.py | 4 +- ghostos/framework/messages/__init__.py | 1 + ghostos/prototypes/console/app.py | 4 +- .../streamlitapp/pages/ghosts/chat.py | 90 ++++++++++++----- ghostos/prototypes/streamlitapp/resources.py | 2 - .../streamlitapp/widgets/messages.py | 71 ++++++++++++-- .../streamlitapp/widgets/renderer.py | 29 ++++-- ghostos/streamlit.py | 34 ++++--- .../core/messages/test_arr_stream_receiver.py | 57 ++++++++++- tests/test_streamlit_render.py | 21 ++++ 14 files changed, 379 insertions(+), 76 deletions(-) create mode 100644 tests/test_streamlit_render.py diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index 0835d982..1d7ea29c 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -361,7 +361,7 @@ def respond( inputs: Iterable[Message], context: Optional[G.ContextType] = None, history: Optional[List[Message]] = None, - ) -> Receiver: + ) -> Tuple[Event, Receiver]: """ create response immediately by inputs. the inputs will change to event. """ diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index 0ccd6347..ba910a6c 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -1,4 +1,5 @@ -from typing import Iterable, Optional, Callable, Tuple, List +from __future__ import annotations +from typing import Iterable, Optional, Callable, Tuple, List, Self, Iterator from typing_extensions import Protocol from collections import deque @@ -7,7 +8,10 @@ from ghostos.core.messages.pipeline import SequencePipe import time -__all__ = ["Stream", "Receiver", "ArrayReceiver", "ArrayStream", "new_arr_connection"] +__all__ = [ + "Stream", "Receiver", "ArrayReceiver", "ArrayStream", "new_arr_connection", + "ArrayReceiverBuffer", +] from ghostos.helpers import Timeleft @@ -126,6 +130,24 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> Optional[bool]: return intercept +class StreamPart(Protocol): + @abstractmethod + def head(self) -> Tuple[Message, bool]: + pass + + @abstractmethod + def chunks(self) -> Iterable[Message]: + pass + + @abstractmethod + def tail(self) -> Message: + pass + + @abstractmethod + def next(self) -> Optional[Self]: + pass + + class ArrayReceiver(Receiver): def __init__( @@ -275,6 +297,77 @@ def closed(self) -> bool: return self._closed +class ArrayReceiverBuffer: + def __init__(self, head: Message, receiver: Iterator[Message]): + self._head = head + self._receiver = receiver + self._chunks = [] + self._done: Optional[Message] = None + self._next: Optional[Self] = None + + @classmethod + def new(cls, receiver: Iterable[Message]) -> Optional[Self]: + iterator = iter(receiver) + head = next(iterator) + if head is None: + return None + return cls(head, iterator) + + def head(self) -> Message: + return self._head + + def chunks(self) -> Iterable[Message]: + if self._head.is_complete(): + yield from [self._head] + return + elif self._done is not None: + return self._chunks + + self._chunks = [self._head] + yield self._head + head = self._head.get_copy() + try: + item = next(self._receiver) + except StopIteration: + self._done = head.as_tail() + return None + + while item is not None: + patched = head.patch(item) + if patched is not None: + head = patched + if item.is_complete(): + self._done = patched + else: + self._chunks.append(item) + yield item + else: + if self._done is None: + self._done = head.as_tail() + self._next = ArrayReceiverBuffer(item, self._receiver) + break + try: + item = next(self._receiver) + except StopIteration: + break + if self._done is None: + self._done = self._head.as_tail() + + def tail(self) -> Message: + if self._head.is_complete(): + return self._head + if self._done: + return self._done + list(self.chunks()) + if self._done is None: + raise RuntimeError(f"tail failed") + return self._done + + def next(self) -> Optional[Self]: + list(self.chunks()) + return self._next + + def new_arr_connection( *, timeout: float = -1, diff --git a/ghostos/core/runtime/events.py b/ghostos/core/runtime/events.py index 43b2773b..ef530410 100644 --- a/ghostos/core/runtime/events.py +++ b/ghostos/core/runtime/events.py @@ -1,9 +1,9 @@ -from typing import List, Optional, Dict, Any +from typing import List, Optional, Dict, Any, Iterable from typing_extensions import Self from abc import ABC, abstractmethod from enum import Enum from pydantic import BaseModel, Field -from ghostos.core.messages.message import Message +from ghostos.core.messages.message import Message, Role from ghostos.entity import EntityMeta from ghostos.helpers import uuid from contextlib import contextmanager @@ -89,6 +89,23 @@ def no_reason_or_instruction(self) -> bool: def default_handler(self) -> str: return f"on_{self.event_type()}" + def iter_message(self, show_instruction: bool = True) -> Iterable[Message]: + if EventTypes.CREATED.value != self.type and self.from_task_name and not self.from_self(): + reason = "" + if self.reason: + reason = f" Reason: {self.reason}" + yield Role.new_system( + content=f"receive self {self.type} from task `{self.from_task_name}`.{reason}") + + # messages in middle + if self.messages: + for message in self.messages: + yield message + + # instruction after messages. + if show_instruction and self.instruction: + yield Role.new_system(content=self.instruction) + @classmethod def new( cls, *, diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index 2e8c5e27..870ec688 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -69,24 +69,7 @@ def event_messages(self, show_instruction: bool = False) -> Iterable[Message]: @staticmethod def iter_event_message(event: Event, show_instruction: bool = True) -> Iterable[Message]: - if event is None: - return [] - - if EventTypes.CREATED.value != event.type and event.from_task_name and not event.from_self(): - reason = "" - if event.reason: - reason = f" Reason: {event.reason}" - yield Role.new_system( - content=f"receive event {event.type} from task `{event.from_task_name}`.{reason}") - - # messages in middle - if event.messages: - for message in event.messages: - yield message - - # instruction after messages. - if show_instruction and event.instruction: - yield Role.new_system(content=event.instruction) + yield from event.iter_message(show_instruction) def messages(self) -> Iterable[Message]: yield from self.event_messages() @@ -96,6 +79,9 @@ def messages(self) -> Iterable[Message]: def is_empty(self) -> bool: return (self.event is None or self.event.is_empty()) and not self.added + def is_from_client(self) -> bool: + return self.event is not None and self.event.from_task_id is None + class GoThreadInfo(BaseModel): """ diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index d938cd37..b915c242 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -104,7 +104,7 @@ def respond( inputs: Iterable[Message], context: Optional[Ghost.ContextType] = None, history: Optional[List[Message]] = None, - ) -> Receiver: + ) -> Tuple[Event, Receiver]: self._validate_closed() context_meta = to_entity_meta(context) if context is not None else None if self._ctx is not None: @@ -116,7 +116,7 @@ def respond( context=context_meta, history=history, ) - return self.respond_event(event) + return event, self.respond_event(event) def respond_event( self, diff --git a/ghostos/framework/messages/__init__.py b/ghostos/framework/messages/__init__.py index 30630295..68be5701 100644 --- a/ghostos/framework/messages/__init__.py +++ b/ghostos/framework/messages/__init__.py @@ -1,3 +1,4 @@ +from ghostos.core.messages import Buffer from ghostos.framework.messages.buffers import DefaultBuffer # default payloads diff --git a/ghostos/prototypes/console/app.py b/ghostos/prototypes/console/app.py index ebe476ce..cf7ff7ab 100644 --- a/ghostos/prototypes/console/app.py +++ b/ghostos/prototypes/console/app.py @@ -95,7 +95,7 @@ async def _main(self): message = Role.new_system( self._welcome_user_message, ) - receiver = self._conversation.respond([message]) + event, receiver = self._conversation.respond([message]) self.output_receiver(receiver) with patch_stdout(raw=True): @@ -189,7 +189,7 @@ def _on_message_input(self, message: Message) -> None: """ :return: task_id """ - receiver = self._conversation.respond([message]) + event, receiver = self._conversation.respond([message]) self.output_receiver(receiver) def _intercept_text(self, text: str) -> bool: diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py b/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py index 222fff03..e1e1a490 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py @@ -3,12 +3,14 @@ import streamlit_react_jsonschema as srj from ghostos.prototypes.streamlitapp.pages.router import GhostChatRoute from ghostos.prototypes.streamlitapp.utils.session import Singleton +from ghostos.prototypes.streamlitapp.resources import get_app_conf from ghostos.prototypes.streamlitapp.widgets.messages import ( render_messages, render_message_item, ) from ghostos.prototypes.streamlitapp.widgets.renderer import render_object -from ghostos.core.runtime import GoThreadInfo +from ghostos.prototypes.streamlitapp.resources import get_app_conf +from ghostos.core.runtime import GoThreadInfo, Turn, Event, GoThreads from ghostos.core.messages import Receiver, Role from ghostos.abcd import Shell, Conversation, Context from ghostos.identifier import get_identifier @@ -125,7 +127,6 @@ def render_chat(route: GhostChatRoute, conversation: Conversation): if route.input_type == "any": pass else: - st.write("chat input") if chat_input := st.chat_input("Your message"): message = Role.USER.new(chat_input) route.inputs.append(message) @@ -141,27 +142,30 @@ def chatting(): while True: route = GhostChatRoute.get(st.session_state) + debug = get_app_conf().BoolOpts.DEBUG_MODE.get() # has input if route is not None and route.inputs: inputs = route.inputs route.inputs = [] route.bind(st.session_state) - - with st.chat_message("user"): - st.write("todo") - - receiver = conversation.respond(inputs) - render_receiver(receiver) + event, receiver = conversation.respond(inputs) + render_event(event, debug) + render_receiver(receiver, debug) elif event := conversation.pop_event(): + render_event(event, debug) receiver = conversation.respond_event(event) - render_receiver(receiver) + render_receiver(receiver, debug) else: - time.sleep(0.5) + time.sleep(1) -def render_receiver(receiver: Receiver): - receiver.wait() - st.write("todo") +def render_receiver(receiver: Receiver, debug: bool): + try: + with receiver: + messages = receiver.wait() + render_messages(messages, debug) + except Exception as e: + st.exception(e) def render_ghost_settings(route: GhostChatRoute): @@ -193,19 +197,20 @@ def render_context_settings(route: GhostChatRoute, conversation: Conversation): conversation.update_context(data) ctx = data else: - data, submitted = srj.pydantic_instance_form(ctx) - if submitted and isinstance(data, Context): + data, changed = render_object(ctx, immutable=False) + if changed and isinstance(data, Context): conversation.update_context(data) ctx = data # render prompt if ctx is not None: - st.subheader(_("context prompt")) + st.subheader(_("Context prompt")) try: prompt = ctx.get_prompt(conversation.container()) st.markdown(prompt) except Exception as e: st.error(e) + # render artifact if ghost.ArtifactType: st.subheader(_("Artifact")) artifact = conversation.get_artifact() @@ -230,16 +235,57 @@ def render_thread_info_settings(route: GhostChatRoute, conversation: Conversatio with st.expander(_("Detail"), expanded=False): st.write(thread_info.model_dump(exclude_defaults=True)) + if st.button("reset history"): + # reset history + thread = conversation.thread() + thread.history = [] + thread.current = None + threads = conversation.container().force_fetch(GoThreads) + threads.save_thread(thread) + st.subheader(_("Thread Messages")) render_thread_messages(thread) def render_thread_messages(thread: GoThreadInfo): turns = thread.turns() - count = 0 + debug = get_app_conf().BoolOpts.DEBUG_MODE.get() for turn in turns: - count += 1 - messages = list(turn.messages()) - if messages: - st.write(f"turn {count}") - render_messages(messages) + render_turn(turn, debug) + + +def render_turn(turn: Turn, debug: bool): + if turn.is_from_client(): + messages = turn.messages() + render_messages(messages, debug) + # from other task + else: + event = turn.event + sub_title = _("background run") + if event is not None: + sub_title = _("background event: ") + event.type + with st.expander(sub_title, expanded=False): + messages = turn.messages() + render_messages(messages, debug) + render_event_object(event, debug) + + +def render_event(event: Event, debug: bool): + if event is None: + return + if event.from_task_id: + sub_title = _("background event: ") + event.type + with st.expander(sub_title, expanded=False): + messages = event.iter_message(show_instruction=True) + render_messages(messages, debug) + else: + messages = event.iter_message(show_instruction=True) + render_messages(messages, debug) + + +def render_event_object(event: Event, debug: bool): + if event is None: + return + from_task_name = event.from_task_name + if from_task_name is not None: + st.button(f"from task {from_task_name}") diff --git a/ghostos/prototypes/streamlitapp/resources.py b/ghostos/prototypes/streamlitapp/resources.py index 3baa6397..6cc416ca 100644 --- a/ghostos/prototypes/streamlitapp/resources.py +++ b/ghostos/prototypes/streamlitapp/resources.py @@ -20,8 +20,6 @@ class AppConf(YamlConfig): domain: str = GHOSTOS_DOMAIN lang: str = Field("zh", description="lang of the app") - help_mode: bool = False - debug_mode: bool = False bool_options: Dict[str, bool] = Field( default_factory=dict, diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py index f7edf6ac..52172229 100644 --- a/ghostos/prototypes/streamlitapp/widgets/messages.py +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -1,14 +1,73 @@ import streamlit as st -from typing import Iterable -from ghostos.core.messages import Message, Role, MessageType -from ghostos.prototypes.streamlitapp.resources import get_app_conf +from typing import Iterable, List, NamedTuple +from ghostos.core.messages import Message, Role, MessageType, Caller from ghostos.helpers import gettext as _ -def render_messages(messages: Iterable[Message]): - debug = get_app_conf().BoolOpts.DEBUG_MODE.get() +class MessageGroup(NamedTuple): + msg_name: str + msg_role: str + stage: str + messages: List[Message] + + +def render_messages(messages: Iterable[Message], debug: bool): + groups: List[MessageGroup] = [] + group = MessageGroup("", "", "", []) + for msg in messages: - render_message_item(msg, debug=debug) + if not msg.is_complete(): + continue + if msg.name != group.msg_name or msg.role != group.msg_role or msg.stage != group.stage: + if group.messages: + groups.append(group) + group = MessageGroup(msg.name, msg.role, msg.stage, []) + group.messages.append(msg) + + if group.messages: + groups.append(group) + for group in groups: + render_message_group(group, debug) + + +def render_message_group(group: MessageGroup, debug: bool): + role = group.msg_role + name = group.msg_name + stage = group.stage + caption = f"{role}: {name}" if name else role + render_role = "user" if role == Role.USER.value else "assistant" + if stage: + with st.container(border=True): + st.caption(stage) + with st.chat_message(render_role): + st.caption(caption) + for msg in group.messages: + render_message_content(msg, debug) + else: + with st.chat_message(render_role): + st.caption(caption) + for msg in group.messages: + render_message_content(msg, debug) + + +def render_message_content(message: Message, debug: bool): + if message.type == MessageType.ERROR: + st.error(f"Error: {message.content}") + elif MessageType.is_text(message): + st.markdown(message.content) + # todo: more types + else: + st.write(message.model_dump(exclude_defaults=True)) + + if message.callers: + if st.button("tool calls", key="tool calls" + message.msg_id): + open_message_caller(message) + + +@st.dialog("message_caller") +def open_message_caller(message: Message): + for caller in message.callers: + st.write(caller.model_dump(exclude_defaults=True)) def render_message_item(msg: Message, debug: bool): diff --git a/ghostos/prototypes/streamlitapp/widgets/renderer.py b/ghostos/prototypes/streamlitapp/widgets/renderer.py index 4b19f3e5..499195ce 100644 --- a/ghostos/prototypes/streamlitapp/widgets/renderer.py +++ b/ghostos/prototypes/streamlitapp/widgets/renderer.py @@ -1,3 +1,4 @@ +from typing import TypeVar, Tuple import streamlit as st import streamlit_react_jsonschema as srj from pydantic import BaseModel @@ -9,29 +10,42 @@ __all__ = ['render_object'] +T = TypeVar('T') -def render_object(obj) -> None: + +def render_object(obj: T, immutable: bool = False) -> Tuple[T, bool]: + """ + render an object in a streamlit compatible way. + :param obj: the object to render + :param immutable: is the object immutable? + :return: [value: obj after rendering, changed: is object changed] + """ if obj is None: st.info("None") container = Singleton.get(Container, st.session_state) renderer = container.get(StreamlitRenderer) - if renderer and renderer.render(obj): - return + if renderer: + r = renderer.render(obj) + if r is not None: + return r.value, r.changed - if render_streamlit_object(obj): - return + if r := render_streamlit_object(obj): + return r.value, r.changed if inspect.isclass(obj): source = inspect.getsource(obj) st.subheader(f"Class {generate_import_path(obj)}") st.code(source) + return obj, False elif inspect.isfunction(obj): source = inspect.getsource(obj) st.subheader(f"Function {generate_import_path(obj)}") st.code(source) + return obj, False elif isinstance(obj, BaseModel): - srj.pydantic_instance_form(obj, readonly=True) + obj, submitted = srj.pydantic_instance_form(obj, readonly=immutable) + return obj, submitted elif isinstance(obj, dict): st.subheader("Dictionary") st.markdown(f""" @@ -39,6 +53,7 @@ def render_object(obj) -> None: {yaml_pretty_dump(obj)} ``` """) + return obj, False elif isinstance(obj, list): st.subheader("List") st.markdown(f""" @@ -46,8 +61,10 @@ def render_object(obj) -> None: {yaml_pretty_dump(obj)} ``` """) + return obj, False else: type_ = type(obj) st.subheader(f"Type {generate_import_path(type_)}") with st.container(border=True): st.write(obj) + return obj, False diff --git a/ghostos/streamlit.py b/ghostos/streamlit.py index b495f25c..0c63ee47 100644 --- a/ghostos/streamlit.py +++ b/ghostos/streamlit.py @@ -1,30 +1,38 @@ -from typing import Protocol +from typing import Protocol, Tuple, Self, TypeVar, Optional, NamedTuple, Any, Generic from abc import ABC, abstractmethod __all__ = [ 'StreamlitObject', 'StreamlitRenderable', 'is_streamlit_renderable', 'render_streamlit_object', 'StreamlitRenderer', 'GroupRenderer', + 'Rendered', ] +T = TypeVar('T') + + +class Rendered(Generic[T], NamedTuple): + value: T + changed: bool + class StreamlitRenderable(Protocol): @abstractmethod - def __streamlit_render__(self): + def __streamlit_render__(self) -> Optional[Rendered[Self]]: pass class StreamlitObject(ABC): @abstractmethod - def __streamlit_render__(self): + def __streamlit_render__(self) -> Optional[Rendered[Self]]: pass class StreamlitRenderer(ABC): @abstractmethod - def render(self, value) -> bool: + def render(self, value: T) -> Optional[Rendered[T]]: pass @@ -32,21 +40,23 @@ class GroupRenderer(StreamlitRenderer): def __init__(self, *renderers: StreamlitRenderer): self.renderers = list(renderers) - def render(self, value) -> bool: + def render(self, value: T) -> Optional[Rendered[T]]: for renderer in self.renderers: - if renderer.render(value): - return True - return False + result = renderer.render(value) + if result is None: + continue + return result + return None def is_streamlit_renderable(obj): return isinstance(obj, StreamlitObject) or hasattr(obj, "__streamlit_render__") -def render_streamlit_object(obj) -> bool: +def render_streamlit_object(obj: T) -> Optional[Rendered[T]]: if is_streamlit_renderable(obj): fn = getattr(obj, "__streamlit_render__", None) if fn is not None: - fn() - return True - return False + r = fn() + return r + return None diff --git a/tests/core/messages/test_arr_stream_receiver.py b/tests/core/messages/test_arr_stream_receiver.py index 5bd2ed0e..2ec2cccc 100644 --- a/tests/core/messages/test_arr_stream_receiver.py +++ b/tests/core/messages/test_arr_stream_receiver.py @@ -1,5 +1,5 @@ from typing import Iterable -from ghostos.core.messages.transport import new_arr_connection, Stream +from ghostos.core.messages.transport import new_arr_connection, Stream, ArrayReceiverBuffer from ghostos.core.messages.pipeline import SequencePipe from ghostos.core.messages.message import Message from threading import Thread @@ -167,3 +167,58 @@ def send_data(s: Stream, c: str): assert len(messages) == 2 assert retriever.error() is None pool.shutdown(wait=True) + + +def test_array_receiver_buffer_baseline(): + stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + content = "hello world" + + def send_data(s: Stream, c: str): + messages = SequencePipe().across(iter_content(c, 0)) + messages = list(messages) + s.send(messages) + + with stream: + send_data(stream, content) + send_data(stream, content) + + buffer = ArrayReceiverBuffer.new(retriever.recv()) + assert buffer is not None + assert buffer.head().content == "h" + for chunk in buffer.chunks(): + assert chunk.content in content + assert len(chunk.content) == 1 + assert not chunk.is_complete() + + assert buffer.tail().content == content + assert buffer.tail().is_complete() + buffer = buffer.next() + assert buffer is not None + assert buffer.head().content == "h" + assert buffer.tail().content == content + + buffer = buffer.next() + assert buffer is None + + +def test_array_receiver_buffer_async(): + from ghostos.contracts.pool import DefaultPool + pool = DefaultPool(10) + stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + content = "hello world" + + def send_data(s: Stream, c: str): + with s: + s.send(iter_content(c, 0.02)) + s.send(iter_content(c, 0.02)) + + pool.submit(send_data, stream, content) + + with retriever: + buffer = ArrayReceiverBuffer.new(retriever.recv()) + assert buffer.tail().content == content + buffer = buffer.next() + assert buffer.tail().content == content + buffer = buffer.next() + assert buffer is None + pool.shutdown(wait=True) diff --git a/tests/test_streamlit_render.py b/tests/test_streamlit_render.py new file mode 100644 index 00000000..d525c59c --- /dev/null +++ b/tests/test_streamlit_render.py @@ -0,0 +1,21 @@ +from typing import Optional, Self + +from ghostos.streamlit import ( + is_streamlit_renderable, + StreamlitObject, + render_streamlit_object, + Rendered +) + + +def test_render_streamlit_object(): + class Foo(StreamlitObject): + + def __streamlit_render__(self) -> Optional[Rendered[Self]]: + return Rendered(value=self, changed=False) + + foo = Foo() + assert is_streamlit_renderable(foo) + r = render_streamlit_object(foo) + assert not r.changed + assert r.value is foo From a72143e74f63a6a1e5da8f415f59a6f317b3aeec Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 21 Nov 2024 21:58:59 +0800 Subject: [PATCH 100/148] dev: complete basic chat page, very simple but ok to run --- .ghostos/.streamlit/config.toml | 4 +- .../docs/ghostos/zh/aifunc/usage_example.md | 4 +- .ghostos/configs/streamlit_app.yml | 2 - examples/aifunc_raw_test.py | 4 +- ghostos/abcd/concepts.py | 22 +- ghostos/abcd/thoughts.py | 4 +- ghostos/abcd/utils.py | 6 +- ghostos/bootstrap.py | 18 +- ghostos/contracts/logger.py | 26 +- ghostos/core/aifunc/driver.py | 2 +- ghostos/core/llms/configs.py | 2 +- ghostos/core/llms/prompt.py | 4 +- ghostos/core/messages/__init__.py | 2 +- ghostos/core/messages/openai.py | 2 +- ghostos/core/messages/payload.py | 6 +- ghostos/core/messages/transport.py | 12 +- ghostos/core/runtime/tasks.py | 4 +- ghostos/entity.py | 2 + ghostos/framework/documents/__init__.py | 2 +- .../framework/ghostos/conversation_impl.py | 42 ++- ghostos/framework/ghostos/ghostos_impl.py | 6 +- ghostos/framework/ghostos/session_impl.py | 1 + ghostos/framework/ghostos/shell_impl.py | 44 ++- ghostos/framework/llms/__init__.py | 2 +- ghostos/framework/llms/openai_driver.py | 13 +- ghostos/framework/llms/providers.py | 19 +- ghostos/framework/logger/named.py | 25 +- ghostos/framework/messages/__init__.py | 2 + ghostos/framework/messages/buffers.py | 4 +- ghostos/framework/messengers/defaults.py | 2 +- ghostos/framework/tasks/storage_tasks.py | 9 +- ghostos/identifier.py | 3 +- ghostos/prototypes/console/app.py | 14 +- .../prototypes/realtime/openai/broadcast.py | 2 +- ghostos/prototypes/realtime/openai/ws.py | 2 +- .../streamlitapp/cli/run_ghost_chat.py | 46 ++- ghostos/prototypes/streamlitapp/main.py | 6 +- .../streamlitapp/pages/aifuncs/detail.py | 4 +- .../prototypes/streamlitapp/pages/configs.py | 41 +++ .../streamlitapp/pages/ghosts/chat.py | 286 ++++++++---------- .../prototypes/streamlitapp/pages/router.py | 40 ++- .../prototypes/streamlitapp/utils/route.py | 26 +- .../prototypes/streamlitapp/utils/session.py | 27 +- .../streamlitapp/widgets/dialogs.py | 84 ++++- .../streamlitapp/widgets/messages.py | 48 ++- .../streamlitapp/widgets/renderer.py | 104 ++++++- ghostos/scripts/cli/run_streamlit_ghost.py | 2 + ghostos/scripts/cli/utils.py | 2 +- ghostos/thoughts/basic.py | 4 +- .../core/messages/test_arr_stream_receiver.py | 26 +- tests/framework/messenger/test_messenger.py | 4 +- 51 files changed, 712 insertions(+), 356 deletions(-) create mode 100644 ghostos/prototypes/streamlitapp/pages/configs.py diff --git a/.ghostos/.streamlit/config.toml b/.ghostos/.streamlit/config.toml index 75cef043..c288c9e1 100644 --- a/.ghostos/.streamlit/config.toml +++ b/.ghostos/.streamlit/config.toml @@ -22,7 +22,7 @@ # "info", or "debug". # Default: "info" -# level = "info" +level = "debug" # String format for logging messages. If logger.datetimeFormat is set, # logger messages will default to `%(asctime)s.%(msecs)03d %(message)s`. See @@ -30,7 +30,7 @@ # https://docs.python.org/3/library/logging.html#formatter-objects # Default: "%(asctime)s %(message)s" -# messageFormat = "%(asctime)s %(message)s" + messageFormat = "%(asctime)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)" [client] diff --git a/.ghostos/assets/docs/ghostos/zh/aifunc/usage_example.md b/.ghostos/assets/docs/ghostos/zh/aifunc/usage_example.md index 572891ec..bcda9b3c 100644 --- a/.ghostos/assets/docs/ghostos/zh/aifunc/usage_example.md +++ b/.ghostos/assets/docs/ghostos/zh/aifunc/usage_example.md @@ -20,7 +20,7 @@ def call_example(con: Container, req: WeatherAIFunc) -> WeatherAIFuncResult: ```python from ghostos.container import Container from ghostos.core.aifunc import AIFuncExecutor, ExecFrame -from ghostos.core.messages import new_arr_connection +from ghostos.core.messages import new_basic_connection from ghostos.demo.aifuncs.weather import WeatherAIFunc, WeatherAIFuncResult @@ -32,7 +32,7 @@ def stream_call_example(con: Container, req: WeatherAIFunc) -> WeatherAIFuncResu executor = con.force_fetch(AIFuncExecutor) - stream, receiver = new_arr_connection() + stream, receiver = new_basic_connection() frame = ExecFrame.from_func(req) t = Thread(target=executor.execute, args=(req, frame, stream)) t.start() diff --git a/.ghostos/configs/streamlit_app.yml b/.ghostos/configs/streamlit_app.yml index 17784775..ba7b6af7 100644 --- a/.ghostos/configs/streamlit_app.yml +++ b/.ghostos/configs/streamlit_app.yml @@ -2,7 +2,5 @@ bool_options: DEBUG_MODE: false HELP_MODE: false -debug_mode: false domain: ghostos -help_mode: false lang: zh diff --git a/examples/aifunc_raw_test.py b/examples/aifunc_raw_test.py index f8e7fc9e..ae50425f 100644 --- a/examples/aifunc_raw_test.py +++ b/examples/aifunc_raw_test.py @@ -1,7 +1,7 @@ import sys from os.path import dirname from ghostos.core.aifunc import AIFuncExecutor -from ghostos.core.messages.transport import new_arr_connection +from ghostos.core.messages.transport import new_basic_connection # I hate python imports ghostos_project_dir = dirname(dirname(__file__)) @@ -29,7 +29,7 @@ fn = AgentFn( request="help me to find news about OpenAI O1 model", ) - stream, receiver = new_arr_connection(timeout=-1, complete_only=True) + stream, receiver = new_basic_connection(timeout=-1, complete_only=True) frame, caller = executor.new_exec_frame(fn, stream) t = Thread(target=caller) t.start() diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index 1d7ea29c..ef24a45a 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -239,11 +239,11 @@ def on_error(self, error: Exception) -> bool: pass @abstractmethod - def on_event(self, event: Event, retriever: Receiver) -> None: + def on_event(self, event: Event, messages: List[Message]) -> None: pass @abstractmethod - def stopped(self) -> bool: + def alive(self) -> bool: pass @abstractmethod @@ -322,6 +322,10 @@ def submit(self, caller: Callable, *args, **kwargs): def close(self): pass + @abstractmethod + def closed(self) -> bool: + pass + class Conversation(Protocol[G]): """ @@ -343,12 +347,24 @@ def task(self) -> GoTaskStruct: def thread(self) -> GoThreadInfo: pass + @abstractmethod + def update_thread(self, thread: GoThreadInfo) -> None: + pass + + @abstractmethod + def get_ghost(self) -> G: + pass + + @abstractmethod + def get_context(self) -> G.ContextType: + pass + @abstractmethod def get_artifact(self) -> Tuple[Union[G.ArtifactType, None], TaskState]: pass @abstractmethod - def talk(self, query: str, user_name: str = "") -> Receiver: + def talk(self, query: str, user_name: str = "") -> Tuple[Event, Receiver]: pass @abstractmethod diff --git a/ghostos/abcd/thoughts.py b/ghostos/abcd/thoughts.py index 81d6235f..317fd318 100644 --- a/ghostos/abcd/thoughts.py +++ b/ghostos/abcd/thoughts.py @@ -68,11 +68,11 @@ def think(self, session: Session, prompt: Prompt) -> Tuple[Prompt, Optional[Oper llm_api = self.get_llm_api(session) streaming = not session.upstream.completes_only() - session.logger.info(f"start llm thinking on prompt {prompt.id}") + session.logger.debug("start llm thinking on prompt %s", prompt.id) items = llm_api.deliver_chat_completion(prompt, streaming) messages, callers = session.respond(items, self.message_stage) prompt.added.extend(messages) - session.logger.info(f"llm thinking on prompt {prompt.id} is done") + session.logger.debug("llm thinking on prompt % is done", prompt.id) for caller in callers: if caller.name in self.actions: diff --git a/ghostos/abcd/utils.py b/ghostos/abcd/utils.py index 704d9f29..576e1d64 100644 --- a/ghostos/abcd/utils.py +++ b/ghostos/abcd/utils.py @@ -47,7 +47,7 @@ def is_ghost(value) -> bool: def fire_session_event(session: Session, event: Event) -> Optional[Operator]: event, op = session.parse_event(event) if op is not None: - session.logger.info("session event is intercepted and op %s is returned", op) + session.logger.debug("session event is intercepted and op %s is returned", op) return op if event is None: # if event is intercepted, stop the run. @@ -77,9 +77,9 @@ def run_session_event(session: Session, event: Event, max_step: int) -> None: raise RuntimeError(f"Max step {max_step} reached") if not session.refresh(): raise RuntimeError("Session refresh failed") - session.logger.info("start session op %s", op) + session.logger.debug("start session op %s", op) next_op = op.run(session) - session.logger.info("done session op %s", op) + session.logger.debug("done session op %s", op) op.destroy() # session do save after each op session.save() diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index c44ce8c1..93484acf 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -70,6 +70,8 @@ # reset ghostos default application instances. 'reset', + 'reset_at', + 'get_container', # default configuration 'workspace_dir', @@ -168,7 +170,6 @@ def default_application_providers( runtime_processes_dir: str = "processes", runtime_tasks_dir: str = "tasks", runtime_threads_dir: str = "threads", - llms_conf_path: str = "llms_conf.yml", ) -> List[Provider]: """ application default providers @@ -220,7 +221,7 @@ def default_application_providers( DefaultMOSSProvider(), # --- llm --- # - ConfigBasedLLMsProvider(llms_conf_path), + ConfigBasedLLMsProvider(), PromptStorageInWorkspaceProvider(), # --- basic library --- # @@ -279,7 +280,11 @@ def get_ghostos(container: Optional[Container] = None) -> GhostOS: return container.force_fetch(GhostOS) -def reset(con: Container) -> None: +def get_container() -> Container: + return application_container + + +def reset(con: Container) -> Container: """ reset static ghostos application level instances :param con: a container with application level contract bindings, shall be validated outside. @@ -290,19 +295,20 @@ def reset(con: Container) -> None: application_container = con # reset global ghost func ghost_func = init_ghost_func(application_container) + return application_container -def reset_at(app_dir: str) -> None: +def reset_at(app_dir: str) -> Container: """ reset application with default configuration at specified app directory only run once if app_dir is the same """ global workspace_dir if app_dir == workspace_dir: - return + return application_container workspace_dir = app_dir _container = make_app_container(app_dir) - reset(_container) + return reset(_container) # --- test the module by python -i --- # diff --git a/ghostos/contracts/logger.py b/ghostos/contracts/logger.py index 4e047ddd..8036a187 100644 --- a/ghostos/contracts/logger.py +++ b/ghostos/contracts/logger.py @@ -8,7 +8,7 @@ __all__ = [ 'LoggerItf', 'config_logging', 'get_logger', 'get_console_logger', 'get_debug_logger', - 'wrap_logger', + 'wrap_logger', 'LoggerAdapter', ] @@ -36,7 +36,7 @@ def info(self, msg, *args, **kwargs): To pass exception information, use the keyword argument exc_info with a true value, e.g. - logger.info("Houston, we have a %s", "notable problem", exc_info=True) + logger.debug("Houston, we have a %s", "notable problem", exc_info=True) """ pass @@ -127,30 +127,30 @@ def get_console_logger( ) -> LoggerItf: logger = getLogger(name) if not logger.hasHandlers(): - logger.setLevel(logging.INFO) _console_handler = logging.StreamHandler() - if debug: - _console_handler.setLevel(logging.DEBUG) _console_formatter = PleshakovFormatter() _console_handler.setFormatter(_console_formatter) logger.addHandler(_console_handler) + + if debug: + logger.setLevel(logging.DEBUG) return LoggerAdapter(logger, extra=extra) def get_debug_logger( name: str = "__ghostos_debug__", extra: Optional[dict] = None, - debug: bool = False, ) -> LoggerItf: logger = getLogger(name) if not logger.hasHandlers(): - logger.setLevel(logging.INFO) - _debug_file_handler = logging.FileHandler("debug.log") - if debug: - _debug_file_handler.setLevel(logging.DEBUG) - formatter = logging.Formatter(fmt="%(asctime)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)") + _debug_file_handler = logging.FileHandler("debug.log", mode="a") + formatter = logging.Formatter( + fmt="%(asctime)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)", + ) _debug_file_handler.setFormatter(formatter) + _debug_file_handler.setLevel(logging.DEBUG) logger.addHandler(_debug_file_handler) + logger.setLevel(logging.DEBUG) return LoggerAdapter(logger, extra=extra) @@ -185,3 +185,7 @@ def format(self, record): get_console_logger().error("hello world") get_console_logger().warning("hello world") get_console_logger().critical("hello world") + get_debug_logger().debug("debug") + get_debug_logger().info("debug") + get_debug_logger().error("debug") + get_debug_logger().critical("debug") diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index 2a763b82..385c6628 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -141,7 +141,7 @@ def on_message(self, message: Message, step: ExecStep, upstream: Optional[Stream message = message.model_copy(deep=True) message.name = self.aifunc.func_name() payload = step.as_payload() - payload.set(message) + payload.set_payload(message) upstream.deliver(message) def on_system_messages(self, messages: List[Message]) -> None: diff --git a/ghostos/core/llms/configs.py b/ghostos/core/llms/configs.py index d9833452..69b05b94 100644 --- a/ghostos/core/llms/configs.py +++ b/ghostos/core/llms/configs.py @@ -3,7 +3,6 @@ import os from typing import List, Dict, Optional, Any, ClassVar - from pydantic import BaseModel, Field from ghostos.core.messages import Payload @@ -68,6 +67,7 @@ class LLMsConfig(BaseModel): """ llms configurations for ghostos.core.llms.llm:LLMs default implementation. """ + services: List[ServiceConf] = Field( default_factory=list, description="define llm services, such as openai or moonshot", diff --git a/ghostos/core/llms/prompt.py b/ghostos/core/llms/prompt.py index 9d3fa11c..490356de 100644 --- a/ghostos/core/llms/prompt.py +++ b/ghostos/core/llms/prompt.py @@ -183,12 +183,12 @@ def fork( class PromptPayload(Payload): key = "prompt_info" - pid: str = Field(description="created from prompt") + prompt_id: str = Field(description="created from prompt") desc: str = Field(default="description of the prompt") @classmethod def from_prompt(cls, prompt: Prompt) -> Self: - return cls(pid=prompt.id, desc=prompt.description) + return cls(prompt_id=prompt.id, desc=prompt.description) class PromptPipe(ABC): diff --git a/ghostos/core/messages/__init__.py b/ghostos/core/messages/__init__.py index 68d9e945..96f07632 100644 --- a/ghostos/core/messages/__init__.py +++ b/ghostos/core/messages/__init__.py @@ -14,4 +14,4 @@ ) from ghostos.core.messages.buffers import Buffer, Flushed from ghostos.core.messages.utils import copy_messages -from ghostos.core.messages.transport import Stream, Receiver, new_arr_connection +from ghostos.core.messages.transport import Stream, Receiver, new_basic_connection, ReceiverBuffer diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index 064b545d..1b23971a 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -217,7 +217,7 @@ def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) - usage = CompletionUsagePayload.from_chunk(item) chunk = Message.new_chunk(role=Role.ASSISTANT.value, typ_=MessageType.DEFAULT) if usage: - usage.set(chunk) + usage.set_payload(chunk) elif len(item.choices) > 0: choice = item.choices[0] delta = choice.delta diff --git a/ghostos/core/messages/payload.py b/ghostos/core/messages/payload.py index c7f16422..cd718476 100644 --- a/ghostos/core/messages/payload.py +++ b/ghostos/core/messages/payload.py @@ -19,17 +19,17 @@ class Payload(BaseModel, ABC): """ the unique key of the payload""" @classmethod - def read(cls, message: Union[Message, HasPayloads]) -> Optional[Self]: + def read_payload(cls, message: Union[Message, HasPayloads]) -> Optional[Self]: value = message.payloads.get(cls.key, None) if value is None: return None return cls(**value) - def set(self, message: Union[Message, HasPayloads]) -> None: + def set_payload(self, message: Union[Message, HasPayloads]) -> None: message.payloads[self.key] = self.model_dump() @classmethod - def exists(cls, message: Union[Message, HasPayloads]) -> bool: + def payload_exists(cls, message: Union[Message, HasPayloads]) -> bool: if not hasattr(message, "payloads"): return False if not isinstance(message.payloads, dict): diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index ba910a6c..c8d7b8e2 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -9,8 +9,8 @@ import time __all__ = [ - "Stream", "Receiver", "ArrayReceiver", "ArrayStream", "new_arr_connection", - "ArrayReceiverBuffer", + "Stream", "Receiver", "ArrayReceiver", "ArrayStream", "new_basic_connection", + "ReceiverBuffer", ] from ghostos.helpers import Timeleft @@ -297,7 +297,7 @@ def closed(self) -> bool: return self._closed -class ArrayReceiverBuffer: +class ReceiverBuffer: def __init__(self, head: Message, receiver: Iterator[Message]): self._head = head self._receiver = receiver @@ -344,7 +344,8 @@ def chunks(self) -> Iterable[Message]: else: if self._done is None: self._done = head.as_tail() - self._next = ArrayReceiverBuffer(item, self._receiver) + self._next = ReceiverBuffer(item, self._receiver) + self._receiver = None break try: item = next(self._receiver) @@ -368,7 +369,8 @@ def next(self) -> Optional[Self]: return self._next -def new_arr_connection( + +def new_basic_connection( *, timeout: float = -1, idle: float = 0.2, diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index ed00147d..9f7ae5ec 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -362,10 +362,8 @@ def get_task_briefs(self, task_ids: List[str]) -> Dict[str, TaskBrief]: pass @abstractmethod - def lock_task(self, task_id: str) -> TaskLocker: + def lock_task(self, task_id: str, overdue: float) -> TaskLocker: """ - :param task_id: - :return: None if not locked """ pass diff --git a/ghostos/entity.py b/ghostos/entity.py index c7606802..0b3f5029 100644 --- a/ghostos/entity.py +++ b/ghostos/entity.py @@ -128,6 +128,8 @@ def to_entity_meta(value: Union[EntityType, Any]) -> EntityMeta: def get_entity(meta: EntityMeta, expect: Type[T]) -> T: + if meta is None: + raise ValueError("EntityMeta cannot be None") entity = from_entity_meta(meta) if not isinstance(entity, expect): raise TypeError(f"Expected entity type {expect} but got {type(entity)}") diff --git a/ghostos/framework/documents/__init__.py b/ghostos/framework/documents/__init__.py index 847cf4d5..8709040e 100644 --- a/ghostos/framework/documents/__init__.py +++ b/ghostos/framework/documents/__init__.py @@ -1,2 +1,2 @@ from ghostos.contracts.documents import DocumentRegistry -from .storage_impl import ConfiguredDocumentRegistryProvider +from .storage_impl import ConfiguredDocumentRegistryProvider, StorageDocumentsConfig diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index b915c242..fa9e8ca6 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -1,11 +1,11 @@ -from typing import Optional, Iterable, List, TypeVar, Tuple, Union +from typing import Optional, Iterable, List, TypeVar, Tuple, Union, Callable from ghostos.container import Container from ghostos.abcd import Conversation, Scope, Ghost, Context from ghostos.abcd import run_session_event from ghostos.core.messages import ( Message, Role, - Stream, Receiver, new_arr_connection, + Stream, Receiver, new_basic_connection, ) from ghostos.core.runtime import ( Event, EventTypes, EventBus, @@ -14,7 +14,7 @@ ) from ghostos.contracts.pool import Pool from ghostos.contracts.logger import LoggerItf, wrap_logger -from ghostos.entity import to_entity_meta +from ghostos.entity import to_entity_meta, get_entity from pydantic import BaseModel, Field from .session_impl import SessionImpl @@ -48,6 +48,7 @@ def __init__( task: GoTaskStruct, task_locker: TaskLocker, is_background: bool, + shell_closed: Callable[[], bool], ): self._conf = conf self._container = container @@ -67,6 +68,7 @@ def __init__( self._threads = container.force_fetch(GoThreads) self._eventbus = container.force_fetch(EventBus) self._closed = False + self._shell_closed = shell_closed self._bootstrap() def _bootstrap(self): @@ -78,25 +80,48 @@ def container(self) -> Container: return self._container def task(self) -> GoTaskStruct: + self._validate_closed() return self._tasks.get_task(self._scope.task_id) def thread(self) -> GoThreadInfo: + self._validate_closed() task = self.task() thread_id = task.thread_id return self._threads.get_thread(thread_id, create=True) + def update_thread(self, thread: GoThreadInfo) -> None: + self._validate_closed() + task = self.task() + thread.id = task.thread_id + self._threads.save_thread(thread) + + def get_ghost(self) -> Ghost: + self._validate_closed() + task = self.task() + return get_entity(task.meta, Ghost) + + def get_context(self) -> Optional[Context]: + self._validate_closed() + task = self.task() + if task.context is None: + return None + return get_entity(task.context, Context) + def get_artifact(self) -> Tuple[Union[Ghost.ArtifactType, None], TaskState]: + self._validate_closed() task = self.task() session = self._create_session(task, self._locker, None) with session: return session.get_artifact(), TaskState(session.task.state) - def talk(self, query: str, user_name: str = "") -> Receiver: - self._logger.info("talk to user %s", user_name) + def talk(self, query: str, user_name: str = "") -> Tuple[Event, Receiver]: + self._validate_closed() + self._logger.debug("talk to user %s", user_name) message = Role.USER.new(content=query, name=user_name) return self.respond([message]) def update_context(self, context: Context) -> None: + self._validate_closed() self._ctx = context def respond( @@ -127,8 +152,9 @@ def respond_event( # complete task_id if not event.task_id: event.task_id = self._scope.task_id + self._logger.debug("start to respond event %s", event.event_id) - stream, retriever = new_arr_connection( + stream, retriever = new_basic_connection( timeout=timeout, idle=self._conf.message_receiver_idle, complete_only=self._is_background, @@ -139,6 +165,8 @@ def respond_event( def _validate_closed(self): if self._closed: raise RuntimeError(f"Conversation is closed") + if self._shell_closed(): + raise RuntimeError(f"Shell is closed") def _submit_session_event(self, event: Event, stream: Stream) -> None: self._logger.debug("submit session event") @@ -209,4 +237,4 @@ def _destroy(self): del self._logger def closed(self) -> bool: - return self._closed + return self._closed or self._shell_closed() diff --git a/ghostos/framework/ghostos/ghostos_impl.py b/ghostos/framework/ghostos/ghostos_impl.py index bfe59017..83c97f3e 100644 --- a/ghostos/framework/ghostos/ghostos_impl.py +++ b/ghostos/framework/ghostos/ghostos_impl.py @@ -65,12 +65,12 @@ def create_shell( process = self._processes.get_process(shell_id) if process is None: process = GoProcess.new(shell_id=shell_id, process_id=process_id) - self._logger.info(f"Created shell `{shell_id}` process `{process_id}`") + self._logger.debug(f"Created shell `{shell_id}` process `{process_id}`") elif process_id is not None and process.process_id != process_id: process = GoProcess.new(shell_id=shell_id, process_id=process_id) - self._logger.info(f"Created shell `{shell_id}` new process `{process_id}`") + self._logger.debug(f"Created shell `{shell_id}` new process `{process_id}`") else: - self._logger.info(f"get shell `{shell_id}` new process `{process.process_id}`") + self._logger.debug(f"get shell `{shell_id}` new process `{process.process_id}`") self._processes.save_process(process) # prepare container diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 81c6e6e4..ee8e7002 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -67,6 +67,7 @@ def __init__( parent_task_id=task.parent, ) logger = container.force_fetch(LoggerItf) + logger.debug("SessionImpl initialized at %s", self.scope) self.logger = wrap_logger( logger, extra=self.scope.model_dump(), diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index 01e271e7..a4e18a3d 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -29,7 +29,10 @@ class ShellConf(BaseModel): max_task_errors: int = Field( default=3, ) - background_idle_time: float = Field(0.5) + background_idle_time: float = Field(1) + task_lock_overdue: float = Field( + default=10.0 + ) G = TypeVar("G", bound=Ghost) @@ -88,7 +91,7 @@ def sync(self, ghost: Ghost, context: Optional[Ghost.ContextType] = None) -> Con task = self._tasks.get_task(task_id) if task is None: task = self.create_root_task(task_id, ghost, context) - self._logger.info("create root task task id %s for ghost", task_id) + self._logger.debug("create root task task id %s for ghost", task_id) task.meta = to_entity_meta(ghost) if context is not None: @@ -103,7 +106,7 @@ def sync_task( throw: bool, is_background: bool, ) -> Optional[Conversation]: - locker = self._tasks.lock_task(task.task_id) + locker = self._tasks.lock_task(task.task_id, self._conf.task_lock_overdue) if locker.acquire(): conf = ConversationConf( max_session_steps=self._conf.max_session_steps, @@ -116,6 +119,7 @@ def sync_task( task=task, task_locker=locker, is_background=is_background, + shell_closed=self.closed, ) elif throw: raise RuntimeError(f'Task {task.task_id} already locked') @@ -138,10 +142,11 @@ def send_message(receiver: Receiver): receiver.wait() timeleft = Timeleft(timeout) - task = self.create_root_task(ghost, context) + task_id = uuid() + task = self.create_root_task(task_id, ghost, context) conversation = self.sync_task(task, throw=True, is_background=False) with conversation: - r = conversation.respond(instructions) + e, r = conversation.respond(instructions) send_message(r) while timeleft.alive(): @@ -198,7 +203,12 @@ def run_background_event( def on_event(e: Event, r: Receiver) -> None: if background: - background.on_event(e, r) + messages = r.wait() + tails = [] + for message in messages: + if message.is_complete(): + tails.append(message) + background.on_event(e, tails) with conversation: event = conversation.pop_event() @@ -236,36 +246,38 @@ def is_stopped() -> bool: if self._closed: return True if background: - return background.stopped() + return not background.alive() return False def idle(): time.sleep(self._conf.background_idle_time) - def halt() -> bool: + def halt() -> int: if background: - halt_time = background.halt() - if halt_time > 0: - time.sleep(halt_time) - return True - return False + return background.halt() + return 0 while not is_stopped(): - if halt(): + if halt_time := halt(): + time.sleep(halt_time) continue try: handled_event = self.run_background_event(background) if handled_event: continue except Exception as err: - self.close() - return + self._logger.exception(err) + break idle() + self.close() def _validate_closed(self): if self._closed: raise RuntimeError(f'Shell is closed') + def closed(self) -> bool: + return self._closed + def close(self): if self._closed: return diff --git a/ghostos/framework/llms/__init__.py b/ghostos/framework/llms/__init__.py index 2602ead4..d10ea522 100644 --- a/ghostos/framework/llms/__init__.py +++ b/ghostos/framework/llms/__init__.py @@ -1,5 +1,5 @@ from ghostos.core.llms import LLMs, Prompt, PromptStorage from ghostos.framework.llms.llms import LLMsImpl from ghostos.framework.llms.openai_driver import OpenAIDriver, OpenAIAdapter, LitellmAdapter -from ghostos.framework.llms.providers import ConfigBasedLLMsProvider, PromptStorageInWorkspaceProvider +from ghostos.framework.llms.providers import ConfigBasedLLMsProvider, PromptStorageInWorkspaceProvider, LLMsYamlConfig from ghostos.framework.llms.prompt_storage_impl import PromptStorageImpl diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index bd6a2284..b631f61d 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -120,7 +120,7 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion raise AttributeError("empty chat!!") try: prompt.run_start = timestamp() - self._logger.info(f"start chat completion for prompt {prompt.id}") + self._logger.debug(f"start chat completion for prompt {prompt.id}") return self._client.chat.completions.create( messages=messages, model=self._model.model, @@ -136,7 +136,7 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion **self._model.kwargs, ) finally: - self._logger.info(f"end chat completion for prompt {prompt.id}") + self._logger.debug(f"end chat completion for prompt {prompt.id}") prompt.run_end = timestamp() def chat_completion(self, prompt: Prompt) -> Message: @@ -145,10 +145,10 @@ def chat_completion(self, prompt: Prompt) -> Message: prompt.output = [message] pack = self._parser.from_chat_completion(message.choices[0].message) # add completion usage - self._model.set(pack) + self._model.set_payload(pack) if message.usage: usage = CompletionUsagePayload.from_usage(message.usage) - usage.set(pack) + usage.set_payload(pack) if not pack.is_complete(): pack.chunk = False @@ -168,8 +168,8 @@ def chat_completion_chunks(self, prompt: Prompt) -> Iterable[Message]: for chunk in messages: yield chunk if chunk.is_complete(): - self._model.set(chunk) - prompt_payload.set(chunk) + self._model.set_payload(chunk) + prompt_payload.set_payload(chunk) output.append(chunk) prompt.output = output except Exception as e: @@ -199,6 +199,7 @@ def driver_name(self) -> str: return OPENAI_DRIVER_NAME def new(self, service: ServiceConf, model: ModelConf) -> LLMApi: + self._logger.debug(f"new llm api %s at service %s", model.model, service.name) return OpenAIAdapter(service, model, self._parser, self._storage, self._logger) diff --git a/ghostos/framework/llms/providers.py b/ghostos/framework/llms/providers.py index fae49ee7..fa14f281 100644 --- a/ghostos/framework/llms/providers.py +++ b/ghostos/framework/llms/providers.py @@ -9,7 +9,15 @@ from ghostos.contracts.workspace import Workspace from ghostos.contracts.logger import LoggerItf -__all__ = ['ConfigBasedLLMsProvider', 'PromptStorageInWorkspaceProvider'] +__all__ = ['ConfigBasedLLMsProvider', 'PromptStorageInWorkspaceProvider', 'LLMsYamlConfig'] + + +class LLMsYamlConfig(YamlConfig, LLMsConfig): + """ + 配置项存储位置. + 详细配置项见 LLMsConfig + """ + relative_path = "llms_conf.yml" class ConfigBasedLLMsProvider(Provider[LLMs]): @@ -17,9 +25,6 @@ class ConfigBasedLLMsProvider(Provider[LLMs]): 基于 Config 来读取 """ - def __init__(self, llm_conf_path: str): - self.llm_conf_path = llm_conf_path - def singleton(self) -> bool: return True @@ -27,12 +32,6 @@ def contract(self) -> Type[LLMs]: return LLMs def factory(self, con: Container) -> Optional[LLMs]: - class LLMsYamlConfig(YamlConfig, LLMsConfig): - """ - 配置项存储位置. - 详细配置项见 LLMsConfig - """ - relative_path = self.llm_conf_path configs = con.force_fetch(Configs) storage = con.force_fetch(PromptStorage) diff --git a/ghostos/framework/logger/named.py b/ghostos/framework/logger/named.py index 544a1eb0..b88b471b 100644 --- a/ghostos/framework/logger/named.py +++ b/ghostos/framework/logger/named.py @@ -1,8 +1,11 @@ from typing import Optional, Type from ghostos.container import Provider, Container -from ghostos.contracts.logger import LoggerItf, get_debug_logger +from ghostos.contracts.logger import LoggerItf, LoggerAdapter +from ghostos.contracts.workspace import Workspace +from os.path import join import logging +from logging.handlers import RotatingFileHandler __all__ = ['NamedLoggerProvider'] @@ -26,8 +29,18 @@ def contract(self) -> Type[LoggerItf]: def factory(self, con: Container) -> Optional[LoggerItf]: logging.captureWarnings(True) - if self.logger_name in logging.Logger.manager.loggerDict: - logger = logging.getLogger(self.logger_name) - origin = logging.LoggerAdapter(logger) - return origin - return get_debug_logger(name=self.logger_name) + ws = con.force_fetch(Workspace) + logger = logging.getLogger(self.logger_name) + if not logger.hasHandlers(): + path = ws.runtime().abspath() + logfile = join(path, "logs/ghostos.log") + handler = RotatingFileHandler(logfile, mode="a") + handler.setLevel(logging.DEBUG) + formatter = logging.Formatter( + fmt="%(asctime)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)", + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + wrapped = LoggerAdapter(logger, extra={}) + return wrapped diff --git a/ghostos/framework/messages/__init__.py b/ghostos/framework/messages/__init__.py index 68be5701..573407dd 100644 --- a/ghostos/framework/messages/__init__.py +++ b/ghostos/framework/messages/__init__.py @@ -1,3 +1,4 @@ +# todo: remove buffer some day from ghostos.core.messages import Buffer from ghostos.framework.messages.buffers import DefaultBuffer @@ -5,3 +6,4 @@ from ghostos.core.runtime import TaskPayload from ghostos.core.messages.openai import CompletionUsagePayload +from ghostos.core.llms import PromptPayload diff --git a/ghostos/framework/messages/buffers.py b/ghostos/framework/messages/buffers.py index ee27a232..88f727fa 100644 --- a/ghostos/framework/messages/buffers.py +++ b/ghostos/framework/messages/buffers.py @@ -271,8 +271,8 @@ def _wrap_first_pack(self, pack: Message) -> Message: # 添加默认的 payloads. if self._payloads: for payload in self._payloads: - if not payload.exists(pack): - payload.set(pack) + if not payload.payload_exists(pack): + payload.set_payload(pack) return pack diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index 47cd65f2..d5c34886 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -88,7 +88,7 @@ def buffer(self, messages: Iterable[Message]) -> Iterable[Message]: # add payload to complete one if self._payloads: for payload in self._payloads: - payload.set(item) + payload.set_payload(item) # buffer outputs self._sent_message_ids.append(item.msg_id) diff --git a/ghostos/framework/tasks/storage_tasks.py b/ghostos/framework/tasks/storage_tasks.py index 69227f12..a0c3e1bf 100644 --- a/ghostos/framework/tasks/storage_tasks.py +++ b/ghostos/framework/tasks/storage_tasks.py @@ -17,11 +17,12 @@ class LockData(TypedDict): lock_id: str created: float - def __init__(self, storage: Storage, task_id: str): + def __init__(self, storage: Storage, task_id: str, overdue: float): self.task_id = task_id self.storage = storage self.lock_id = uuid() self._acquired = False + self._overdue = overdue def acquire(self) -> bool: filename = self.locker_file_name() @@ -30,7 +31,7 @@ def acquire(self) -> bool: data = yaml.safe_load(content) lock = self.LockData(**data) now = time.time() - if lock['lock_id'] == self.lock_id or now - float(lock["created"]) > 100: + if lock['lock_id'] == self.lock_id or now - float(lock["created"]) > self._overdue: self.create_lock() return True return False @@ -112,8 +113,8 @@ def get_task_briefs(self, task_ids: List[str], states: Optional[List[TaskState]] for task in self.get_tasks(task_ids, states): yield TaskBrief.from_task(task) - def lock_task(self, task_id: str) -> TaskLocker: - return SimpleStorageLocker(self._storage, task_id) + def lock_task(self, task_id: str, overdue: float) -> TaskLocker: + return SimpleStorageLocker(self._storage, task_id, overdue) class StorageTasksImplProvider(Provider[GoTasks]): diff --git a/ghostos/identifier.py b/ghostos/identifier.py index ee9ff54a..9432f5d9 100644 --- a/ghostos/identifier.py +++ b/ghostos/identifier.py @@ -159,8 +159,7 @@ def identify_class(cls: type) -> Identifier: return Identifier( id=id_, name=name, - description=desc, - kind="class", + description=desc or "", ) diff --git a/ghostos/prototypes/console/app.py b/ghostos/prototypes/console/app.py index cf7ff7ab..7abd87d9 100644 --- a/ghostos/prototypes/console/app.py +++ b/ghostos/prototypes/console/app.py @@ -1,6 +1,6 @@ from __future__ import annotations import asyncio -from typing import Optional +from typing import Optional, List from ghostos.abcd import GhostOS, Ghost, Background from ghostos.contracts.logger import get_console_logger @@ -149,12 +149,12 @@ def on_error(self, error: Exception) -> bool: self.close() return False - def on_event(self, event: Event, retriever: Receiver) -> None: - self._logger.info(f"Received event {event.event_id} for task {event.task_id}") - self.output_receiver(retriever) + def on_event(self, event: Event, messages: List[Message]) -> None: + self._logger.debug(f"Received event {event.event_id} for task {event.task_id}") + # self.output_receiver(retriever) - def stopped(self) -> bool: - return self._stopped + def alive(self) -> bool: + return not self._stopped def halt(self) -> int: return 0 @@ -234,7 +234,7 @@ def _print_message(self, message: Message): # some message is not visible to user if not content: return - payload = TaskPayload.read(message) + payload = TaskPayload.read_payload(message) title = "receive message" # markdown content prefix = "" diff --git a/ghostos/prototypes/realtime/openai/broadcast.py b/ghostos/prototypes/realtime/openai/broadcast.py index 73643061..201d5d4d 100644 --- a/ghostos/prototypes/realtime/openai/broadcast.py +++ b/ghostos/prototypes/realtime/openai/broadcast.py @@ -86,7 +86,7 @@ def publish(self, topic: str, data: Union[dict, None]): def close(self): if self._closed: return - self._logger.info("%s is closing", self.__class__.__name__) + self._logger.debug("%s is closing", self.__class__.__name__) self._closed = True for chan in self.subscriber_channels.values(): chan.put(None) diff --git a/ghostos/prototypes/realtime/openai/ws.py b/ghostos/prototypes/realtime/openai/ws.py index c7f3ad9f..0a860065 100644 --- a/ghostos/prototypes/realtime/openai/ws.py +++ b/ghostos/prototypes/realtime/openai/ws.py @@ -117,7 +117,7 @@ def close(self): if self._ws is not None: self._ws.close() self._ws = None - self._logger.info("[OpenAIWSConnection] stop the connection") + self._logger.debug("[OpenAIWSConnection] stop the connection") def closed(self) -> bool: return self._closed diff --git a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py index 89a0d818..0d432e99 100644 --- a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py +++ b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py @@ -1,11 +1,15 @@ +from typing import List + +from ghostos.core.messages import Message +from ghostos.core.runtime import Event +from ghostos.contracts.logger import get_console_logger from ghostos.helpers import create_and_bind_module from ghostos.scripts.cli.run_streamlit_ghost import RunGhostChatApp -from ghostos.bootstrap import make_app_container, get_ghostos +from ghostos.bootstrap import get_ghostos, get_container from ghostos.prototypes.streamlitapp.main import main_run from ghostos.prototypes.streamlitapp.pages.router import default_router, GhostChatRoute from ghostos.prototypes.streamlitapp.utils.session import Singleton -from ghostos.contracts.logger import get_console_logger -from ghostos.abcd import GhostOS, Shell +from ghostos.abcd import Shell, Background import streamlit as st import sys import json @@ -13,9 +17,29 @@ if len(sys.argv) < 2: raise SystemExit(f"invalid RunAIFuncApp arguments") +logger = get_console_logger(debug=True) + + +class StreamlitBackgroundApp(Background): + + def on_error(self, error: Exception) -> bool: + logger.exception(error) + return False + + def on_event(self, event: Event, messages: List[Message]) -> None: + pass + + def alive(self) -> bool: + from streamlit.runtime import Runtime, RuntimeState + state = Runtime.instance().state + # if streamlit is closed, close ghostos shell + return state not in {RuntimeState.STOPPING, RuntimeState.STOPPED} + + def halt(self) -> int: + return 0 + def bootstrap(): - logger = get_console_logger() run_aifunc_app_arg = sys.argv[1] data = json.loads(run_aifunc_app_arg) @@ -28,21 +52,25 @@ def bootstrap(): # bootstrap container logger.debug(f"generate ghostos app container at workspace {app_arg.workspace_dir}") - container = make_app_container(app_arg.workspace_dir) # bound route. page_route = GhostChatRoute(ghost_meta=app_arg.ghost_meta) + page_route = page_route.get_or_bind(st.session_state) # initialize router and set aifunc is default router = default_router().with_current(page_route) - ghostos = get_ghostos(container) - shell = ghostos.create_shell("ghostos_streamlit_app", "ghostos_streamlit_app") + ghostos = get_ghostos() + shell = Singleton.get(Shell, st.session_state, force=False) + container = get_container() + if shell is None: + logger.debug("start shell background run") + shell = ghostos.create_shell("ghostos_streamlit_app", "ghostos_streamlit_app") + shell.background_run(4, StreamlitBackgroundApp()) + Singleton(shell, Shell).bind(st.session_state) return [ Singleton(container), Singleton(router), - Singleton(ghostos, GhostOS), - Singleton(shell, Shell), ] diff --git a/ghostos/prototypes/streamlitapp/main.py b/ghostos/prototypes/streamlitapp/main.py index 22ef023f..40bc03bd 100644 --- a/ghostos/prototypes/streamlitapp/main.py +++ b/ghostos/prototypes/streamlitapp/main.py @@ -69,10 +69,10 @@ def main_run(bootstrap: BOOTSTRAP) -> None: pgs = st.navigation(router.pages(), position="hidden") # define sidebar with st.sidebar: - router.render_homepage() + # router.render_homepage() # render page links - # with st.expander(label=_("Navigator"), expanded=False, icon=":material/menu:"): - # router.render_navigator(use_container_width=True) + with st.expander(label=_("Navigator"), expanded=False, icon=":material/menu:"): + router.render_navigator(use_container_width=True) # with helper mode toggle # open_navigator = st.button( # label=_("GhostOS Navigator"), diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py index 4f665055..2e07b7a0 100644 --- a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py @@ -17,7 +17,7 @@ render_message_item, render_messages, ) -from ghostos.core.messages import new_arr_connection +from ghostos.core.messages import new_basic_connection from ghostos.core.aifunc import ( AIFunc, AIFuncExecutor, @@ -101,7 +101,7 @@ def render_aifunc_execute_stream(route: AIFuncDetailRoute, fn: Type[AIFunc]): return executor = get_container().force_fetch(AIFuncExecutor) - stream, receiver = new_arr_connection(timeout=route.timeout, idle=route.exec_idle, complete_only=True) + stream, receiver = new_basic_connection(timeout=route.timeout, idle=route.exec_idle, complete_only=True) frame, caller = executor.new_exec_frame(args, stream) # save status route.frame = frame diff --git a/ghostos/prototypes/streamlitapp/pages/configs.py b/ghostos/prototypes/streamlitapp/pages/configs.py new file mode 100644 index 00000000..8ce5c3ef --- /dev/null +++ b/ghostos/prototypes/streamlitapp/pages/configs.py @@ -0,0 +1,41 @@ +import streamlit as st +import streamlit_react_jsonschema as srj +from typing import Type +from ghostos.prototypes.streamlitapp.pages.router import ConfigsRoute +from ghostos.prototypes.streamlitapp.utils.session import Singleton +from ghostos.contracts.configs import Configs, Config +from ghostos.container import Container +from ghostos.helpers import import_from_path +from ghostos.identifier import identify_class + + +def main(): + route = ConfigsRoute.get(st.session_state) + classes = [] + classes_identities = [] + for cls_name in route.config_classes: + cls = import_from_path(cls_name) + classes.append(cls) + classes_identities.append(identify_class(cls)) + + idx = 0 + for identity in classes_identities: + cls = classes[idx] + with st.container(border=True): + st.subheader(cls.__name__) + st.markdown(identity.description) + if st.button("open", key="open_cls_id:" + identity.id): + open_config_update_dialog(cls) + + idx += 1 + + +@st.dialog("Update Config", width="large") +def open_config_update_dialog(cls: Type[Config]): + container = Singleton.get(Container, st.session_state) + configs = container.force_fetch(Configs) + data = configs.get(cls) + updated, submitted = srj.pydantic_instance_form(data) + if submitted and isinstance(updated, Config): + configs.save(updated) + st.write("Config saved") diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py b/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py index e1e1a490..88658d35 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py @@ -1,96 +1,89 @@ import streamlit as st import time import streamlit_react_jsonschema as srj -from ghostos.prototypes.streamlitapp.pages.router import GhostChatRoute +from typing import Iterable, List +from ghostos.prototypes.streamlitapp.pages.router import ( + GhostChatRoute, +) from ghostos.prototypes.streamlitapp.utils.session import Singleton -from ghostos.prototypes.streamlitapp.resources import get_app_conf from ghostos.prototypes.streamlitapp.widgets.messages import ( - render_messages, - render_message_item, + render_message_in_content, render_message_payloads +) +from ghostos.prototypes.streamlitapp.widgets.renderer import ( + render_object, render_event, render_turn, + render_empty, ) -from ghostos.prototypes.streamlitapp.widgets.renderer import render_object from ghostos.prototypes.streamlitapp.resources import get_app_conf -from ghostos.core.runtime import GoThreadInfo, Turn, Event, GoThreads -from ghostos.core.messages import Receiver, Role +from ghostos.core.runtime import GoThreadInfo, Event, GoTaskStruct +from ghostos.core.messages import Receiver, Role, ReceiverBuffer, MessageType, Message +from streamlit.logger import get_logger from ghostos.abcd import Shell, Conversation, Context from ghostos.identifier import get_identifier from ghostos.helpers import gettext as _ from ghostos.helpers import generate_import_path, yaml_pretty_dump -from pydantic import BaseModel, Field +from pydantic import BaseModel import inspect - -class ButtonInfo(BaseModel): - label: str = Field(description="The label of the subpage.") - help: str = Field(default="todo", description="The help text of the subpage.") - icon: str = Field(default=":material/thumb_up:", description="The icon of the subpage.") - - -chat = ButtonInfo( - label=_("Chat"), -) -ghost_settings = ButtonInfo( - label=_("Ghost Settings"), -) -context_settings = ButtonInfo( - label=_("Context Settings"), -) -task_info = ButtonInfo( - label=_("Task Info"), -) -thread_info = ButtonInfo( - label=_("Thread Info"), -) - -subpages = [chat, context_settings, ghost_settings, task_info, thread_info] - -chat_input_type = ButtonInfo( - label=_("Chat Input"), -) - -input_types = [chat_input_type] +logger = get_logger("ghostos") def main(): - route = GhostChatRoute.get_or_bind(st.session_state) # create shell + route = GhostChatRoute.get(st.session_state) + if route is None: + st.error("No route found for session state") + return + + # get ghost and context ghost = route.get_ghost() context = route.get_context() + ghost = route.get_route_bound(ghost) + context = route.get_route_bound(context) + conversation = Singleton.get(Conversation, st.session_state, force=False) if not conversation: shell = Singleton.get(Shell, st.session_state) # create conversation conversation = shell.sync(ghost, context) Singleton(conversation, Conversation).bind(st.session_state) - # run the pages run_chat_page(route, conversation) - - # rebind route. - route.bind(st.session_state) + route.get_or_bind(st.session_state) def run_chat_page(route: GhostChatRoute, conversation: Conversation): + pic = None with st.sidebar: - for subpage in subpages: - button = st.button( - label=subpage.label, - help=subpage.help, - icon=subpage.icon, - use_container_width=True, + # other pages + if st.button(_("Ghost Settings"), use_container_width=True): + render_ghost_settings(route) + if st.button(_("Context"), use_container_width=True): + render_context_settings(conversation) + if st.button("Task Info", use_container_width=True): + render_task_info_settings(conversation.task()) + if st.button("Clear Messages", use_container_width=True): + thread = conversation.thread() + thread = thread.reset_history([]) + conversation.update_thread(thread) + st.rerun() + + st.subheader("Inputs") + # input type + with st.container(border=True): + auto_run = st.toggle( + "auto run event", + help="automatic run background event", + value=True, ) - if button: - route.page_type = subpage.label - if route.page_type == chat.label: - st.divider() - if st.button("Image Input", use_container_width=True): - route.input_type = "image" - if st.button("Textarea Input", use_container_width=True): - route.input_type = "text" - if st.button("File Input", use_container_width=True): - route.input_type = "file" - if st.button("Video Shortcut Input", use_container_width=True): - route.input_type = "video" + show_video = st.toggle("show video") + show_image_file = st.toggle("upload image") + + if show_video: + pic = st.camera_input("Task a picture") + if show_image_file: + image = st.file_uploader("Upload image", type=["png", "jpg", "jpeg"]) + for i in range(5): + st.empty() # header st.title("Ghost") @@ -108,50 +101,29 @@ def run_chat_page(route: GhostChatRoute, conversation: Conversation): {yaml_pretty_dump(data)} ``` """) + inputs = [] + if chat_input := st.chat_input("message"): + inputs = route.get_route_bound([], "inputs") + inputs.append(Role.USER.new(chat_input)) + route.bind_to_route([], "inputs") + route.input_type = "" - # body - if route.page_type == context_settings.label: - render_context_settings(route, conversation) - elif route.page_type == ghost_settings.label: - render_ghost_settings(route) - elif route.page_type == task_info.label: - render_task_info_settings(route, conversation) - elif route.page_type == thread_info.label: - render_thread_info_settings(route, conversation) - else: - render_chat(route, conversation) + chatting(route, conversation, inputs, auto_run) -def render_chat(route: GhostChatRoute, conversation: Conversation): - st.title(route.page_type) - if route.input_type == "any": - pass - else: - if chat_input := st.chat_input("Your message"): - message = Role.USER.new(chat_input) - route.inputs.append(message) - route.bind(st.session_state) - chatting() +def chatting(route: GhostChatRoute, conversation: Conversation, inputs: List[Message], rotate: bool): + thread = conversation.thread() + render_thread_messages(thread, max_turn=20) + debug = get_app_conf().BoolOpts.DEBUG_MODE.get() + render_empty() + if inputs: + event, receiver = conversation.respond(inputs) + render_event(event, debug) + render_receiver(receiver, debug) -@st.fragment -def chatting(): - conversation = Singleton.get(Conversation, st.session_state) - thread = conversation.thread() - render_thread_messages(thread) - - while True: - route = GhostChatRoute.get(st.session_state) - debug = get_app_conf().BoolOpts.DEBUG_MODE.get() - # has input - if route is not None and route.inputs: - inputs = route.inputs - route.inputs = [] - route.bind(st.session_state) - event, receiver = conversation.respond(inputs) - render_event(event, debug) - render_receiver(receiver, debug) - elif event := conversation.pop_event(): + while not route.input_type and rotate and not conversation.closed(): + if event := conversation.pop_event(): render_event(event, debug) receiver = conversation.respond_event(event) render_receiver(receiver, debug) @@ -159,18 +131,48 @@ def chatting(): time.sleep(1) +@st.dialog("Textarea") +def video_input_dialog(route: GhostChatRoute): + text = st.text_area("You message", value="") + logger.debug("++++++++++++++++ set chat_text_input: %s", text) + if text: + st.session_state["chat_text_input"] = text + logger.debug("end of text area input") + + def render_receiver(receiver: Receiver, debug: bool): try: with receiver: - messages = receiver.wait() - render_messages(messages, debug) + with st.status("waiting..."): + buffer = ReceiverBuffer.new(receiver.recv()) + if buffer is None: + return + with st.chat_message("assistant"): + while buffer is not None: + if MessageType.is_text(buffer.head()): + contents = chunks_to_st_stream(buffer.chunks()) + st.write_stream(contents) + render_message_payloads(buffer.tail(), debug) + else: + render_message_in_content(buffer.tail(), debug) + # render next item + buffer = buffer.next() except Exception as e: st.exception(e) +def chunks_to_st_stream(chunks: Iterable[Message]) -> Iterable[str]: + for chunk in chunks: + if chunk.content: + yield chunk.content + + +@st.dialog(_("Ghost Settings"), width="large") def render_ghost_settings(route: GhostChatRoute): + if route is None: + st.error("page is not configured") + return ghost = route.get_ghost() - st.subheader(_(route.page_type)) # render ghost info if isinstance(ghost, BaseModel): data, mod = srj.pydantic_instance_form(ghost) @@ -181,12 +183,13 @@ def render_ghost_settings(route: GhostChatRoute): source = inspect.getsource(ghost.__class__) with st.expander("source code", expanded=False): st.code(source) + render_empty() -def render_context_settings(route: GhostChatRoute, conversation: Conversation): - st.subheader(route.page_type) - ctx = route.get_context() - ghost = route.get_ghost() +@st.dialog("Ghost Context", width="large") +def render_context_settings(conversation: Conversation): + ctx = conversation.get_context() + ghost = conversation.get_ghost() if ctx is None and ghost.ContextType is None: st.info("No specific Context for this Ghost") return @@ -215,77 +218,34 @@ def render_context_settings(route: GhostChatRoute, conversation: Conversation): st.subheader(_("Artifact")) artifact = conversation.get_artifact() render_object(artifact) + render_empty() -def render_task_info_settings(route: GhostChatRoute, conversation: Conversation): +@st.dialog("Task Info", width="large") +def render_task_info_settings(task: GoTaskStruct): from ghostos.core.runtime.tasks import TaskBrief - st.subheader(route.page_type) - task = conversation.task() brief = TaskBrief.from_task(task) srj.pydantic_instance_form(brief, readonly=True) with st.expander(_("Detail"), expanded=False): st.write(task.model_dump(exclude_defaults=True)) + render_empty() -def render_thread_info_settings(route: GhostChatRoute, conversation: Conversation): - st.subheader(route.page_type) - - thread = conversation.thread() - - with st.expander(_("Detail"), expanded=False): - st.write(thread_info.model_dump(exclude_defaults=True)) - if st.button("reset history"): - # reset history - thread = conversation.thread() - thread.history = [] - thread.current = None - threads = conversation.container().force_fetch(GoThreads) - threads.save_thread(thread) - - st.subheader(_("Thread Messages")) - render_thread_messages(thread) - - -def render_thread_messages(thread: GoThreadInfo): - turns = thread.turns() +def render_thread_messages(thread: GoThreadInfo, max_turn: int = 20): + turns = list(thread.turns()) + turns = turns[-max_turn:] debug = get_app_conf().BoolOpts.DEBUG_MODE.get() + count = 0 for turn in turns: - render_turn(turn, debug) - - -def render_turn(turn: Turn, debug: bool): - if turn.is_from_client(): - messages = turn.messages() - render_messages(messages, debug) - # from other task - else: - event = turn.event - sub_title = _("background run") - if event is not None: - sub_title = _("background event: ") + event.type - with st.expander(sub_title, expanded=False): - messages = turn.messages() - render_messages(messages, debug) - render_event_object(event, debug) - - -def render_event(event: Event, debug: bool): - if event is None: - return - if event.from_task_id: - sub_title = _("background event: ") + event.type - with st.expander(sub_title, expanded=False): - messages = event.iter_message(show_instruction=True) - render_messages(messages, debug) - else: - messages = event.iter_message(show_instruction=True) - render_messages(messages, debug) + count += render_turn(turn, debug) + if count == 0: + st.info("No thread messages yet") def render_event_object(event: Event, debug: bool): if event is None: return from_task_name = event.from_task_name - if from_task_name is not None: - st.button(f"from task {from_task_name}") + if debug and from_task_name is not None: + st.button(f"from task {from_task_name}", key=f"from task {event.event_id}") diff --git a/ghostos/prototypes/streamlitapp/pages/router.py b/ghostos/prototypes/streamlitapp/pages/router.py index bb967994..69798c2c 100644 --- a/ghostos/prototypes/streamlitapp/pages/router.py +++ b/ghostos/prototypes/streamlitapp/pages/router.py @@ -3,36 +3,38 @@ from ghostos.core.messages import Message from ghostos.core.aifunc import ExecFrame from ghostos.abcd import Ghost, Context -from ghostos.entity import EntityMeta, from_entity_meta, get_entity +from ghostos.entity import EntityMeta, get_entity +from ghostos.helpers import generate_import_path from enum import Enum from pydantic import Field +from ghostos.framework.llms import LLMsYamlConfig +from ghostos.framework.documents import StorageDocumentsConfig class PagePath(str, Enum): HOMEPAGE = "ghostos.prototypes.streamlitapp.pages.homepage" AIFUNCS = "ghostos.prototypes.streamlitapp.pages.aifuncs" GHOSTOS = "ghostos.prototypes.streamlitapp.pages.ghosts" + CONFIGS = "ghostos.prototypes.streamlitapp.pages.configs" def suffix(self, attr_name: str): return self.value + attr_name -# --- ghosts --- # +# --- ghost --- # class GhostChatRoute(Route): link = Link( - name="ghost_chat", + name="Ghost Chat", import_path=PagePath.GHOSTOS.suffix(".chat:main"), streamlit_icon=":material/smart_toy:", button_help="todo", antd_icon="robot", ) + task_id: str = Field(default="", description="Ghost Task ID") ghost_meta: Optional[EntityMeta] = Field(default=None, description="ghost meta") context_meta: Optional[EntityMeta] = Field(default=None, description="context meta") - input_type: str = Field(default="Chat", description="input type") - page_type: str = Field(default="Chat", description="page type") - - inputs: List[Message] = Field(default_factory=list, description="inputs") + input_type: str = Field(default="", description="input type") __ghost__ = None @@ -51,6 +53,25 @@ def get_context(self) -> Optional[Context]: return self.__context__ +# --- configs --- # + +class ConfigsRoute(Route): + link = Link( + name="Configs", + import_path=PagePath.CONFIGS.suffix(":main"), + streamlit_icon=":material/settings:", + button_help="todo", + antd_icon="settings", + ) + config_classes: List[str] = Field( + default_factory=lambda: [ + generate_import_path(LLMsYamlConfig), + generate_import_path(StorageDocumentsConfig), + ], + description="config classes" + ) + + # --- home --- # class Home(Route): @@ -144,12 +165,13 @@ def default_router() -> Router: GhostOSHost(), AIFuncListRoute(), AIFuncDetailRoute(), + # ghosts GhostChatRoute(), + ConfigsRoute(), ], home=Home.label(), navigator_page_names=[ - GhostOSHost.label(), - AIFuncListRoute.label(), + ConfigsRoute.label(), ], default_menu={ Home.label(): None, diff --git a/ghostos/prototypes/streamlitapp/utils/route.py b/ghostos/prototypes/streamlitapp/utils/route.py index d3fe4bbb..2c14df22 100644 --- a/ghostos/prototypes/streamlitapp/utils/route.py +++ b/ghostos/prototypes/streamlitapp/utils/route.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import ClassVar, Callable, Optional, MutableMapping, Literal, List, Dict, Set, Union +from typing import ClassVar, Callable, Optional, MutableMapping, TypeVar, List, Dict, Set, Union from abc import ABC from typing_extensions import Self from ghostos.prototypes.streamlitapp.utils.session import SessionStateValue @@ -12,6 +12,8 @@ __all__ = ["Router", 'Route', 'Link'] +T = TypeVar("T") + class Link: """ @@ -114,6 +116,7 @@ def render_page_link( help_ = self.link.button_help if help_ is not None: help_ = _(help_) + self.bind(st.session_state) st.page_link( self.page(), label=label, @@ -149,10 +152,6 @@ def get(cls, session_state: MutableMapping) -> Optional[Self]: return cls(**data) return None - @classmethod - def default(cls) -> Self: - return cls() - def bind(self, session_state: MutableMapping) -> None: from ghostos.container import get_caller_info key = self.session_state_key() @@ -165,6 +164,23 @@ def label_of_current_page(cls) -> str: return st.session_state[current] return "" + @classmethod + def get_route_bound(cls, value: T, key: str = "") -> T: + if not key: + key = generate_import_path(type(value)) + session_key = cls.session_state_key() + ":" + key + if session_key in st.session_state: + return st.session_state[session_key] + st.session_state[session_key] = value + return value + + @classmethod + def bind_to_route(cls, value, key: str = ""): + if not key: + key = generate_import_path(type(value)) + session_key = cls.session_state_key() + ":" + key + st.session_state[session_key] = value + class Router: diff --git a/ghostos/prototypes/streamlitapp/utils/session.py b/ghostos/prototypes/streamlitapp/utils/session.py index 58a373f4..7703f98b 100644 --- a/ghostos/prototypes/streamlitapp/utils/session.py +++ b/ghostos/prototypes/streamlitapp/utils/session.py @@ -1,8 +1,9 @@ from abc import ABC, abstractmethod -from typing import MutableMapping, Optional, ClassVar, Any, TypeVar, Type, List +from typing import MutableMapping, Optional, ClassVar, Any, TypeVar, Type, List, Callable from typing_extensions import Self from pydantic import BaseModel from ghostos.helpers import generate_import_path +import streamlit as st __all__ = [ 'SessionStateValue', 'ModelSingleton', @@ -28,25 +29,16 @@ def get(cls, session_state: MutableMapping) -> Optional[Self]: """ pass - @classmethod - def get_or_bind(cls, session_state: MutableMapping) -> Self: - value = cls.get(session_state) + def get_or_bind(self, session_state: MutableMapping) -> Self: + value = self.get(session_state) + cls = self.__class__ if value is None: - default_value = cls.default() - default_value.bind(session_state) - return default_value + self.bind(session_state) + return self if not isinstance(value, cls): raise ValueError(f"type {cls} can not find self in streamlit.session_state, {value} found") return value - @classmethod - @abstractmethod - def default(cls) -> Self: - """ - default self value - """ - pass - @abstractmethod def bind(self, session_state: MutableMapping) -> None: """ @@ -77,11 +69,6 @@ def get(cls, session_state: MutableMapping) -> Optional[Self]: def session_key(cls) -> str: return generate_import_path(cls) - @classmethod - def default(cls) -> Self: - # SingletonModel shall have default value for each field. - return cls() - def bind(self, session_state: MutableMapping) -> None: key = self.session_key() session_state[key] = self diff --git a/ghostos/prototypes/streamlitapp/widgets/dialogs.py b/ghostos/prototypes/streamlitapp/widgets/dialogs.py index 21ad231f..6f96b8fc 100644 --- a/ghostos/prototypes/streamlitapp/widgets/dialogs.py +++ b/ghostos/prototypes/streamlitapp/widgets/dialogs.py @@ -1,8 +1,90 @@ import streamlit as st -from ghostos.helpers import gettext as _ +from ghostos.helpers import gettext as _, yaml_pretty_dump +from ghostos.framework.messages import CompletionUsagePayload +from ghostos.prototypes.streamlitapp.widgets.renderer import render_empty @st.dialog(title=_("Code"), width="large") def open_code_dialog(title: str, code: str): st.subheader(title) st.code(code, line_numbers=True, wrap_lines=True) + render_empty() + + +@st.dialog(title=_("Task Info"), width="large") +def open_task_info_dialog(task_id: str): + from ghostos.prototypes.streamlitapp.widgets.renderer import render_task_by_id + render_task_by_id(task_id) + render_empty() + + +@st.dialog(title=_("Token Usage"), width="large") +def open_completion_usage_dialog(completion: CompletionUsagePayload): + import streamlit_react_jsonschema as srj + srj.pydantic_instance_form(completion, readonly=True) + render_empty() + + +@st.dialog(title=_("Prompt Info"), width="large") +def open_prompt_info_dialog(prompt_id: str): + import streamlit_react_jsonschema as srj + from ghostos.prototypes.streamlitapp.utils.session import Singleton + from ghostos.prototypes.streamlitapp.widgets.messages import render_messages + from ghostos.container import Container + from ghostos.core.llms import PromptStorage + + prefix = "prompt-info" + container = Singleton.get(Container, st.session_state) + storage = container.get(PromptStorage) + if storage is None: + st.error(f"Prompt storage is not initialized") + return + prompt = storage.get(prompt_id) + if prompt is None: + st.error(f"Prompt {prompt_id} not found") + return + + # description + desc = prompt.model_dump(include={"id", "description"}) + st.markdown(f""" +```yaml +{yaml_pretty_dump(desc)} +``` +""") + + # model info + if prompt.model: + st.subheader("Model Info") + srj.pydantic_instance_form(prompt.model, readonly=True) + + # prompt error + if prompt.error: + st.subheader("Prompt error") + st.error(prompt.error) + + # prompt functions + if prompt.functions: + st.subheader(_("Functions")) + with st.container(border=True): + for func in prompt.functions: + with st.expander(func.name): + st.write(func.model_dump()) + + system_prompt = prompt.system_prompt() + st.subheader("System Prompt") + with st.container(border=True): + st.markdown(system_prompt) + + if prompt.history: + st.subheader(_("History")) + with st.container(border=True): + render_messages(prompt.history, False, prefix) + if prompt.inputs: + st.subheader(_("Input")) + with st.container(border=True): + render_messages(prompt.inputs, False, prefix) + if prompt.added: + st.subheader(_("Added")) + with st.container(border=True): + render_messages(prompt.added, False, prefix) + render_empty() diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py index 52172229..f7d6e03f 100644 --- a/ghostos/prototypes/streamlitapp/widgets/messages.py +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -1,6 +1,7 @@ import streamlit as st from typing import Iterable, List, NamedTuple from ghostos.core.messages import Message, Role, MessageType, Caller +from ghostos.framework.messages import CompletionUsagePayload, TaskPayload, PromptPayload from ghostos.helpers import gettext as _ @@ -11,7 +12,7 @@ class MessageGroup(NamedTuple): messages: List[Message] -def render_messages(messages: Iterable[Message], debug: bool): +def render_messages(messages: Iterable[Message], debug: bool, prefix: str = ""): groups: List[MessageGroup] = [] group = MessageGroup("", "", "", []) @@ -27,10 +28,10 @@ def render_messages(messages: Iterable[Message], debug: bool): if group.messages: groups.append(group) for group in groups: - render_message_group(group, debug) + render_message_group(group, debug, prefix) -def render_message_group(group: MessageGroup, debug: bool): +def render_message_group(group: MessageGroup, debug: bool, prefix: str = ""): role = group.msg_role name = group.msg_name stage = group.stage @@ -42,15 +43,47 @@ def render_message_group(group: MessageGroup, debug: bool): with st.chat_message(render_role): st.caption(caption) for msg in group.messages: - render_message_content(msg, debug) + render_message_in_content(msg, debug, prefix) else: with st.chat_message(render_role): st.caption(caption) for msg in group.messages: - render_message_content(msg, debug) + render_message_in_content(msg, debug, prefix) -def render_message_content(message: Message, debug: bool): +def render_message_payloads(message: Message, debug: bool, prefix: str = ""): + import streamlit_antd_components as sac + from ghostos.prototypes.streamlitapp.widgets.dialogs import ( + open_task_info_dialog, open_completion_usage_dialog, open_prompt_info_dialog, + ) + + if not debug: + return + items = [] + task_payload = TaskPayload.read_payload(message) + if task_payload: + items.append(sac.ButtonsItem(label="Task Info")) + completion_usage = CompletionUsagePayload.read_payload(message) + if completion_usage: + items.append(sac.ButtonsItem(label="Completion Usage")) + prompt_payload = PromptPayload.read_payload(message) + if prompt_payload: + items.append(sac.ButtonsItem(label="Prompt Info")) + if items: + selected = sac.buttons( + items, + index=None, + key=prefix + ":payloads:" + message.msg_id, + ) + if selected == "Task Info" and task_payload: + open_task_info_dialog(task_payload.task_id) + elif selected == "Completion Usage" and completion_usage: + open_completion_usage_dialog(completion_usage) + elif selected == "Prompt Info" and prompt_payload: + open_prompt_info_dialog(prompt_payload.prompt_id) + + +def render_message_in_content(message: Message, debug: bool, prefix: str = ""): if message.type == MessageType.ERROR: st.error(f"Error: {message.content}") elif MessageType.is_text(message): @@ -60,8 +93,9 @@ def render_message_content(message: Message, debug: bool): st.write(message.model_dump(exclude_defaults=True)) if message.callers: - if st.button("tool calls", key="tool calls" + message.msg_id): + if st.button("tool calls", key=prefix + "tool calls" + message.msg_id): open_message_caller(message) + render_message_payloads(message, debug, prefix) @st.dialog("message_caller") diff --git a/ghostos/prototypes/streamlitapp/widgets/renderer.py b/ghostos/prototypes/streamlitapp/widgets/renderer.py index 499195ce..f664b8aa 100644 --- a/ghostos/prototypes/streamlitapp/widgets/renderer.py +++ b/ghostos/prototypes/streamlitapp/widgets/renderer.py @@ -4,15 +4,117 @@ from pydantic import BaseModel from ghostos.helpers import generate_import_path, yaml_pretty_dump from ghostos.streamlit import render_streamlit_object, StreamlitRenderer +from ghostos.core.runtime import ( + TaskBrief, GoTasks, GoTaskStruct, + GoThreads, GoThreadInfo, Turn, + Event, +) from ghostos.prototypes.streamlitapp.utils.session import Singleton from ghostos.container import Container +from ghostos.helpers import gettext as _ import inspect -__all__ = ['render_object'] +__all__ = [ + 'render_object', + 'render_task', 'render_task_by_id', + 'render_thread', 'render_event', 'render_turn', 'render_event_object', + 'render_empty', +] T = TypeVar('T') +def render_task_by_id(task_id: str): + container = Singleton.get(Container, st.session_state) + tasks = container.force_fetch(GoTasks) + task = tasks.get_task(task_id) + if task is None: + st.info(f"Task {task_id} not found") + st.empty() + return + render_task(task) + + +def render_empty(): + for i in range(30): + st.empty() + + +def render_task(task: GoTaskStruct): + brief = TaskBrief.from_task(task) + srj.pydantic_instance_form(brief, readonly=True) + + with st.expander(_("Detail"), expanded=False): + st.write(task.model_dump(exclude_defaults=True)) + + if task.children: + st.subheader("Subtasks") + st.write("todo") + + container = Singleton.get(Container, st.session_state) + threads = container.force_fetch(GoThreads) + thread = threads.get_thread(task.thread_id) + st.subheader("Thread Info") + if thread is None: + st.info(f"Thread {task.thread_id} is not created yet") + st.empty() + return + with st.container(border=True): + render_thread(thread, prefix="render_thread_in_task", debug=False) + + +def render_thread(thread: GoThreadInfo, max_turn: int = 20, prefix: str = "", debug: bool = False): + st.subheader("Thread Info") + turns = list(thread.turns()) + turns = turns[-max_turn:] + count = 0 + for turn in turns: + count += render_turn(turn, debug, prefix) + if count == 0: + st.info("No thread messages yet") + + +def render_turn(turn: Turn, debug: bool, prefix: str = "") -> int: + from ghostos.prototypes.streamlitapp.widgets.messages import render_messages + if turn.is_from_client(): + messages = list(turn.messages()) + render_messages(messages, debug, prefix) + return len(messages) + # from other task + else: + event = turn.event + sub_title = _("background run") + if event is not None: + sub_title = _("background event: ") + event.type + with st.expander(sub_title, expanded=False): + messages = list(turn.messages()) + render_messages(messages, debug, prefix) + render_event_object(event, debug) + return len(messages) + + +def render_event(event: Event, debug: bool): + from ghostos.prototypes.streamlitapp.widgets.messages import render_messages + if event is None: + return + if event.from_task_id: + sub_title = _("background event: ") + event.type + with st.expander(sub_title, expanded=False): + messages = event.iter_message(show_instruction=True) + render_messages(messages, debug) + else: + messages = event.iter_message(show_instruction=True) + render_messages(messages, debug) + + +def render_event_object(event: Event, debug: bool): + if event is None: + return + from_task_name = event.from_task_name + if debug and from_task_name is not None: + st.button(f"from task {from_task_name}", key=f"from task {event.event_id}") + + def render_object(obj: T, immutable: bool = False) -> Tuple[T, bool]: """ render an object in a streamlit compatible way. diff --git a/ghostos/scripts/cli/run_streamlit_ghost.py b/ghostos/scripts/cli/run_streamlit_ghost.py index 5c65a833..36954151 100644 --- a/ghostos/scripts/cli/run_streamlit_ghost.py +++ b/ghostos/scripts/cli/run_streamlit_ghost.py @@ -5,6 +5,7 @@ from streamlit.web.cli import main_run from ghostos.prototypes.streamlitapp import cli from ghostos.entity import EntityMeta, to_entity_meta +from ghostos.bootstrap import reset_at, get_ghostos from pydantic import BaseModel, Field import sys from os import path @@ -21,6 +22,7 @@ class RunGhostChatApp(BaseModel): def main(): # path workspace_dir = check_ghostos_workspace_exists() + container = reset_at(workspace_dir) ghost, modulename, filename, is_temp = get_ghost_by_cli_argv() args = RunGhostChatApp( modulename=modulename, diff --git a/ghostos/scripts/cli/utils.py b/ghostos/scripts/cli/utils.py index dc207a9f..c273d3d5 100644 --- a/ghostos/scripts/cli/utils.py +++ b/ghostos/scripts/cli/utils.py @@ -38,7 +38,7 @@ def check_ghostos_workspace_exists() -> str: app_dir, ok = expect_workspace_dir() if not ok: logger.error("expect GhostOS workspace `%s` is not found. ", app_dir) - logger.info("run `ghostos init` to create workspace") + logger.debug("run `ghostos init` to create workspace") sys.exit(0) return app_dir diff --git a/ghostos/thoughts/basic.py b/ghostos/thoughts/basic.py index 295df5ae..6723b9b1 100644 --- a/ghostos/thoughts/basic.py +++ b/ghostos/thoughts/basic.py @@ -80,7 +80,7 @@ def think(self, g: Ghost, e: Event) -> Optional[Operator]: llm_api = self.get_llmapi(g) # run llms - logger.info("start llm thinking") # todo: logger + logger.debug("start llm thinking") # todo: logger # prepare messenger messenger = session.messenger(functional_tokens=chat.functional_tokens) llm_api.deliver_chat_completion(chat, messenger) @@ -91,7 +91,7 @@ def think(self, g: Ghost, e: Event) -> Optional[Operator]: # callback actions for caller in callers: if caller.name in action_map: - logger.info(f"llm response caller `{caller.name}` match action") + logger.debug(f"llm response caller `{caller.name}` match action") action = action_map[caller.name] op = action.act(container, session, caller) if op is not None: diff --git a/tests/core/messages/test_arr_stream_receiver.py b/tests/core/messages/test_arr_stream_receiver.py index 2ec2cccc..6b030130 100644 --- a/tests/core/messages/test_arr_stream_receiver.py +++ b/tests/core/messages/test_arr_stream_receiver.py @@ -1,5 +1,5 @@ from typing import Iterable -from ghostos.core.messages.transport import new_arr_connection, Stream, ArrayReceiverBuffer +from ghostos.core.messages.transport import new_basic_connection, Stream, ReceiverBuffer from ghostos.core.messages.pipeline import SequencePipe from ghostos.core.messages.message import Message from threading import Thread @@ -15,7 +15,7 @@ def iter_content(content: str, gap: float) -> Iterable[Message]: def test_new_connection_baseline(): - stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + stream, retriever = new_basic_connection(timeout=5, idle=0.2, complete_only=False) assert stream.alive() assert not retriever.closed() content = "hello world, ha ha ha ha" @@ -43,7 +43,7 @@ def send_data(s: Stream, c: str): def test_new_connection_complete_only(): - stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=True) + stream, retriever = new_basic_connection(timeout=5, idle=0.2, complete_only=True) content = "hello world" def send_data(s: Stream, c: str): @@ -61,7 +61,7 @@ def send_data(s: Stream, c: str): def test_new_connection_timeout(): - stream, retriever = new_arr_connection(timeout=0.2, idle=0.2, complete_only=False) + stream, retriever = new_basic_connection(timeout=0.2, idle=0.2, complete_only=False) content = "hello world" def send_data(s: Stream, c: str): @@ -87,7 +87,7 @@ def send_data(s: Stream, c: str): def test_new_connection_sync(): - stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + stream, retriever = new_basic_connection(timeout=5, idle=0.2, complete_only=False) content = "hello world" def send_data(s: Stream, c: str): @@ -104,7 +104,7 @@ def send_data(s: Stream, c: str): def test_new_connection_wait(): - stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + stream, retriever = new_basic_connection(timeout=5, idle=0.2, complete_only=False) content = "hello world" def send_data(s: Stream, c: str): @@ -119,7 +119,7 @@ def send_data(s: Stream, c: str): def test_new_connection_recv_with_sequence(): - stream, retriever = new_arr_connection(timeout=0, idle=0.1, complete_only=False) + stream, retriever = new_basic_connection(timeout=0, idle=0.1, complete_only=False) content = "hello world" def send_data(s: Stream, c: str): @@ -134,7 +134,7 @@ def send_data(s: Stream, c: str): def test_new_connection_wait_with_sequence(): - stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + stream, retriever = new_basic_connection(timeout=5, idle=0.2, complete_only=False) content = "hello world" def send_data(s: Stream, c: str): @@ -152,7 +152,7 @@ def send_data(s: Stream, c: str): def test_new_connection_with_pool(): from ghostos.contracts.pool import DefaultPool pool = DefaultPool(10) - stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + stream, retriever = new_basic_connection(timeout=5, idle=0.2, complete_only=False) content = "hello world" def send_data(s: Stream, c: str): @@ -170,7 +170,7 @@ def send_data(s: Stream, c: str): def test_array_receiver_buffer_baseline(): - stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + stream, retriever = new_basic_connection(timeout=5, idle=0.2, complete_only=False) content = "hello world" def send_data(s: Stream, c: str): @@ -182,7 +182,7 @@ def send_data(s: Stream, c: str): send_data(stream, content) send_data(stream, content) - buffer = ArrayReceiverBuffer.new(retriever.recv()) + buffer = ReceiverBuffer.new(retriever.recv()) assert buffer is not None assert buffer.head().content == "h" for chunk in buffer.chunks(): @@ -204,7 +204,7 @@ def send_data(s: Stream, c: str): def test_array_receiver_buffer_async(): from ghostos.contracts.pool import DefaultPool pool = DefaultPool(10) - stream, retriever = new_arr_connection(timeout=5, idle=0.2, complete_only=False) + stream, retriever = new_basic_connection(timeout=5, idle=0.2, complete_only=False) content = "hello world" def send_data(s: Stream, c: str): @@ -215,7 +215,7 @@ def send_data(s: Stream, c: str): pool.submit(send_data, stream, content) with retriever: - buffer = ArrayReceiverBuffer.new(retriever.recv()) + buffer = ReceiverBuffer.new(retriever.recv()) assert buffer.tail().content == content buffer = buffer.next() assert buffer.tail().content == content diff --git a/tests/framework/messenger/test_messenger.py b/tests/framework/messenger/test_messenger.py index e6439904..b5a69432 100644 --- a/tests/framework/messenger/test_messenger.py +++ b/tests/framework/messenger/test_messenger.py @@ -1,5 +1,5 @@ from ghostos.framework.messengers import DefaultMessenger -from ghostos.core.messages import Message, new_arr_connection +from ghostos.core.messages import Message, new_basic_connection def test_default_messenger_baseline(): @@ -16,7 +16,7 @@ def test_default_messenger_baseline(): def test_messenger_with_upstream(): - stream, receiver = new_arr_connection() + stream, receiver = new_basic_connection() messenger = DefaultMessenger(stream) items = [] content = "hello world" From e1440636975e655b116364f917afb80241d7cf23 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 23 Nov 2024 01:57:37 +0800 Subject: [PATCH 101/148] dev: achieve moss agent but so many bugs to fix: 1. openai tools protocol is not asynchronize ready 2. means i shall use functional tokens instead. 3. moss action response need many tests 4. python logging is pain in the ass, can't be sure what does it behave 5. streamlit configuration need some hacks --- .ghostos/.example.env | 4 +- .ghostos/configs/llms_conf.yml | 2 +- .ghostos/runtime/variables/.gitignore | 2 + ghostos/abcd/__init__.py | 1 + ghostos/abcd/concepts.py | 17 +- ghostos/abcd/thoughts.py | 36 +- ghostos/bootstrap.py | 8 +- ghostos/contracts/logger.py | 20 +- ghostos/core/llms/configs.py | 1 + ghostos/core/llms/prompt.py | 29 +- ghostos/core/llms/prompt_pipes.py | 4 +- ghostos/core/messages/message.py | 30 +- ghostos/core/messages/message_classes.py | 4 +- ghostos/core/messages/openai.py | 131 +++++--- ghostos/core/moss/impl.py | 5 + ghostos/core/moss/lifecycle.py | 25 +- ghostos/core/runtime/threads.py | 40 ++- ghostos/demo/src/examples/run_aifunc_test.py | 12 - .../framework/ghostos/conversation_impl.py | 20 +- ghostos/framework/ghostos/ghostos_impl.py | 13 +- ghostos/framework/ghostos/session_impl.py | 24 +- ghostos/framework/ghostos/shell_impl.py | 14 +- ghostos/framework/ghostos/taskflow_impl.py | 3 - ghostos/framework/llms/openai_driver.py | 32 +- ghostos/framework/llms/providers.py | 5 +- ghostos/framework/logger/__init__.py | 2 +- ghostos/framework/logger/named.py | 16 +- ghostos/framework/messengers/defaults.py | 16 +- ghostos/ghosts/moss_agent/__init__.py | 36 ++ .../moss_agent/{moss_agent.py => agent.py} | 309 ++++++++++-------- ghostos/ghosts/moss_agent/for_developer.py | 85 +++++ ghostos/ghosts/moss_agent/for_meta_ai.py | 39 +++ ghostos/ghosts/moss_agent/instructions.py | 35 +- ghostos/prompter.py | 7 +- ghostos/prototypes/aifunc/__init__.py | 20 -- ghostos/prototypes/aifunc/app.py | 110 ------- .../streamlitapp/cli/run_ghost_chat.py | 10 +- .../pages/{ghosts/chat.py => ghosts.py} | 110 +++++-- .../prototypes/streamlitapp/pages/router.py | 32 +- .../streamlitapp/tests/test_markdown.py | 9 + .../streamlitapp/widgets/messages.py | 26 +- .../streamlitapp/widgets/renderer.py | 7 +- ghostos/scripts/clear_runtime.py | 8 + ghostos/scripts/cli/run_console.py | 4 +- ghostos/scripts/cli/run_streamlit_ghost.py | 30 +- ghostos/scripts/cli/utils.py | 81 ++++- tests/core/messages/test_message_parser.py | 10 +- tests/python/test_slice.py | 8 + tests/python/test_yield.py | 10 + 49 files changed, 971 insertions(+), 531 deletions(-) create mode 100644 .ghostos/runtime/variables/.gitignore delete mode 100644 ghostos/demo/src/examples/run_aifunc_test.py rename ghostos/ghosts/moss_agent/{moss_agent.py => agent.py} (50%) create mode 100644 ghostos/ghosts/moss_agent/for_developer.py create mode 100644 ghostos/ghosts/moss_agent/for_meta_ai.py delete mode 100644 ghostos/prototypes/aifunc/__init__.py delete mode 100644 ghostos/prototypes/aifunc/app.py rename ghostos/prototypes/streamlitapp/pages/{ghosts/chat.py => ghosts.py} (72%) create mode 100644 ghostos/prototypes/streamlitapp/tests/test_markdown.py diff --git a/.ghostos/.example.env b/.ghostos/.example.env index 1f21c384..1745b847 100644 --- a/.ghostos/.example.env +++ b/.ghostos/.example.env @@ -14,6 +14,4 @@ ANTHROPIC_API_KEY="your anthropic api key" # optional anthropic proxy ANTHROPIC_PROXY # [deepseek](https://deepseek.com) api key -DEEPSEEK_API_KEY -# Logger name for your application -LoggerName=debug \ No newline at end of file +DEEPSEEK_API_KEY \ No newline at end of file diff --git a/.ghostos/configs/llms_conf.yml b/.ghostos/configs/llms_conf.yml index d775d6dc..cef00404 100644 --- a/.ghostos/configs/llms_conf.yml +++ b/.ghostos/configs/llms_conf.yml @@ -16,7 +16,7 @@ services: base_url: https://api.deepseek.com/beta # proxy: $OPENAI_PROXY # Configure default LLM API here. -default: moonshot-v1-32k +default: gpt-4o # The models below can be edited as you want, see details: ghostos.core.llms.configs:ModelConf # the key of models is a `llm_api_name`, value is a ModelConf instance. models: diff --git a/.ghostos/runtime/variables/.gitignore b/.ghostos/runtime/variables/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/.ghostos/runtime/variables/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ghostos/abcd/__init__.py b/ghostos/abcd/__init__.py index 407a3ff3..48395b24 100644 --- a/ghostos/abcd/__init__.py +++ b/ghostos/abcd/__init__.py @@ -11,3 +11,4 @@ get_ghost_driver_type, get_ghost_driver, is_ghost, run_session_event, fire_session_event, ) +from ghostos.abcd.thoughts import Thought, LLMThought, ChainOfThoughts diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index ef24a45a..23a0177f 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -11,10 +11,10 @@ from ghostos.core.runtime import ( TaskState, ) -from ghostos.core.llms import Prompt from ghostos.core.runtime.events import Event from ghostos.core.runtime.tasks import GoTaskStruct, TaskBrief from ghostos.core.runtime.threads import GoThreadInfo +from ghostos.core.llms import PromptPipe, Prompt from ghostos.core.messages import MessageKind, Message, Stream, Caller, Payload, Receiver from ghostos.contracts.logger import LoggerItf from ghostos.container import Container, Provider @@ -146,10 +146,6 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: def truncate(self, session: Session) -> GoThreadInfo: pass - @abstractmethod - def prompt(self, session: Session) -> Prompt: - pass - class Context(Payload, DataPrompter, ABC): """ @@ -201,11 +197,15 @@ def destroy(self): pass -class Action(Protocol): +class Action(PromptPipe, ABC): @abstractmethod def name(self) -> str: pass + @abstractmethod + def update_prompt(self, prompt: Prompt) -> Prompt: + pass + @abstractmethod def run(self, session: Session, caller: Caller) -> Union[Operator, None]: pass @@ -355,6 +355,11 @@ def update_thread(self, thread: GoThreadInfo) -> None: def get_ghost(self) -> G: pass + def get_ghost_driver(self) -> GhostDriver[G]: + from ghostos.abcd.utils import get_ghost_driver + ghost = self.get_ghost() + return get_ghost_driver(ghost) + @abstractmethod def get_context(self) -> G.ContextType: pass diff --git a/ghostos/abcd/thoughts.py b/ghostos/abcd/thoughts.py index 317fd318..ff491281 100644 --- a/ghostos/abcd/thoughts.py +++ b/ghostos/abcd/thoughts.py @@ -1,7 +1,10 @@ -from typing import Optional, Generic, TypeVar, Tuple, List +from typing import Optional, Generic, TypeVar, Tuple, List, Iterable from abc import ABC, abstractmethod from ghostos.abcd.concepts import Session, Operator, Action from ghostos.core.llms import Prompt, ModelConf, ServiceConf, PromptPipe, LLMs, LLMApi +from pydantic import BaseModel, Field + +__all__ = ['Thought', 'LLMThought', 'SummaryThought', 'ChainOfThoughts'] T = TypeVar("T") @@ -42,8 +45,8 @@ class LLMThought(Thought[Operator]): def __init__( self, llm_api: str = "", + actions: Optional[Iterable[Action]] = None, message_stage: str = "", - *actions: Action, model: Optional[ModelConf] = None, service: Optional[ServiceConf] = None, ): @@ -59,12 +62,13 @@ def __init__( self.message_stage = message_stage self.model = model self.service = service - self.actions = {action.name(): action for action in actions} + self.actions = {} + if actions: + self.actions = {action.name(): action for action in actions} def think(self, session: Session, prompt: Prompt) -> Tuple[Prompt, Optional[Operator]]: for action in self.actions.values(): - if isinstance(action, PromptPipe): - prompt = action.update_prompt(prompt) + prompt = action.update_prompt(prompt) llm_api = self.get_llm_api(session) streaming = not session.upstream.completes_only() @@ -89,3 +93,25 @@ def get_llm_api(self, session: Session) -> LLMApi: else: llm_api = llms.get_api(self.llm_api) return llm_api + + +class SummaryThought(BaseModel, Thought[str]): + """ + simple summary thought + """ + + llm_api: str = Field("", description="the llm api to use") + instruction: str = Field( + "summarize the history message in 500 words, keep the most important information.", + description="the llm instruction to use", + ) + + def think(self, session: Session, prompt: Prompt) -> Tuple[Prompt, Optional[T]]: + from ghostos.core.messages import Role + forked = prompt.fork(None) + instruction = Role.SYSTEM.new(content=self.instruction) + forked.added.append(instruction) + llms = session.container.force_fetch(LLMs) + llm_api = llms.get_api(self.llm_api) + message = llm_api.chat_completion(forked) + return prompt, message.content diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 93484acf..84cd877b 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -164,7 +164,6 @@ def default_application_contracts() -> Contracts: def default_application_providers( root_dir: str, - logger_name: str, workspace_configs_dir: str = "configs", workspace_runtime_dir: str = "runtime", runtime_processes_dir: str = "processes", @@ -187,7 +186,7 @@ def default_application_providers( from ghostos.framework.tasks import WorkspaceTasksProvider from ghostos.framework.eventbuses import MemEventBusImplProvider from ghostos.framework.llms import ConfigBasedLLMsProvider, PromptStorageInWorkspaceProvider - from ghostos.framework.logger import NamedLoggerProvider + from ghostos.framework.logger import DefaultLoggerProvider from ghostos.framework.variables import WorkspaceVariablesProvider from ghostos.framework.ghostos import GhostOSProvider from ghostos.core.aifunc import DefaultAIFuncExecutorProvider, AIFuncRepoByConfigsProvider @@ -196,7 +195,7 @@ def default_application_providers( # --- logger ---# - NamedLoggerProvider(logger_name), + DefaultLoggerProvider(), # --- workspace --- # BasicWorkspaceProvider( workspace_dir=root_dir, @@ -250,9 +249,8 @@ def make_app_container( # load env from dotenv file dotenv.load_dotenv(dotenv_path=join(workspace_path, dotenv_file_path)) # default logger name for GhostOS application - logger_name = os.environ.get("LoggerName", "ghostos") if app_providers is None: - app_providers = default_application_providers(root_dir=workspace_path, logger_name=logger_name) + app_providers = default_application_providers(root_dir=workspace_path) if app_contracts is None: app_contracts = default_application_contracts() diff --git a/ghostos/contracts/logger.py b/ghostos/contracts/logger.py index 8036a187..a174527b 100644 --- a/ghostos/contracts/logger.py +++ b/ghostos/contracts/logger.py @@ -2,13 +2,13 @@ from abc import abstractmethod from logging.config import dictConfig from logging import getLogger, LoggerAdapter, Logger -from typing import Protocol, Optional +from typing import Protocol, Optional, Union from os import path import yaml __all__ = [ 'LoggerItf', 'config_logging', 'get_logger', 'get_console_logger', 'get_debug_logger', - 'wrap_logger', 'LoggerAdapter', + 'wrap_logger', 'LoggerAdapter', 'get_ghostos_logger', ] @@ -137,6 +137,22 @@ def get_console_logger( return LoggerAdapter(logger, extra=extra) +def get_ghostos_logger(extra: Optional[dict] = None) -> Union[LoggerAdapter, Logger]: + logger = getLogger("ghostos") + if not logger.hasHandlers(): + _debug_file_handler = logging.FileHandler("debug.log", mode="a") + formatter = logging.Formatter( + fmt="%(asctime)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)", + ) + _debug_file_handler.setFormatter(formatter) + _debug_file_handler.setLevel(logging.DEBUG) + logger.addHandler(_debug_file_handler) + logger.setLevel(logging.DEBUG) + if extra: + return LoggerAdapter(logger, extra) + return logger + + def get_debug_logger( name: str = "__ghostos_debug__", extra: Optional[dict] = None, diff --git a/ghostos/core/llms/configs.py b/ghostos/core/llms/configs.py index 69b05b94..d75745c2 100644 --- a/ghostos/core/llms/configs.py +++ b/ghostos/core/llms/configs.py @@ -32,6 +32,7 @@ class ModelConf(Payload): timeout: float = Field(default=30, description="timeout") request_timeout: float = Field(default=40, description="request timeout") kwargs: Dict[str, Any] = Field(default_factory=dict, description="kwargs") + use_tools: bool = Field(default=True, description="use tools") class ServiceConf(BaseModel): diff --git a/ghostos/core/llms/prompt.py b/ghostos/core/llms/prompt.py index 490356de..c2281b80 100644 --- a/ghostos/core/llms/prompt.py +++ b/ghostos/core/llms/prompt.py @@ -7,6 +7,7 @@ from openai.types.chat.completion_create_params import Function, FunctionCall from openai import NotGiven, NOT_GIVEN from openai.types.chat.chat_completion_function_call_option_param import ChatCompletionFunctionCallOptionParam +from openai.types.shared_params.function_definition import FunctionDefinition from openai.types.chat.chat_completion_tool_param import ChatCompletionToolParam from pydantic import BaseModel, Field @@ -105,10 +106,14 @@ def get_openai_functions(self) -> Union[List[Function], NotGiven]: return NOT_GIVEN functions = [] for func in self.functions: - if func.id is not None: - continue - openai_func = Function(**func.model_dump()) + openai_func = Function( + name=func.name, + description=func.description, + parameters=func.parameters, + ) functions.append(openai_func) + if not functions: + return NOT_GIVEN return functions def get_openai_tools(self) -> Union[List[ChatCompletionToolParam], NotGiven]: @@ -116,15 +121,19 @@ def get_openai_tools(self) -> Union[List[ChatCompletionToolParam], NotGiven]: return NOT_GIVEN tools = [] for func in self.functions: - if func.id is None: - continue - openai_func = Function(**func.model_dump()) - tool = ChatCompletionToolParam(function=openai_func) + openai_func = FunctionDefinition( + name=func.name, + description=func.description, + parameters=func.parameters, + ) + tool = ChatCompletionToolParam(function=openai_func, type="function") tools.append(tool) + if not tools: + return NOT_GIVEN return tools def get_openai_function_call(self) -> Union[FunctionCall, NotGiven]: - if not self.functions: + if not self.functions or self.model.use_tools: return NOT_GIVEN if self.function_call is None: return "auto" @@ -149,7 +158,7 @@ def filter_stages(self, stages: Optional[List[str]] = None) -> Self: def fork( self, - inputs: List[Message], + inputs: Optional[List[Message]], *, system: Optional[List[Message]] = None, description: str = "", @@ -166,7 +175,7 @@ def fork( copied = self.filter_stages(stages) copied.id = prompt_id copied.description = description - if inputs: + if inputs is not None: copied.history.extend(copied.inputs) copied.history.extend(copied.added) copied.inputs = inputs diff --git a/ghostos/core/llms/prompt_pipes.py b/ghostos/core/llms/prompt_pipes.py index f45dfc79..060c915c 100644 --- a/ghostos/core/llms/prompt_pipes.py +++ b/ghostos/core/llms/prompt_pipes.py @@ -19,8 +19,8 @@ def filter_fn(message: Message) -> Optional[Message]: if message.role != Role.ASSISTANT.value: return message - copy = None - if message.name != self._assistant_name: + copy = message + if message.name == self._assistant_name: copy = message.get_copy() copy.name = "" return copy diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index aba75710..22e3294c 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -292,19 +292,16 @@ def new_chunk( content: Optional[str] = None, memory: Optional[str] = None, name: Optional[str] = None, + ref_id: Optional[str] = None, ): """ create a chunk message. - :param typ_: - :param role: - :param content: - :param memory: - :param name: :return: """ return cls( role=role, name=name, content=content, memory=memory, type=typ_, + ref_id=ref_id, seq="chunk", ) @@ -480,6 +477,17 @@ def new_output(self, output: str) -> CallerOutput: content=output, ) + @classmethod + def from_message(cls, message: Message) -> Iterable[Caller]: + if message.type == MessageType.FUNCTION_CALL.value: + yield Caller( + id=message.ref_id, + name=message.name, + arguments=message.content, + ) + if message.callers: + yield from message.callers + class CallerOutput(BaseModel, MessageClass): __message_type__ = MessageType.FUNCTION_OUTPUT.value @@ -504,24 +512,24 @@ def from_message(cls, container: Message) -> Optional[Self]: return cls( call_id=container.ref_id, name=container.name, - output=container.content, + content=container.content, ) - def to_openai_param(self, container: Optional[Container]) -> Dict: + def to_openai_param(self, container: Optional[Container]) -> List[Dict]: from openai.types.chat.chat_completion_tool_message_param import ChatCompletionToolMessageParam from openai.types.chat.chat_completion_function_message_param import ChatCompletionFunctionMessageParam if self.call_id: - return ChatCompletionToolMessageParam( + return [ChatCompletionToolMessageParam( content=self.content, role="tool", tool_call_id=self.call_id, - ) + )] else: - return ChatCompletionFunctionMessageParam( + return [ChatCompletionFunctionMessageParam( content=self.content, name=self.name, role="function", - ) + )] class MessageClassesParser: diff --git a/ghostos/core/messages/message_classes.py b/ghostos/core/messages/message_classes.py index 46944e1b..d6e79870 100644 --- a/ghostos/core/messages/message_classes.py +++ b/ghostos/core/messages/message_classes.py @@ -94,10 +94,10 @@ def parse(self, messages: Iterable[Union[MessageKind, Any]]) -> Iterable[Message for item in messages: if isinstance(item, Message): yield self._with_ref(item) - if isinstance(item, MessageClass): + elif isinstance(item, MessageClass): msg = item.to_message() yield self._with_ref(msg) - if isinstance(item, str): + elif isinstance(item, str): if not item: # exclude empty message continue diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index 1b23971a..0081858c 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -4,19 +4,19 @@ from openai.types.completion_usage import CompletionUsage from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam from openai.types.chat.chat_completion_message import ChatCompletionMessage +from openai.types.chat.chat_completion_tool_message_param import ChatCompletionToolMessageParam from openai.types.chat.chat_completion_assistant_message_param import ChatCompletionAssistantMessageParam, FunctionCall from openai.types.chat.chat_completion_message_tool_call_param import ChatCompletionMessageToolCallParam from openai.types.chat.chat_completion_system_message_param import ChatCompletionSystemMessageParam from openai.types.chat.chat_completion_user_message_param import ChatCompletionUserMessageParam from openai.types.chat.chat_completion_function_message_param import ChatCompletionFunctionMessageParam -from openai.types.chat.chat_completion_tool_message_param import ChatCompletionToolMessageParam - from ghostos.core.messages import ( Message, MessageType, Role, Caller, Payload, MessageClass, MessageClassesParser ) from ghostos.core.messages.message_classes import ( CallerOutput, VariableMessage, ) +from ghostos.contracts.logger import get_ghostos_logger from ghostos.container import Provider, Container from ghostos.helpers import import_class_from_path @@ -120,30 +120,70 @@ def parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam yield from self._parse_message(message) def _parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: + if message.type == MessageType.FUNCTION_CALL.value: + if message.ref_id: + yield from [ + ChatCompletionAssistantMessageParam( + content=None, + role="assistant", + tool_calls=[ + ChatCompletionMessageToolCallParam( + id=message.ref_id, + function=FunctionCall( + name=message.name, + arguments=message.content, + ), + type="function" + ) + ] + ) + ] + else: + yield from [ + ChatCompletionAssistantMessageParam( + content=None, + role="assistant", + function_call=FunctionCall( + name=message.name, + arguments=message.content, + ) + ) + ] + elif message.type == MessageType.FUNCTION_OUTPUT: + if message.ref_id: + yield from [ + ChatCompletionToolMessageParam( + tool_call_id=message.ref_id, + content=message.content, + role="tool", + ) + ] + else: + yield from [ + ChatCompletionFunctionMessageParam( + content=message.get_content(), + name=message.name, + role="function", + ) + ] + elif not MessageType.is_text(message): + return [] + if message.role == Role.ASSISTANT: - return self._parse_assistant_chat_completion(message) + yield from self._parse_assistant_chat_completion(message) elif message.role == Role.SYSTEM: - return [ - ChatCompletionSystemMessageParam(content=message.get_content(), name=message.name, role="system") + yield from [ + ChatCompletionSystemMessageParam(content=message.get_content(), role="system") ] elif message.role == Role.USER: - return [ - ChatCompletionUserMessageParam(content=message.get_content(), name=message.name, role="user") - ] - elif message.type == MessageType.FUNCTION_OUTPUT: - return [ - ChatCompletionFunctionMessageParam(content=message.get_content(), name=message.name, role="function") - ] - elif message.role == MessageType.FUNCTION_CALL: - return [ - ChatCompletionToolMessageParam( - tool_call_id=message.ref_id, - content=message.get_content(), - role="tool", - ) + item = ChatCompletionUserMessageParam(content=message.get_content(), role="user") + if message.name: + item["name"] = message.name + yield from [ + item ] else: - return [] + yield from [] @staticmethod def _parse_assistant_chat_completion(message: Message) -> Iterable[ChatCompletionAssistantMessageParam]: @@ -179,14 +219,15 @@ def _parse_assistant_chat_completion(message: Message) -> Iterable[ChatCompletio tool_calls.append(tool_call) if not content and not function_call and not tool_calls: return [] - - return [ChatCompletionAssistantMessageParam( + item = ChatCompletionAssistantMessageParam( content=content, role="assistant", - name=message.name if message.name else "", - function_call=function_call, tool_calls=tool_calls, - )] + ) + if message.name: + item["name"] = message.name + + return [item] def from_chat_completion(self, message: ChatCompletionMessage) -> Message: pack = Message.new_tail(type_=MessageType.DEFAULT, role=message.role, content=message.content) @@ -212,6 +253,7 @@ def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) - # 创建首包, 并发送. buffer = None for item in messages: + get_ghostos_logger().debug("receive chunk: %s", item) if len(item.choices) == 0: # 接受到了 openai 协议尾包. 但在这个协议里不作为尾包发送. usage = CompletionUsagePayload.from_chunk(item) @@ -222,6 +264,8 @@ def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) - choice = item.choices[0] delta = choice.delta chunk = self._new_pack_from_delta(delta) + if chunk is None: + continue else: continue @@ -241,23 +285,36 @@ def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) - yield buffer.as_tail(copy=False) @staticmethod - def _new_pack_from_delta(delta: ChoiceDelta) -> Message: - pack = Message.new_chunk( - role=Role.ASSISTANT.value, - content=delta.content, - typ_=MessageType.DEFAULT, - ) + def _new_pack_from_delta(delta: ChoiceDelta) -> Optional[Message]: + # function call if delta.function_call: - function_call = Caller(**delta.function_call.model_dump()) - pack.callers.add(function_call) + pack = Message.new_chunk( + typ_=MessageType.FUNCTION_CALL.value, + name=delta.function_call.name, + content=delta.function_call.arguments, + ) + return pack # tool calls - if delta.tool_calls: + elif delta.content: + pack = Message.new_chunk( + role=Role.ASSISTANT.value, + content=delta.content, + typ_=MessageType.DEFAULT, + ) + return pack + + elif delta.tool_calls: for item in delta.tool_calls: - tool_call = Caller(**item.tool_call.model_dump()) - pack.callers.add(tool_call) - return pack + pack = Message.new_chunk( + typ_=MessageType.FUNCTION_CALL.value, + ref_id=item.id, + name=item.function.name, + content=item.function.arguments, + ) + return pack + return None class DefaultOpenAIParserProvider(Provider[OpenAIMessageParser]): diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index f561ad9f..f2138044 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -189,6 +189,7 @@ def __del__(self): self.destroy() def _compile_moss(self) -> Moss: + from .lifecycle import __moss_compiled__ moss_type = self.moss_type() if not issubclass(moss_type, Moss): raise TypeError(f"Moss type {moss_type} is not subclass of {generate_module_and_attr_name(Moss)}") @@ -229,6 +230,10 @@ def inject(attr_name: str, injected: Any) -> Any: self._compiled.__dict__[MOSS_VALUE_NAME] = moss self._compiled.__dict__[MOSS_TYPE_NAME] = moss_type + fn = __moss_compiled__ + if __moss_compiled__.__name__ in self._compiled.__dict__: + fn = self._compiled.__dict__[__moss_compiled__.__name__] + fn(moss) return moss def container(self) -> Container: diff --git a/ghostos/core/moss/lifecycle.py b/ghostos/core/moss/lifecycle.py index 8ea24ef5..abab44b5 100644 --- a/ghostos/core/moss/lifecycle.py +++ b/ghostos/core/moss/lifecycle.py @@ -4,7 +4,7 @@ if TYPE_CHECKING: from ghostos.core.moss.prompts import AttrPrompts - from ghostos.core.moss.abcd import MossPrompter, Execution, MossRuntime, MossCompiler + from ghostos.core.moss.abcd import MossPrompter, Execution, MossRuntime, MossCompiler, Moss """ 这个文件提供了 MOSS 生命周期的关键方法, 每一个都是可选的. @@ -16,22 +16,10 @@ '__moss_attr_prompts__', '__moss_code_context__', '__moss_exec__', + '__moss_compiled__', ] -class MOSS(ABC): - """ - 可以在代码里自定一个名为 MOSS 的类. - 如果定义了, 或者引用了, 会自动生成它的实例. - 会对类上定义的属性进行依赖注入. - 对类上定义的方法名也按名称进行依赖注入. 没有注入对象时保留原方法. - 这个类不要有 init 方法. - 它的源码就是 MOSS 的 Prompt. - 如果给 MOSS 注入了它未定义的属性或方法, 也会追加到它生成的 Prompt 里. - """ - pass - - def __moss_compile__(compiler: "MossCompiler") -> "MossCompiler": """ 从 compile 中获取 MOSSRuntime, 并对它进行初始化. @@ -44,6 +32,15 @@ def __moss_compile__(compiler: "MossCompiler") -> "MossCompiler": return compiler +def __moss_compiled__(moss: "Moss") -> None: + """ + + :param moss: + :return: + """ + return + + def __moss_attr_prompts__() -> "AttrPrompts": """ 生成本地变量生成的 prompt. diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index 870ec688..a775fa18 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -38,6 +38,10 @@ class Turn(BaseModel): created: int = Field( default_factory=timestamp, ) + summary: Optional[str] = Field( + default=None, + description="The summary before till this turn", + ) extra: Dict[str, Any] = Field(default_factory=dict, description="extra information") @classmethod @@ -71,7 +75,11 @@ def event_messages(self, show_instruction: bool = False) -> Iterable[Message]: def iter_event_message(event: Event, show_instruction: bool = True) -> Iterable[Message]: yield from event.iter_message(show_instruction) - def messages(self) -> Iterable[Message]: + def messages(self, truncate: bool) -> Iterable[Message]: + if truncate and self.summary is not None: + yield Role.SYSTEM.new("summary of omitted history messages" + self.summary) + return + yield from self.event_messages() if self.added: yield from self.added @@ -160,14 +168,25 @@ def last_turn(self) -> Turn: return self.history[-1] return self.on_created - def get_history_messages(self) -> Iterable[Message]: + def get_history_turns(self, truncate: bool = True) -> List[Turn]: + turns = [] + if self.history: + for turn in self.history: + # use summary as truncate point + if truncate and turn.summary is not None: + turns = [turn] + else: + turns.append(turn) + return turns + + def get_history_messages(self, truncate: bool) -> Iterable[Message]: """ 返回所有的历史消息. """ - yield from self.on_created.messages() - if self.history: - for turn in self.history: - yield from turn.messages() + yield from self.on_created.messages(False) + turns = self.get_history_turns(truncate) + for turn in turns: + yield from turn.messages(truncate) def get_pycontext(self) -> PyContext: """ @@ -269,9 +288,14 @@ def reset_history(self, messages: Iterable[Message]) -> Self: def thread_copy(self, update: Optional[dict] = None) -> "GoThreadInfo": return self.model_copy(update=update, deep=True) - def to_prompt(self, system: List[Message], stages: Optional[List[str]] = None) -> Prompt: + def to_prompt( + self, + system: List[Message], + stages: Optional[List[str]] = None, + truncate: bool = True, + ) -> Prompt: turn_id = self.last_turn().turn_id - history = list(self.get_history_messages()) + history = list(self.get_history_messages(truncate)) inputs = [] appending = [] current_turn = self.current diff --git a/ghostos/demo/src/examples/run_aifunc_test.py b/ghostos/demo/src/examples/run_aifunc_test.py deleted file mode 100644 index b419a354..00000000 --- a/ghostos/demo/src/examples/run_aifunc_test.py +++ /dev/null @@ -1,12 +0,0 @@ -from ghostos.prototypes.aifunc import quick_run_aifunc -from ghostos.demo.aifuncs import AgentFn -from ghostos.helpers import yaml_pretty_dump - -if __name__ == '__main__': - fn = AgentFn( - request="please tell me the weather in beijing today, and I want to know the news about OpenAI model o1", - ) - - result = quick_run_aifunc(fn, current_path=__file__, dirname_times=3, debug=True) - print(result) - print(yaml_pretty_dump(result.model_dump(exclude_defaults=True))) diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index fa9e8ca6..57852c67 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -13,7 +13,7 @@ GoThreadInfo, GoThreads, ) from ghostos.contracts.pool import Pool -from ghostos.contracts.logger import LoggerItf, wrap_logger +from ghostos.contracts.logger import LoggerItf, get_ghostos_logger from ghostos.entity import to_entity_meta, get_entity from pydantic import BaseModel, Field from .session_impl import SessionImpl @@ -59,8 +59,6 @@ def __init__( parent_task_id=task.parent, ) self._pool = self._container.force_fetch(Pool) - logger = container.force_fetch(LoggerItf) - self._logger = wrap_logger(logger, self._scope.model_dump()) self._is_background = is_background self._ctx: Optional[Context] = None self._locker = task_locker @@ -72,9 +70,12 @@ def __init__( self._bootstrap() def _bootstrap(self): - self._container.set(LoggerItf, self._logger) self._container.bootstrap() + @property + def logger(self): + return get_ghostos_logger(self._scope.model_dump()) + def container(self) -> Container: self._validate_closed() return self._container @@ -116,7 +117,7 @@ def get_artifact(self) -> Tuple[Union[Ghost.ArtifactType, None], TaskState]: def talk(self, query: str, user_name: str = "") -> Tuple[Event, Receiver]: self._validate_closed() - self._logger.debug("talk to user %s", user_name) + self.logger.debug("talk to user %s", user_name) message = Role.USER.new(content=query, name=user_name) return self.respond([message]) @@ -152,7 +153,7 @@ def respond_event( # complete task_id if not event.task_id: event.task_id = self._scope.task_id - self._logger.debug("start to respond event %s", event.event_id) + self.logger.debug("start to respond event %s", event.event_id) stream, retriever = new_basic_connection( timeout=timeout, @@ -169,19 +170,19 @@ def _validate_closed(self): raise RuntimeError(f"Shell is closed") def _submit_session_event(self, event: Event, stream: Stream) -> None: - self._logger.debug("submit session event") + self.logger.debug("submit session event") try: with stream: task = self._tasks.get_task(event.task_id) session = self._create_session(task, self._locker, stream) - self._logger.debug( + self.logger.debug( f"create session from event id %s, task_id is %s", event.event_id, task.task_id, ) with session: run_session_event(session, event, self._conf.max_session_step) except Exception as e: - self._logger.exception(e) + self.logger.exception(e) self.fail(error=e) finally: self._eventbus.notify_task(event.task_id) @@ -234,7 +235,6 @@ def _destroy(self): del self._threads del self._eventbus del self._pool - del self._logger def closed(self) -> bool: return self._closed or self._shell_closed() diff --git a/ghostos/framework/ghostos/ghostos_impl.py b/ghostos/framework/ghostos/ghostos_impl.py index 83c97f3e..547e54c2 100644 --- a/ghostos/framework/ghostos/ghostos_impl.py +++ b/ghostos/framework/ghostos/ghostos_impl.py @@ -8,7 +8,7 @@ from ghostos.contracts.pool import Pool from ghostos.contracts.variables import Variables from ghostos.contracts.workspace import Workspace -from ghostos.contracts.logger import LoggerItf +from ghostos.contracts.logger import LoggerItf, get_ghostos_logger from pydantic import Field from .shell_impl import ShellImpl, ShellConf @@ -42,11 +42,14 @@ def __init__( ): self.contracts.validate(container) self._container = container - self._logger = self._container.force_fetch(LoggerItf) self._processes = container.force_fetch(GoProcesses) self._configs = container.force_fetch(Configs) self._ghostos_config = self._configs.get(GhostOSConfig) + @property + def logger(self) -> LoggerItf: + return get_ghostos_logger() + def container(self) -> Container: return self._container @@ -65,12 +68,12 @@ def create_shell( process = self._processes.get_process(shell_id) if process is None: process = GoProcess.new(shell_id=shell_id, process_id=process_id) - self._logger.debug(f"Created shell `{shell_id}` process `{process_id}`") + self.logger.debug(f"Created shell `{shell_id}` process `{process_id}`") elif process_id is not None and process.process_id != process_id: process = GoProcess.new(shell_id=shell_id, process_id=process_id) - self._logger.debug(f"Created shell `{shell_id}` new process `{process_id}`") + self.logger.debug(f"Created shell `{shell_id}` new process `{process_id}`") else: - self._logger.debug(f"get shell `{shell_id}` new process `{process.process_id}`") + self.logger.debug(f"get shell `{shell_id}` new process `{process.process_id}`") self._processes.save_process(process) # prepare container diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index ee8e7002..985ae881 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -15,7 +15,7 @@ GoThreadInfo, ) from ghostos.prompter import Prompter -from ghostos.contracts.logger import wrap_logger, LoggerItf +from ghostos.contracts.logger import get_ghostos_logger, LoggerItf from ghostos.contracts.variables import Variables from ghostos.container import Container, provide, Contracts from ghostos.entity import to_entity_meta, from_entity_meta, get_entity, EntityType @@ -66,12 +66,6 @@ def __init__( task_id=task.task_id, parent_task_id=task.parent, ) - logger = container.force_fetch(LoggerItf) - logger.debug("SessionImpl initialized at %s", self.scope) - self.logger = wrap_logger( - logger, - extra=self.scope.model_dump(), - ) self.ghost: G = get_entity(self.task.meta, Ghost) self.ghost_driver: GhostDriver[G] = get_ghost_driver(self.ghost) @@ -98,6 +92,10 @@ def __init__( raise RuntimeError(f"Failed to start session") Session.instance_count += 1 + @property + def logger(self) -> LoggerItf: + return get_ghostos_logger(self.scope.model_dump()) + def __del__(self): # for gc test Session.instance_count -= 1 @@ -106,7 +104,6 @@ def __del__(self): def _bootstrap(self): self.contracts.validate(self.container) self.container.set(Session, self) - self.container.set(LoggerItf, self.logger) self.container.set(Scope, self.scope) self.container.set(MessageKindParser, self._message_parser) self.container.register(provide(GoTaskStruct, False)(lambda c: self.task)) @@ -167,7 +164,7 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] return None, EmptyOperator() if EventTypes.ERROR.value == event.type: - self.task.error += 1 + self.task.errors += 1 if self.task.errors > self._max_errors: # if reach max errors, fail the task return None, self.taskflow().fail("task failed too much, exceeds max errors") @@ -238,12 +235,13 @@ def messenger(self, stage: str = "") -> Messenger: def respond(self, messages: Iterable[MessageKind], stage: str = "") -> Tuple[List[Message], List[Caller]]: self._validate_alive() + messages = self._message_parser.parse(messages) with self._thread_locker: messenger = self.messenger(stage) messenger.send(messages) - messages, callers = messenger.flush() - self.thread.append(*messages) - return messages, callers + buffer, callers = messenger.flush() + self.thread.append(*buffer) + return buffer, callers def cancel_subtask(self, ghost: G, reason: str = "") -> None: self._validate_alive() @@ -323,7 +321,7 @@ def _update_state_changes(self) -> None: task = self.task task.meta = to_entity_meta(self.ghost) state_values = {} - for name, value in self.state: + for name, value in self.state.items(): state_values[name] = to_entity_meta(value) thread = self.thread # update system log diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index a4e18a3d..3ba71392 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -1,7 +1,7 @@ import time from typing import Union, Optional, Iterable, List, Tuple, TypeVar, Callable -from ghostos.contracts.logger import LoggerItf +from ghostos.contracts.logger import LoggerItf, get_ghostos_logger from ghostos.contracts.pool import Pool from ghostos.container import Container, Provider from ghostos.abcd import Shell, Conversation, Ghost, Scope, Background @@ -16,7 +16,6 @@ from ghostos.identifier import get_identifier from ghostos.entity import to_entity_meta from pydantic import BaseModel, Field -from threading import Thread from .conversation_impl import ConversationImpl, ConversationConf __all__ = ['ShellConf', 'ShellImpl', 'Shell'] @@ -64,7 +63,6 @@ def __init__( self._tasks = container.force_fetch(GoTasks) self._closed = False self._background_started = False - self._logger = container.force_fetch(LoggerItf) # bootstrap the container. # bind self self._container.set(Shell, self) @@ -72,6 +70,10 @@ def __init__( self._container.set(ShellConf, config) self._container.bootstrap() + @property + def logger(self) -> LoggerItf: + return get_ghostos_logger() + def container(self) -> Container: return self._container @@ -86,12 +88,12 @@ def send_event(self, event: Event) -> None: def sync(self, ghost: Ghost, context: Optional[Ghost.ContextType] = None) -> Conversation: driver = get_ghost_driver(ghost) task_id = driver.make_task_id(self._scope) - self._logger.debug("sync ghost with task id %s", task_id) + self.logger.debug("sync ghost with task id %s", task_id) task = self._tasks.get_task(task_id) if task is None: task = self.create_root_task(task_id, ghost, context) - self._logger.debug("create root task task id %s for ghost", task_id) + self.logger.debug("create root task task id %s for ghost", task_id) task.meta = to_entity_meta(ghost) if context is not None: @@ -266,7 +268,7 @@ def halt() -> int: if handled_event: continue except Exception as err: - self._logger.exception(err) + self.logger.exception(err) break idle() self.close() diff --git a/ghostos/framework/ghostos/taskflow_impl.py b/ghostos/framework/ghostos/taskflow_impl.py index 8b0d5675..5c965535 100644 --- a/ghostos/framework/ghostos/taskflow_impl.py +++ b/ghostos/framework/ghostos/taskflow_impl.py @@ -135,9 +135,6 @@ def __init__(self, messages: List[Message], instruction: str, sync: bool): self.sync: bool = sync def run(self, session: Session) -> Union[Operator, None]: - if len(self.messages) == 0 and not self.instruction: - return None - task = session.task event = EventTypes.ROTATE.new( task_id=task.task_id, diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index b631f61d..63864b90 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -7,7 +7,7 @@ from openai.types.chat.chat_completion_stream_options_param import ChatCompletionStreamOptionsParam from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam from openai.types.chat.chat_completion_chunk import ChatCompletionChunk -from ghostos.contracts.logger import LoggerItf +from ghostos.contracts.logger import LoggerItf, get_ghostos_logger from ghostos.helpers import timestamp from ghostos.core.messages import ( Message, OpenAIMessageParser, DefaultOpenAIMessageParser, @@ -75,14 +75,12 @@ def __init__( model_conf: ModelConf, parser: OpenAIMessageParser, storage: PromptStorage, - logger: LoggerItf, # deprecated: functional_token_prompt: Optional[str] = None, ): self._service = service_conf self._model = model_conf self._storage: PromptStorage = storage - self._logger = logger http_client = None if service_conf.proxy: transport = SyncProxyTransport.from_url(service_conf.proxy) @@ -120,13 +118,20 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion raise AttributeError("empty chat!!") try: prompt.run_start = timestamp() - self._logger.debug(f"start chat completion for prompt {prompt.id}") + get_ghostos_logger().info(f"start chat completion for prompt %s", prompt.id) + get_ghostos_logger().info(f"start chat completion messages %s", messages) + functions = prompt.get_openai_functions() + tools = prompt.get_openai_tools() + if self._model.use_tools: + functions = NOT_GIVEN + else: + tools = NOT_GIVEN return self._client.chat.completions.create( messages=messages, model=self._model.model, function_call=prompt.get_openai_function_call(), - functions=prompt.get_openai_functions(), - tools=prompt.get_openai_tools(), + functions=functions, + tools=tools, max_tokens=self._model.max_tokens, temperature=self._model.temperature, n=self._model.n, @@ -136,7 +141,7 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion **self._model.kwargs, ) finally: - self._logger.debug(f"end chat completion for prompt {prompt.id}") + get_ghostos_logger().debug(f"end chat completion for prompt {prompt.id}") prompt.run_end = timestamp() def chat_completion(self, prompt: Prompt) -> Message: @@ -163,6 +168,7 @@ def chat_completion_chunks(self, prompt: Prompt) -> Iterable[Message]: try: chunks: Iterable[ChatCompletionChunk] = self._chat_completion(prompt, stream=True) messages = self._parser.from_chat_completion_chunks(chunks) + get_ghostos_logger().debug("++++++++++++messages %s", messages) prompt_payload = PromptPayload.from_prompt(prompt) output = [] for chunk in messages: @@ -188,19 +194,18 @@ class OpenAIDriver(LLMDriver): adapter """ - def __init__(self, storage: PromptStorage, logger: LoggerItf, parser: Optional[OpenAIMessageParser] = None): + def __init__(self, storage: PromptStorage, parser: Optional[OpenAIMessageParser] = None): if parser is None: parser = DefaultOpenAIMessageParser(None, None) self._parser = parser self._storage = storage - self._logger = logger def driver_name(self) -> str: return OPENAI_DRIVER_NAME def new(self, service: ServiceConf, model: ModelConf) -> LLMApi: - self._logger.debug(f"new llm api %s at service %s", model.model, service.name) - return OpenAIAdapter(service, model, self._parser, self._storage, self._logger) + get_ghostos_logger().debug(f"new llm api %s at service %s", model.model, service.name) + return OpenAIAdapter(service, model, self._parser, self._storage) class LitellmAdapter(OpenAIAdapter): @@ -251,9 +256,8 @@ class OpenAIDriverBootstrapper(Bootstrapper): def bootstrap(self, container: Container) -> None: llms = container.force_fetch(LLMs) - logger = container.force_fetch(LoggerItf) storage = container.force_fetch(PromptStorage) - openai_driver = OpenAIDriver(storage, logger) - lite_llm_driver = LiteLLMDriver(storage, logger) + openai_driver = OpenAIDriver(storage) + lite_llm_driver = LiteLLMDriver(storage) llms.register_driver(openai_driver) llms.register_driver(lite_llm_driver) diff --git a/ghostos/framework/llms/providers.py b/ghostos/framework/llms/providers.py index fa14f281..730c2c3f 100644 --- a/ghostos/framework/llms/providers.py +++ b/ghostos/framework/llms/providers.py @@ -35,12 +35,11 @@ def factory(self, con: Container) -> Optional[LLMs]: configs = con.force_fetch(Configs) storage = con.force_fetch(PromptStorage) - logger = con.force_fetch(LoggerItf) parser = con.get(OpenAIMessageParser) conf = configs.get(LLMsYamlConfig) - openai_driver = OpenAIDriver(storage, logger, parser) - lite_llm_driver = LiteLLMDriver(storage, logger, parser) + openai_driver = OpenAIDriver(storage, parser) + lite_llm_driver = LiteLLMDriver(storage, parser) # register default drivers. llms = LLMsImpl(conf=conf, default_driver=openai_driver) diff --git a/ghostos/framework/logger/__init__.py b/ghostos/framework/logger/__init__.py index d5eb62e3..9776aa72 100644 --- a/ghostos/framework/logger/__init__.py +++ b/ghostos/framework/logger/__init__.py @@ -1,3 +1,3 @@ from ghostos.contracts.logger import LoggerItf -from ghostos.framework.logger.named import NamedLoggerProvider +from ghostos.framework.logger.named import DefaultLoggerProvider from ghostos.framework.logger.fake import FakeLogger diff --git a/ghostos/framework/logger/named.py b/ghostos/framework/logger/named.py index b88b471b..2361c9db 100644 --- a/ghostos/framework/logger/named.py +++ b/ghostos/framework/logger/named.py @@ -1,28 +1,22 @@ from typing import Optional, Type from ghostos.container import Provider, Container -from ghostos.contracts.logger import LoggerItf, LoggerAdapter +from ghostos.contracts.logger import LoggerItf, LoggerAdapter, get_ghostos_logger from ghostos.contracts.workspace import Workspace from os.path import join import logging from logging.handlers import RotatingFileHandler -__all__ = ['NamedLoggerProvider'] +__all__ = ['DefaultLoggerProvider'] -class NamedLoggerProvider(Provider[LoggerItf]): +class DefaultLoggerProvider(Provider[LoggerItf]): """ basic logger """ - def __init__( - self, - logger_name: str = "ghostos", - ): - self.logger_name = logger_name - def singleton(self) -> bool: - return True + return False def contract(self) -> Type[LoggerItf]: return LoggerItf @@ -30,7 +24,7 @@ def contract(self) -> Type[LoggerItf]: def factory(self, con: Container) -> Optional[LoggerItf]: logging.captureWarnings(True) ws = con.force_fetch(Workspace) - logger = logging.getLogger(self.logger_name) + logger = get_ghostos_logger() if not logger.hasHandlers(): path = ws.runtime().abspath() logfile = join(path, "logs/ghostos.log") diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index d5c34886..ea99f355 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -1,7 +1,7 @@ from typing import Optional, Iterable, List, Tuple from ghostos.abcd.concepts import Messenger from ghostos.core.messages import ( - Message, Payload, Role, + Message, Payload, Role, MessageType, Stream, Caller, ) from ghostos.core.messages.pipeline import SequencePipe @@ -43,7 +43,13 @@ def flush(self) -> Tuple[List[Message], List[Caller]]: for msg_id in message_ids: message = self._sent_messages[msg_id] messages.append(message) - if message.callers: + if message.type == MessageType.FUNCTION_CALL: + callers.append(Caller( + id=message.ref_id, + name=message.name, + arguments=message.content, + )) + elif message.callers: callers.extend(message.callers) self.destroy() return messages, callers @@ -74,8 +80,10 @@ def buffer(self, messages: Iterable[Message]) -> Iterable[Message]: for item in messages: # add message info if item.is_complete() or item.is_head(): - item.name = self._assistant_name - item.stage = self._stage + if not item.name: + item.name = self._assistant_name + if not item.stage: + item.stage = self._stage if not item.role: item.role = self._role # create buffer in case upstream is cancel diff --git a/ghostos/ghosts/moss_agent/__init__.py b/ghostos/ghosts/moss_agent/__init__.py index e69de29b..8787f0c7 100644 --- a/ghostos/ghosts/moss_agent/__init__.py +++ b/ghostos/ghosts/moss_agent/__init__.py @@ -0,0 +1,36 @@ +from ghostos.ghosts.moss_agent.agent import MossAgent, MossAgentDriver, MossAction + + +def new_moss_agent( + modulename: str, + *, + name: str = None, + description: str = None, + persona: str = None, + instruction: str = None, + llm_api: str = "", +) -> MossAgent: + if persona is None: + persona = f""" +You are an Agent created from python module file. +Your goal is helping user to: +- understand the python code. +- interact with the python code that provided to you. +""" + if instruction is None: + instruction = """ +- you are kind, helpful agent. +- you are master of python coding. +""" + if name is None: + name = modulename + if description is None: + description = f"default moss agent built from python module `{modulename}`." + return MossAgent( + moss_module=modulename, + persona=persona, + instruction=instruction, + name=name, + description=description, + llm_api=llm_api, + ) diff --git a/ghostos/ghosts/moss_agent/moss_agent.py b/ghostos/ghosts/moss_agent/agent.py similarity index 50% rename from ghostos/ghosts/moss_agent/moss_agent.py rename to ghostos/ghosts/moss_agent/agent.py index 399ac400..4fdecfb1 100644 --- a/ghostos/ghosts/moss_agent/moss_agent.py +++ b/ghostos/ghosts/moss_agent/agent.py @@ -1,4 +1,4 @@ -from typing import Union, Optional, Dict, Any, TypeVar, List, Self, Iterable +from typing import Union, Optional, Dict, List, Self, Iterable, Tuple, ClassVar from types import ModuleType from ghostos.identifier import Identifier @@ -6,38 +6,45 @@ from ghostos.helpers import import_from_path from ghostos.prompter import TextPrmt, Prompter -from ghostos.abcd import GhostDriver, Operator, Agent, Session, StateValue, Action +from ghostos.abcd import GhostDriver, Operator, Agent, Session, StateValue, Action, Thought from ghostos.core.runtime import Event, GoThreadInfo -from ghostos.core.moss import MossCompiler, PyContext, Moss, MossRuntime +from ghostos.core.moss import MossCompiler, PyContext, MossRuntime +from ghostos.entity import ModelEntity from ghostos.core.messages import Message, Caller, Role from ghostos.core.llms import ( - LLMs, LLMApi, Prompt, PromptPipe, AssistantNamePipe, run_prompt_pipeline, LLMFunc, ) from .instructions import ( - GHOSTOS_INTRODUCTION, MOSS_INTRODUCTION, AGENT_INTRODUCTION, MOSS_FUNCTION_DESC, + GHOSTOS_INTRODUCTION, MOSS_INTRODUCTION, AGENT_META_INTRODUCTION, MOSS_FUNCTION_DESC, get_moss_context_prompter, get_agent_identity, ) import json +from ghostos.container import Provider -class MossAgent(BaseModel, Agent): +__all__ = ['MossAgent', 'MossAgentDriver', 'MossAction'] + + +class MossAgent(ModelEntity, Agent): """ Basic Agent that turn a python module into a conversational agent. """ - ArtifactType = None """ subclass of MossAgent could have a GoalType, default is None""" moss_module: str = Field(description="Moss module name for the agent") + persona: str = Field(description="Persona for the agent, if not given, use global persona") instruction: str = Field(description="The instruction that the agent should follow") - persona: str = Field(default="", description="Persona for the agent, if not given, use global persona") + # optional configs name: str = Field(default="", description="name of the agent") description: str = Field(default="", description="description of the agent") + code: Optional[str] = Field(default=None, description="code override the module") compile_module: Optional[str] = Field(None, description="Compile module name for the agent") - llmapi_name: str = Field(default="", description="name of the llm api, if none, use default one") + llm_api: str = Field(default="", description="name of the llm api, if none, use default one") + truncate_at_turns: int = Field(default=30, description="when history turns reach the point, truncate") + truncate_to_turns: int = Field(default=20, description="when truncate the history, left turns") def __identifier__(self) -> Identifier: name = self.name if self.name else self.moss_module @@ -48,137 +55,183 @@ def __identifier__(self) -> Identifier: ) -A = TypeVar("A", bound=MossAgent) - - -# --- lifecycle methods of moss agent --- # - - -def __agent_goal__(agent: MossAgent, moss: Moss): - """ - get the agent goal, default is None - """ - return None - - -def __agent_contextual_prompt__(agent: A, moss: Moss) -> str: - """ - magic function that defined in the moss module, generate contextual prompt for the agent - """ - return "" - - -__agent__: Optional[MossAgent] = None -""" magic attr that predefine an agent of the module with given persona and instruction.""" - - -def __agent_moss_injections__(agent: A, session: Session[A]) -> Dict[str, Any]: - """ - manually define some of the injections to the Moss Class. - if a property of Moss is not injected here, the session container will inject it by typehint. - :param agent: - :param session: - """ - return { - } - - -def __agent_on_event_type__(agent: A, session: Session[A], moss: Moss): - pass - - class MossAgentDriver(GhostDriver[MossAgent]): def get_module(self) -> ModuleType: m = import_from_path(self.ghost.moss_module) return m - def get_artifact(self, session: Session) -> Optional[MossAgent.GoalType]: + def providers(self) -> Iterable[Provider]: + """ + ghost session level providers + """ + from ghostos.ghosts.moss_agent.for_developer import __moss_agent_providers__ + m = self.get_module() + fn = __moss_agent_providers__ + if __moss_agent_providers__.__name__ in m.__dict__: + fn = m.__dict__[__moss_agent_providers__.__name__] + return fn(self.ghost) + + def parse_event(self, session: Session, event: Event) -> Union[Event, None]: + """ + parse the event before handle it. if return None, the event is ignored. + :param session: + :param event: + :return: + """ + from ghostos.ghosts.moss_agent.for_developer import __moss_agent_parse_event__ + fn = __moss_agent_parse_event__ + if __moss_agent_parse_event__.__name__ in event.__dict__: + fn = event.__dict__[__moss_agent_parse_event__.__name__] + return fn(self.ghost, session, event) + + def truncate(self, session: Session) -> GoThreadInfo: + from ghostos.ghosts.moss_agent.for_developer import __moss_agent_truncate__ + fn = __moss_agent_truncate__ + if __moss_agent_truncate__.__name__ in session.__dict__: + fn = session.__dict__[__moss_agent_truncate__.__name__] + return fn(self.ghost, session) + + def get_artifact(self, session: Session) -> Optional[MossAgent.ArtifactType]: + from .for_meta_ai import __moss_agent_artifact__ m = self.get_module() - if __agent_goal__.__name__ not in m.__dict__: + if __moss_agent_artifact__.__name__ not in m.__dict__: return None - fn = getattr(m, __agent_goal__.__name__) - compiler = self.get_compiler(session) + fn = getattr(m, __moss_agent_artifact__.__name__) + compiler = self._get_moss_compiler(session) with compiler: runtime = compiler.compile(self.ghost.moss_module) with runtime: moss = runtime.moss() return fn(self.ghost, moss) - def on_event(self, session: Session, event: Event) -> Union[Operator, None]: + def thought(self, session: Session, runtime: MossRuntime) -> Thought: + from .for_meta_ai import __moss_agent_thought__ as fn + compiled = runtime.module() + if fn.__name__ in compiled.__dict__: + fn = compiled.__dict__[fn.__name__] + return fn(self.ghost, runtime.moss(), *self.get_actions(session, runtime)) - thread = self.update_with_event(session, event) + def get_actions(self, session: Session, runtime: MossRuntime) -> Iterable[Action]: + """ + get moss agent's actions. default is moss action. + """ + from .for_meta_ai import __moss_agent_actions__ as fn + compiled = runtime.module() + if fn.__name__ in compiled.__dict__: + fn = compiled.__dict__[fn.__name__] + yield from fn(self.ghost, runtime.moss()) + # moss action at last + moss_action = MossAction(runtime) + yield moss_action + + def on_event(self, session: Session, event: Event) -> Union[Operator, None]: - compiler = self.get_compiler(session) + compiler = self._get_moss_compiler(session) with compiler: rtm = compiler.compile(self.ghost.compile_module) with rtm: # prepare instructions. - instructions = self.get_instructions(session, rtm) + op, ok = self._on_custom_event_handler(session, rtm, event) + if ok: + return op or session.taskflow().wait() + + # prepare thread + thread = session.thread + thread.new_turn(event) + + instructions = self._get_instructions(session, rtm) # prepare prompt prompt = thread.to_prompt(instructions) - pipes = self.get_prompt_pipes(session, rtm) + pipes = self._get_prompt_pipes(session, rtm) prompt = run_prompt_pipeline(prompt, pipes) # prepare actions - actions = self.get_actions(session, rtm) - for action in actions: - # update prompt with action - if isinstance(action, PromptPipe): - prompt = action.update_prompt(prompt) - - # call llm - llm = self.get_llmapi(session) - messages = llm.deliver_chat_completion(prompt, not session.upstream.completes_only()) - messages, callers = session.respond(messages, remember=True) - - # handle actions - for caller in callers: - if caller.name in actions: - action = actions[caller.name] - op = action.run(session, caller) - if op is not None: - return op - return session.flow().wait() - - def get_instructions(self, session: Session, moss_rtm: MossRuntime) -> List[Message]: + thought = self.thought(session, rtm) + prompt, op = thought.think(session, prompt) + if op is not None: + return op + return session.taskflow().wait() + + def _on_custom_event_handler( + self, + session: Session, + runtime: MossRuntime, + event: Event, + ) -> Tuple[Optional[Event], bool]: + method_name = "__moss_agent_on_" + event.type + "__" + compiled = runtime.module() + if method_name in compiled.__dict__: + fn = compiled.__dict__[method_name] + return fn(self.ghost, session, runtime, event), True + return None, False + + def _get_instructions(self, session: Session, runtime: MossRuntime) -> List[Message]: """ generate moss agent's instruction :param session: - :param moss_rtm: + :param runtime: :return: """ - prompter = self.get_instruction_prompter(session, moss_rtm) + prompter = self._get_instruction_prompter(session, runtime) instruction = prompter.get_prompt(session.container, depth=0) return [Role.SYSTEM.new(content=instruction)] - def get_instruction_prompter(self, session: Session, runtime: MossRuntime) -> Prompter: - prompter = MossAgentPrompter.new( - self.ghost, - runtime, + def _get_instruction_prompter(self, session: Session, runtime: MossRuntime) -> Prompter: + agent = self.ghost + return TextPrmt().with_children( + # system meta prompt + TextPrmt( + title="Meta Instruction", + content=AGENT_META_INTRODUCTION, + ).with_children( + TextPrmt(title="GhostOS", content=GHOSTOS_INTRODUCTION), + TextPrmt(title="MOSS", content=MOSS_INTRODUCTION), + # code context + get_moss_context_prompter("Code Context", runtime), + ), + # agent prompt + TextPrmt( + title="Agent Info", + content="The Agent info about who you are and what you are doing: ", + ).with_children( + get_agent_identity("Identity", agent.__identifier__()), + TextPrmt(title="Persona", content=self._get_agent_persona(session, runtime)), + TextPrmt(title="Instruction", content=self._get_agent_instruction(session, runtime)), + ), + TextPrmt( + title="Context", + content="", + ).with_children( + self._get_context_prompter(session), + ) ) - return prompter - def get_actions(self, session: Session, runtime: MossRuntime) -> Dict[str, Action]: - """ - get moss agent's actions. default is moss action. - """ - moss_action = MossAction(runtime) - return {moss_action.name(): moss_action} + def _get_agent_persona(self, session: Session, runtime: MossRuntime) -> str: + from .for_meta_ai import __moss_agent_persona__ as fn + compiled = runtime.module() + if fn.__name__ in compiled.__dict__: + fn = compiled.__dict__[fn.__name__] + return fn(self.ghost, runtime.moss()) + + def _get_agent_instruction(self, session: Session, runtime: MossRuntime) -> str: + from .for_meta_ai import __moss_agent_instruction__ as fn + compiled = runtime.module() + if fn.__name__ in compiled.__dict__: + fn = compiled.__dict__[fn.__name__] + return fn(self.ghost, runtime.moss()) + + def _get_context_prompter(self, session: Session) -> Optional[Prompter]: + ctx = session.get_context() + if ctx is None: + return None + return ctx - def get_prompt_pipes(self, session: Session, runtime: MossRuntime) -> Iterable[PromptPipe]: + def _get_prompt_pipes(self, session: Session, runtime: MossRuntime) -> Iterable[PromptPipe]: yield AssistantNamePipe(self.ghost.name) - def get_llmapi(self, session: Session) -> LLMApi: - llms = session.container.force_fetch(LLMs) - llmapi_name = self.ghost.llmapi_name - return llms.get_api(llmapi_name) - - def update_with_event(self, session: Session, event: Event) -> GoThreadInfo: - session.thread.new_turn(event) - return session.thread - - def get_compiler(self, session: Session) -> MossCompiler: + def _get_moss_compiler(self, session: Session) -> MossCompiler: + from ghostos.ghosts.moss_agent.for_developer import __moss_agent_injections__ pycontext = self.get_pycontext(session) compiler = session.container.force_fetch(MossCompiler) @@ -189,12 +242,12 @@ def get_compiler(self, session: Session) -> MossCompiler: compiler.bind(type(self.ghost), self.ghost) # bind agent level injections. - injection_fn = __agent_moss_injections__ + fn = __moss_agent_injections__ module = self.get_module() # if magic function __agent_moss_injections__ exists, use it to get some instance level injections to moss. - if __agent_moss_injections__.__name__ in module.__dict__: - injection_fn = getattr(module, __agent_moss_injections__.__name__) - injections = injection_fn(self.ghost, session) + if __moss_agent_injections__.__name__ in module.__dict__: + fn = module.__dict__[__moss_agent_injections__.__name__] + injections = fn(self.ghost, session) if injections: compiler = compiler.injects(**injections) return compiler @@ -207,36 +260,15 @@ def get_pycontext(self, session: Session) -> PyContext: """ pycontext = SessionPyContext( module=self.ghost.moss_module, + code=self.ghost.code, ) return pycontext.get_or_bind(session) -class MossAgentPrompter(TextPrmt): - - @classmethod - def new(cls, agent: MossAgent, runtime: MossRuntime) -> Self: - children = [ - # system meta prompt - TextPrmt(title="Meta Instruction", content=AGENT_INTRODUCTION).with_children( - TextPrmt(title="GhostOS", content=GHOSTOS_INTRODUCTION), - TextPrmt(title="MOSS", content=MOSS_INTRODUCTION), - # code context - get_moss_context_prompter("Code Context", runtime), - ), - # agent prompt - TextPrmt( - title="Agent Info", - content="The Agent info about who you are and what you are doing: ", - ).with_children( - get_agent_identity("Identity", agent.__identifier__()), - TextPrmt(title="Persona", content=agent.persona), - TextPrmt(title="Instructions", content=agent.instruction), - ), - ] - return cls().with_children(*children) - - class SessionPyContext(PyContext, StateValue): + """ + bind pycontext to session.state + """ def get(self, session: Session) -> Optional[Self]: data = session.state.get(SessionPyContext.__name__, None) @@ -253,6 +285,7 @@ def bind(self, session: Session) -> None: class MossAction(Action, PromptPipe): class Argument(BaseModel): + name: ClassVar[str] = "moss" code: str = Field( description="generated moss code", ) @@ -261,7 +294,7 @@ def __init__(self, runtime: MossRuntime): self.runtime: MossRuntime = runtime def name(self) -> str: - return "moss" + return self.Argument.name def update_prompt(self, prompt: Prompt) -> Prompt: parameters = self.Argument.model_json_schema() @@ -283,6 +316,7 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: # if code is not exists, inform the llm if not code: return self.fire_error(session, caller, "the moss code is empty") + session.logger.info("moss action code: %s", code) error = self.runtime.lint_exec_code(code) if error: @@ -290,7 +324,7 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: moss = self.runtime.moss() try: - result = self.runtime.execute(target="main", args=[moss]) + result = self.runtime.execute(target="main", code=code, args=[moss]) op = result.returns if op is not None and not isinstance(op, Operator): return self.fire_error(session, caller, "result of moss code is not None or Operator") @@ -301,14 +335,14 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: # handle std output std_output = result.std_output + session.logger.info("moss action std_output: %s", std_output) if std_output: output = f"Moss output:\n{std_output}" message = caller.new_output(output) + session.respond([message]) if op is None: # if std output is not empty, and op is none, observe the output as default. - return session.taskflow().think(message) - else: - session.respond([message], remember=True) + return session.taskflow().think() return op except Exception as e: @@ -317,4 +351,5 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: @staticmethod def fire_error(session: Session, caller: Caller, error: str) -> Operator: message = caller.new_output(error) - return session.taskflow().error(message) + session.respond([message]) + return session.taskflow().error() diff --git a/ghostos/ghosts/moss_agent/for_developer.py b/ghostos/ghosts/moss_agent/for_developer.py new file mode 100644 index 00000000..587477f1 --- /dev/null +++ b/ghostos/ghosts/moss_agent/for_developer.py @@ -0,0 +1,85 @@ +from typing import Optional, TypeVar, Dict, Any, Iterable +from .agent import MossAgent +from ghostos.core.moss import Moss, MossRuntime +from ghostos.abcd.concepts import Session, Operator +from ghostos.core.runtime import GoThreadInfo, Event +from ghostos.container import Provider + +A = TypeVar("A") + + +# +# lifecycle methods for developer. +# agent need not know details about these methods, but also ok. +# hide these methods because too much ghostos designing patterns are needed. + +def __moss_agent_providers__(agent: A) -> Iterable[Provider]: + """ + return session level providers that special required to the Agent. + :param agent: the moss agent instance. + :return: providers that register to the session container. + """ + return [] + + +def __moss_agent_truncate__(agent: MossAgent, session: Session) -> GoThreadInfo: + """ + default truncate logic of the agent + :param agent: + :param session: + :return: + """ + from ghostos.abcd.thoughts import SummaryThought + from ghostos.core.llms import Prompt + + thread = session.thread + turns = thread.get_history_turns(True) + # do the truncate + if len(turns) > agent.truncate_at_turns: + truncated = agent.truncate_at_turns - agent.truncate_to_turns + if truncated <= 0: + return thread + turns = turns[:truncated] + target = turns[truncated] + messages = [] + for turn in turns: + messages.extend(turn.messages(False)) + prompt = Prompt(history=messages) + _, summary = SummaryThought(llm_api=agent.llm_api).think(session, prompt) + if summary: + target.summary = summary + return session.thread + + +def __moss_agent_parse_event__(agent: MossAgent, session: Session, event: Event) -> Optional[Event]: + return event + + +def __moss_agent_injections__(agent: A, session: Session[A]) -> Dict[str, Any]: + """ + manually define some of the injections to the Moss Class. + if a property of Moss is not injected here, the session container will inject it by typehint. + :param agent: + :param session: + """ + return { + } + + +def __moss_agent_on_event_type__( + agent: MossAgent, + session: Session[A], + runtime: MossRuntime, + event: Event, +) -> Optional[Operator]: + """ + define customized event handler + :param agent: + :param session: + :param runtime: + :param event: + :return: + """ + pass + +# diff --git a/ghostos/ghosts/moss_agent/for_meta_ai.py b/ghostos/ghosts/moss_agent/for_meta_ai.py new file mode 100644 index 00000000..9a12aedb --- /dev/null +++ b/ghostos/ghosts/moss_agent/for_meta_ai.py @@ -0,0 +1,39 @@ +from typing import Optional, TypeVar, Dict, Any, Iterable +from .agent import MossAgent +from ghostos.core.moss import Moss +from ghostos.abcd import Session, Action, Thought, LLMThought +from ghostos.prompter import Prompter +from ghostos.core.runtime import GoThreadInfo +from ghostos.container import Provider, Container + +A = TypeVar("A") + + +# --- lifecycle methods for meta agent --- # + + +def __moss_agent_artifact__(agent: MossAgent, moss: Moss): + """ + get the agent goal, default is None + """ + return None + + +def __moss_agent_actions__(agent: MossAgent, moss: Moss) -> Iterable[Action]: + yield from [] + + +def __moss_agent_persona__(agent: MossAgent, moss: Moss) -> str: + return agent.persona + + +def __moss_agent_instruction__(agent: MossAgent, moss: Moss) -> str: + return agent.instruction + + +def __moss_agent_thought__(agent: MossAgent, moss: Moss, *actions: Action) -> Thought: + return LLMThought( + llm_api=agent.llm_api, + actions=actions, + message_stage="", + ) diff --git a/ghostos/ghosts/moss_agent/instructions.py b/ghostos/ghosts/moss_agent/instructions.py index 5a2d5b71..2e20ed2e 100644 --- a/ghostos/ghosts/moss_agent/instructions.py +++ b/ghostos/ghosts/moss_agent/instructions.py @@ -2,7 +2,7 @@ from ghostos.prompter import Prompter, TextPrmt from ghostos.identifier import Identifier -AGENT_INTRODUCTION = """ +AGENT_META_INTRODUCTION = """ You are the mind of an AI Agent driven by `GhostOS` framework. Here are some basic information you might expect: """ @@ -26,13 +26,14 @@ def main(moss: Moss): \""" :param moss: instance of the class `Moss`, the properties on it will be injected with runtime implementations. - :return: Union[Operator, None], if None, the outer system will perform default action. + :return: Optional[Operator] + if return None, the outer system will perform default action, or observe the values you printed. Otherwise, the outer system will execute the operator. - You shall only return operator by the libraries provided by `moss`. + You shall only return operator by the libraries provided on `moss`. \""" ``` - -* the outer system will execute the main function to realize your will. +* the outer system will execute the main function in the python module provided to you. you shall not import the module. +* the imported functions are only shown with signature, the source code is omitted. * if the python code context can not fulfill your will, do not use the `moss` tool. * you can reply as usual without calling the tool `moss`. use it only when you know what you're doing. * the code you generated executed only once and do not add to the python context. @@ -54,9 +55,11 @@ def main(moss: Moss): def get_moss_context_prompter(title: str, runtime: MossRuntime) -> Prompter: code_context = runtime.prompter().dump_code_context() + injections = runtime.moss_injections() children = [] container = runtime.container() + for name, injection in injections.items(): if isinstance(injection, Prompter): prompter = TextPrmt( @@ -64,20 +67,30 @@ def get_moss_context_prompter(title: str, runtime: MossRuntime) -> Prompter: content=injection.self_prompt(container), ) children.append(prompter) + end = "more information about attributes on `moss`:" if children else "" + content = f""" +The module provided to you are `{runtime.module().__name__}`. +The code are: +```python +{code_context} +``` + +{end} +""" return TextPrmt( title=title, - content=code_context, + content=content, ).with_children(*children) def get_agent_identity(title: str, id_: Identifier) -> Prompter: + from ghostos.helpers import yaml_pretty_dump + value = id_.model_dump(exclude_defaults=True) return TextPrmt( title=title, content=f""" -`name`: -{id_.name} - -`description`: -{id_.description} +```yaml +{yaml_pretty_dump(value)} +``` """ ) diff --git a/ghostos/prompter.py b/ghostos/prompter.py index 6e97ed69..56802c38 100644 --- a/ghostos/prompter.py +++ b/ghostos/prompter.py @@ -91,7 +91,10 @@ class Prompter(ABC): def with_children(self, *children: Prompter) -> Self: children = list(children) if len(children) > 0: - self.add_child(*children) + for child in children: + if child is None: + continue + self.add_child(child) return self def add_child(self, *prompters: Prompter) -> Self: @@ -167,6 +170,8 @@ def flatten(self, index: str = "") -> Dict[str, Self]: result = {index: self} idx = 0 for child in self.__children__: + if not child: + continue sub_index = index + "." + str(idx) sub_flatten = child.flatten(sub_index) for key in sub_flatten: diff --git a/ghostos/prototypes/aifunc/__init__.py b/ghostos/prototypes/aifunc/__init__.py deleted file mode 100644 index 5635de75..00000000 --- a/ghostos/prototypes/aifunc/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from ghostos.prototypes.aifunc.app import run_aifunc -from ghostos.core.aifunc import AIFunc, AIFuncResult -from os.path import dirname - -__all__ = ["run_aifunc", "quick_run_aifunc"] - - -def quick_run_aifunc( - aifunc: AIFunc, - current_path: str, - dirname_times: int = 0, - debug: bool = True, -) -> AIFuncResult: - """ - create aifunc runtime with default paths and run it - """ - root_dir = current_path - for i in range(dirname_times): - root_dir = dirname(root_dir) - return run_aifunc(root_dir, aifunc, debug=debug) diff --git a/ghostos/prototypes/aifunc/app.py b/ghostos/prototypes/aifunc/app.py deleted file mode 100644 index 7b7ed7a8..00000000 --- a/ghostos/prototypes/aifunc/app.py +++ /dev/null @@ -1,110 +0,0 @@ -import argparse -import sys -import os -import yaml -from typing import List, Dict - -from ghostos.core.runtime import GoThreadInfo -from logging.config import dictConfig -from ghostos.core.llms import Prompt -from ghostos.core.messages import Message -from ghostos.core.moss import moss_container -from ghostos.core.aifunc import ( - DefaultAIFuncExecutorImpl, AIFunc, DefaultAIFuncDriverImpl, AIFuncExecutor, - AIFuncResult, -) -from ghostos.framework.logger import NamedLoggerProvider -from ghostos.framework.storage import FileStorageProvider -from ghostos.framework.llms import ConfigBasedLLMsProvider -from ghostos.framework.threads import MsgThreadRepoByStorageProvider -from ghostos.framework.configs import ConfigsByStorageProvider -from rich.console import Console -from rich.panel import Panel -from rich.markdown import Markdown -from rich.prompt import Prompt - -__all__ = ['run_aifunc'] - -console = Console() - - -def init_logger(conf_path: str): - with open(conf_path) as f: - content = f.read() - data = yaml.safe_load(content) - dictConfig(data) - - -def run_aifunc( - root_dir: str, - aifunc: AIFunc, - logger_conf_path: str = "configs/logging.yml", - logger_name: str = "debug", - threads_path: str = "runtime/threads", - configs_path: str = "configs", - llm_conf_path: str = "llms_conf.yml", - llm_api_name: str = "", - debug: bool = True, -) -> AIFuncResult: - # prepare logger - absolute_logger_conf = os.path.join(root_dir, logger_conf_path) - init_logger(absolute_logger_conf) - - # prepare container - container = moss_container() - container.register(FileStorageProvider(root_dir)) - container.register(NamedLoggerProvider(logger_name=logger_name)) - container.register(MsgThreadRepoByStorageProvider(threads_dir=threads_path)) - container.register(ConfigsByStorageProvider(configs_path)) - container.register(ConfigBasedLLMsProvider(llm_conf_path)) - - class TestDriverImpl(DefaultAIFuncDriverImpl): - console = console - - def on_message(self, message: Message) -> None: - self.console.print( - Panel( - Markdown(message.get_content()), - title=f"generated message ({self.name()})", - ) - ) - if debug: - value = Prompt.ask("Continue?", choices=["y", "n"], default="y") - if value != "y": - exit(0) - - def on_chat(self, chat: Prompt) -> None: - for message in chat.get_messages(): - self.console.print(Panel( - Markdown(message.get_content()), - title=f"chat_info ({self.name()})", - )) - if debug: - value = Prompt.ask("Continue?", choices=["y", "n"], default="y") - if value != "y": - exit(0) - - def on_system_messages(self, messages: List[Message]) -> None: - pass - - def on_save(self, manager: AIFuncExecutor, thread: GoThreadInfo) -> None: - current = thread.current - if current: - for message in current.messages(): - self.console.print( - Panel( - Markdown(message.get_content()), - title="thread new round message", - ) - ) - super().on_save(manager, thread) - - manager = DefaultAIFuncExecutorImpl( - container=container, - llm_api_name=llm_api_name, - default_driver=TestDriverImpl, - ) - try: - return manager.execute(aifunc) - finally: - manager.destroy() diff --git a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py index 0d432e99..c1e94341 100644 --- a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py +++ b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py @@ -2,7 +2,7 @@ from ghostos.core.messages import Message from ghostos.core.runtime import Event -from ghostos.contracts.logger import get_console_logger +from ghostos.contracts.logger import get_ghostos_logger from ghostos.helpers import create_and_bind_module from ghostos.scripts.cli.run_streamlit_ghost import RunGhostChatApp from ghostos.bootstrap import get_ghostos, get_container @@ -17,7 +17,7 @@ if len(sys.argv) < 2: raise SystemExit(f"invalid RunAIFuncApp arguments") -logger = get_console_logger(debug=True) +logger = get_ghostos_logger() class StreamlitBackgroundApp(Background): @@ -54,7 +54,11 @@ def bootstrap(): logger.debug(f"generate ghostos app container at workspace {app_arg.workspace_dir}") # bound route. - page_route = GhostChatRoute(ghost_meta=app_arg.ghost_meta) + page_route = GhostChatRoute( + ghost_meta=app_arg.ghost_meta, + context_meta=app_arg.context_meta, + filename=app_arg.filename, + ) page_route = page_route.get_or_bind(st.session_state) # initialize router and set aifunc is default router = default_router().with_current(page_route) diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py similarity index 72% rename from ghostos/prototypes/streamlitapp/pages/ghosts/chat.py rename to ghostos/prototypes/streamlitapp/pages/ghosts.py index 88658d35..e72e4892 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts/chat.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -3,7 +3,7 @@ import streamlit_react_jsonschema as srj from typing import Iterable, List from ghostos.prototypes.streamlitapp.pages.router import ( - GhostChatRoute, + GhostChatRoute, GhostTaskRoute, GhostSettingsRoute, ) from ghostos.prototypes.streamlitapp.utils.session import Singleton from ghostos.prototypes.streamlitapp.widgets.messages import ( @@ -19,15 +19,17 @@ from streamlit.logger import get_logger from ghostos.abcd import Shell, Conversation, Context from ghostos.identifier import get_identifier +from ghostos.entity import to_entity_meta from ghostos.helpers import gettext as _ from ghostos.helpers import generate_import_path, yaml_pretty_dump +from ghostos.scripts.cli.utils import GhostsConf, GhostInfo from pydantic import BaseModel import inspect logger = get_logger("ghostos") -def main(): +def main_chat(): # create shell route = GhostChatRoute.get(st.session_state) if route is None: @@ -35,32 +37,13 @@ def main(): return # get ghost and context - ghost = route.get_ghost() - context = route.get_context() - ghost = route.get_route_bound(ghost) - context = route.get_route_bound(context) - - conversation = Singleton.get(Conversation, st.session_state, force=False) - if not conversation: - shell = Singleton.get(Shell, st.session_state) - # create conversation - conversation = shell.sync(ghost, context) - Singleton(conversation, Conversation).bind(st.session_state) + conversation = get_conversation(route) # run the pages - run_chat_page(route, conversation) - route.get_or_bind(st.session_state) - - -def run_chat_page(route: GhostChatRoute, conversation: Conversation): - pic = None with st.sidebar: # other pages - if st.button(_("Ghost Settings"), use_container_width=True): - render_ghost_settings(route) - if st.button(_("Context"), use_container_width=True): - render_context_settings(conversation) - if st.button("Task Info", use_container_width=True): - render_task_info_settings(conversation.task()) + with st.container(border=True): + GhostSettingsRoute().render_page_link(use_container_width=True) + GhostTaskRoute().render_page_link(use_container_width=True) if st.button("Clear Messages", use_container_width=True): thread = conversation.thread() thread = thread.reset_history([]) @@ -111,6 +94,44 @@ def run_chat_page(route: GhostChatRoute, conversation: Conversation): chatting(route, conversation, inputs, auto_run) +def get_conversation(route: GhostChatRoute) -> Conversation: + conversation = Singleton.get(Conversation, st.session_state, force=False) + if not conversation: + shell = Singleton.get(Shell, st.session_state) + # create conversation + conversation = shell.sync(route.get_ghost(), route.get_context()) + Singleton(conversation, Conversation).bind(st.session_state) + return conversation + + +def main_settings(): + route = GhostChatRoute.get(st.session_state) + with st.sidebar: + # other pages + with st.container(border=True): + route.render_page_link(use_container_width=True) + GhostTaskRoute().render_page_link(use_container_width=True) + st.title("Ghost Settings") + conversation = get_conversation(route) + render_ghost_settings(route) + render_instruction(conversation) + render_context_settings(conversation) + + +def main_task(): + route = GhostChatRoute.get(st.session_state) + with st.sidebar: + # other pages + with st.container(border=True): + route.render_page_link(use_container_width=True) + GhostSettingsRoute().render_page_link(use_container_width=True) + conversation = get_conversation(route) + task = conversation.task() + thread = conversation.thread() + st.title("Ghost Task Info") + render_task_info_settings(task, thread) + + def chatting(route: GhostChatRoute, conversation: Conversation, inputs: List[Message], rotate: bool): thread = conversation.thread() render_thread_messages(thread, max_turn=20) @@ -153,6 +174,12 @@ def render_receiver(receiver: Receiver, debug: bool): contents = chunks_to_st_stream(buffer.chunks()) st.write_stream(contents) render_message_payloads(buffer.tail(), debug) + elif MessageType.FUNCTION_CALL.match(buffer.head()): + if debug: + contents = chunks_to_st_stream(buffer.chunks()) + with st.container(border=True): + st.write_stream(contents) + render_message_payloads(buffer.tail(), debug) else: render_message_in_content(buffer.tail(), debug) # render next item @@ -167,7 +194,6 @@ def chunks_to_st_stream(chunks: Iterable[Message]) -> Iterable[str]: yield chunk.content -@st.dialog(_("Ghost Settings"), width="large") def render_ghost_settings(route: GhostChatRoute): if route is None: st.error("page is not configured") @@ -175,9 +201,17 @@ def render_ghost_settings(route: GhostChatRoute): ghost = route.get_ghost() # render ghost info if isinstance(ghost, BaseModel): - data, mod = srj.pydantic_instance_form(ghost) - if st.button("Save"): - st.write("todo saving ghosts") + data, submitted = srj.pydantic_instance_form(ghost) + st.write("debug") + st.write(submitted) + if route.filename and submitted: + ghosts_conf = GhostsConf.load_from(route.filename) + key = ghosts_conf.file_ghost_key(route.filename) + info = GhostInfo(ghost=to_entity_meta(data)) + ghosts_conf.ghosts[key] = info + ghosts_conf.save(route.filename) + st.write("saved") + else: st.write(ghost) source = inspect.getsource(ghost.__class__) @@ -186,8 +220,13 @@ def render_ghost_settings(route: GhostChatRoute): render_empty() -@st.dialog("Ghost Context", width="large") +def render_instruction(conversation: Conversation): + st.subheader("Instructions") + conversation.get_ghost_driver() + + def render_context_settings(conversation: Conversation): + st.subheader("Context") ctx = conversation.get_context() ghost = conversation.get_ghost() if ctx is None and ghost.ContextType is None: @@ -221,26 +260,31 @@ def render_context_settings(conversation: Conversation): render_empty() -@st.dialog("Task Info", width="large") -def render_task_info_settings(task: GoTaskStruct): +def render_task_info_settings(task: GoTaskStruct, thread: GoThreadInfo): from ghostos.core.runtime.tasks import TaskBrief brief = TaskBrief.from_task(task) srj.pydantic_instance_form(brief, readonly=True) with st.expander(_("Detail"), expanded=False): st.write(task.model_dump(exclude_defaults=True)) + + st.subheader("Thread Info") + with st.container(border=True): + render_thread_messages(thread, max_turn=0) render_empty() def render_thread_messages(thread: GoThreadInfo, max_turn: int = 20): turns = list(thread.turns()) - turns = turns[-max_turn:] + if max_turn > 0: + turns = turns[-max_turn:] debug = get_app_conf().BoolOpts.DEBUG_MODE.get() count = 0 for turn in turns: count += render_turn(turn, debug) if count == 0: st.info("No thread messages yet") + render_empty() def render_event_object(event: Event, debug: bool): diff --git a/ghostos/prototypes/streamlitapp/pages/router.py b/ghostos/prototypes/streamlitapp/pages/router.py index 69798c2c..55e828f2 100644 --- a/ghostos/prototypes/streamlitapp/pages/router.py +++ b/ghostos/prototypes/streamlitapp/pages/router.py @@ -14,19 +14,39 @@ class PagePath(str, Enum): HOMEPAGE = "ghostos.prototypes.streamlitapp.pages.homepage" AIFUNCS = "ghostos.prototypes.streamlitapp.pages.aifuncs" - GHOSTOS = "ghostos.prototypes.streamlitapp.pages.ghosts" + GHOSTS = "ghostos.prototypes.streamlitapp.pages.ghosts" CONFIGS = "ghostos.prototypes.streamlitapp.pages.configs" def suffix(self, attr_name: str): return self.value + attr_name -# --- ghost --- # +# --- ghosts --- # + +class GhostTaskRoute(Route): + link = Link( + name="Task Info", + import_path=PagePath.GHOSTS.suffix(":main_task"), + streamlit_icon=":material/smart_toy:", + button_help="todo", + antd_icon="robot", + ) + + +class GhostSettingsRoute(Route): + link = Link( + name="Ghost Settings", + import_path=PagePath.GHOSTS.suffix(":main_settings"), + streamlit_icon=":material/smart_toy:", + button_help="todo", + antd_icon="robot", + ) + class GhostChatRoute(Route): link = Link( - name="Ghost Chat", - import_path=PagePath.GHOSTOS.suffix(".chat:main"), + name="Chat", + import_path=PagePath.GHOSTS.suffix(":main_chat"), streamlit_icon=":material/smart_toy:", button_help="todo", antd_icon="robot", @@ -34,6 +54,7 @@ class GhostChatRoute(Route): task_id: str = Field(default="", description="Ghost Task ID") ghost_meta: Optional[EntityMeta] = Field(default=None, description="ghost meta") context_meta: Optional[EntityMeta] = Field(default=None, description="context meta") + filename: Optional[str] = Field(default=None, description="filename to lunch the ghost") input_type: str = Field(default="", description="input type") __ghost__ = None @@ -167,6 +188,9 @@ def default_router() -> Router: AIFuncDetailRoute(), # ghosts GhostChatRoute(), + GhostSettingsRoute(), + GhostTaskRoute(), + ConfigsRoute(), ], home=Home.label(), diff --git a/ghostos/prototypes/streamlitapp/tests/test_markdown.py b/ghostos/prototypes/streamlitapp/tests/test_markdown.py new file mode 100644 index 00000000..c50b9104 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/test_markdown.py @@ -0,0 +1,9 @@ +import streamlit as st + +st.markdown(f""" +```moss +def foo(): + return "hello world" + +``` +""") diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py index f7d6e03f..d41667ad 100644 --- a/ghostos/prototypes/streamlitapp/widgets/messages.py +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -1,3 +1,5 @@ +import json + import streamlit as st from typing import Iterable, List, NamedTuple from ghostos.core.messages import Message, Role, MessageType, Caller @@ -89,19 +91,27 @@ def render_message_in_content(message: Message, debug: bool, prefix: str = ""): elif MessageType.is_text(message): st.markdown(message.content) # todo: more types + elif MessageType.FUNCTION_CALL.match(message) and debug: + callers = Caller.from_message(message) + render_message_caller(callers) else: st.write(message.model_dump(exclude_defaults=True)) - - if message.callers: - if st.button("tool calls", key=prefix + "tool calls" + message.msg_id): - open_message_caller(message) + if message.callers and debug: + render_message_caller(message.callers) render_message_payloads(message, debug, prefix) -@st.dialog("message_caller") -def open_message_caller(message: Message): - for caller in message.callers: - st.write(caller.model_dump(exclude_defaults=True)) +def render_message_caller(callers: Iterable[Caller]): + from ghostos.ghosts.moss_agent import MossAction + for caller in callers: + if caller.name == MossAction.Argument.name: + data = json.loads(caller.arguments) + arguments = MossAction.Argument(**data) + st.caption(f"functino call: {caller.name}") + st.code(arguments.code) + else: + st.caption(f"function call: {caller.name}") + st.json(caller.arguments) def render_message_item(msg: Message, debug: bool): diff --git a/ghostos/prototypes/streamlitapp/widgets/renderer.py b/ghostos/prototypes/streamlitapp/widgets/renderer.py index f664b8aa..5b94fdc9 100644 --- a/ghostos/prototypes/streamlitapp/widgets/renderer.py +++ b/ghostos/prototypes/streamlitapp/widgets/renderer.py @@ -76,8 +76,11 @@ def render_thread(thread: GoThreadInfo, max_turn: int = 20, prefix: str = "", de def render_turn(turn: Turn, debug: bool, prefix: str = "") -> int: from ghostos.prototypes.streamlitapp.widgets.messages import render_messages + if turn.summary is not None: + st.info("summary:\n" + turn.summary) + if turn.is_from_client(): - messages = list(turn.messages()) + messages = list(turn.messages(False)) render_messages(messages, debug, prefix) return len(messages) # from other task @@ -87,7 +90,7 @@ def render_turn(turn: Turn, debug: bool, prefix: str = "") -> int: if event is not None: sub_title = _("background event: ") + event.type with st.expander(sub_title, expanded=False): - messages = list(turn.messages()) + messages = list(turn.messages(False)) render_messages(messages, debug, prefix) render_event_object(event, debug) return len(messages) diff --git a/ghostos/scripts/clear_runtime.py b/ghostos/scripts/clear_runtime.py index 9e9b3f14..1046381e 100644 --- a/ghostos/scripts/clear_runtime.py +++ b/ghostos/scripts/clear_runtime.py @@ -75,6 +75,10 @@ def main(): "--logs", "-l", action="store_true", ) + parser.add_argument( + "--variables", "-v", + action="store_true", + ) from ghostos.bootstrap import workspace_dir runtime_dir = join(workspace_dir, "runtime") parsed = parser.parse_args(sys.argv[1:]) @@ -101,6 +105,10 @@ def main(): cleared = clear_directory(join(runtime_dir, "prompts"), recursive=True) done += 1 print(f"clear runtime/prompts files: {cleared}") + if _all or parsed.variables: + cleared = clear_directory(join(runtime_dir, "variables"), recursive=True) + done += 1 + print(f"clear runtime/prompts files: {cleared}") if _all or parsed.logs: cleared = clear_directory(join(runtime_dir, "logs"), recursive=True) done += 1 diff --git a/ghostos/scripts/cli/run_console.py b/ghostos/scripts/cli/run_console.py index dd3ab951..e74083a8 100644 --- a/ghostos/scripts/cli/run_console.py +++ b/ghostos/scripts/cli/run_console.py @@ -5,12 +5,14 @@ ) from ghostos.bootstrap import make_app_container, get_ghostos from ghostos.prototypes.console import ConsoleApp +from ghostos.entity import get_entity def main(): workspace_dir = check_ghostos_workspace_exists() - ghost, modulename, filename, is_temp = get_ghost_by_cli_argv() + ghost_info, modulename, filename, is_temp = get_ghost_by_cli_argv() container = make_app_container(workspace_dir) ghostos = get_ghostos(container) + ghost = get_entity(ghost_info.ghost, Ghost) app = ConsoleApp(ghostos=ghostos, ghost=ghost, username="") app.run() diff --git a/ghostos/scripts/cli/run_streamlit_ghost.py b/ghostos/scripts/cli/run_streamlit_ghost.py index 36954151..b2f798ee 100644 --- a/ghostos/scripts/cli/run_streamlit_ghost.py +++ b/ghostos/scripts/cli/run_streamlit_ghost.py @@ -1,11 +1,12 @@ +from typing import Optional, Dict, List from ghostos.scripts.cli.utils import ( check_ghostos_workspace_exists, get_ghost_by_cli_argv, ) from streamlit.web.cli import main_run from ghostos.prototypes.streamlitapp import cli -from ghostos.entity import EntityMeta, to_entity_meta -from ghostos.bootstrap import reset_at, get_ghostos +from ghostos.entity import EntityMeta +from ghostos.core.moss.utils import escape_string_quotes from pydantic import BaseModel, Field import sys from os import path @@ -17,20 +18,39 @@ class RunGhostChatApp(BaseModel): is_temp: bool = Field(description="if the modulename is temp module") workspace_dir: str = Field(description="the ghostos dir") ghost_meta: EntityMeta + context_meta: Optional[EntityMeta] = Field(default=None) + + +def get_config_flag_options(workspace_dir: str) -> List[str]: + from os.path import join, dirname + from toml import loads + filename = join(workspace_dir, ".streamlit/config.toml") + with open(filename, "r") as f: + data = loads(f.read()) + flags = [] + for key in data: + attrs = data[key] + for attr_name in attrs: + value = attrs[attr_name] + flags.append(f"{key}.{attr_name}=`{value}`") + return flags def main(): # path workspace_dir = check_ghostos_workspace_exists() - container = reset_at(workspace_dir) - ghost, modulename, filename, is_temp = get_ghost_by_cli_argv() + ghost_info, modulename, filename, is_temp = get_ghost_by_cli_argv() args = RunGhostChatApp( modulename=modulename, filename=filename, is_temp=is_temp, workspace_dir=workspace_dir, - ghost_meta=to_entity_meta(ghost), + ghost_meta=ghost_info.ghost, + context_meta=ghost_info.context, ) script_path = path.join(path.dirname(cli.__file__), "run_ghost_chat.py") args = [script_path, args.model_dump_json(), *sys.argv[1:]] + + flags = get_config_flag_options(workspace_dir) + args.extend(flags) main_run(args) diff --git a/ghostos/scripts/cli/utils.py b/ghostos/scripts/cli/utils.py index c273d3d5..81337447 100644 --- a/ghostos/scripts/cli/utils.py +++ b/ghostos/scripts/cli/utils.py @@ -1,24 +1,37 @@ +from __future__ import annotations import sys -from typing import Tuple, List, NamedTuple, Any, Optional +from typing import Tuple, List, NamedTuple, Any, Optional, Dict, Self from types import ModuleType from ghostos.bootstrap import expect_workspace_dir from ghostos.contracts.logger import get_console_logger from ghostos.helpers import create_module, import_from_path -import inspect from ghostos.abcd import Ghost +from pydantic import BaseModel, Field +from ghostos.entity import EntityMeta, to_entity_meta +from ghostos.ghosts.moss_agent import new_moss_agent +import inspect +import json __all__ = [ 'get_ghost_by_cli_argv', 'get_or_create_module_from_name', 'check_ghostos_workspace_exists', 'parse_args_modulename_or_filename', + 'GhostsConf', 'GhostInfo', ] -def get_ghost_by_cli_argv() -> Tuple[Ghost, str, str, bool]: +def get_ghost_by_cli_argv() -> Tuple[GhostInfo, str, str, bool]: filename_or_modulename, args = parse_args_modulename_or_filename() - found = get_or_create_module_from_name(filename_or_modulename, "ghostos.temp.agent") + found = get_or_create_module_from_name(filename_or_modulename, "ghostos.temp.ghost") + + # ghost info + ghosts_conf = GhostsConf.load_from(filename_or_modulename) + ghost_key = GhostsConf.file_ghost_key(filename_or_modulename) + if ghost_key in ghosts_conf.ghosts: + ghost_info = ghosts_conf.ghosts[ghost_key] + return ghost_info, found.module.__name__, found.filename, found.is_temp if found.value is not None: if not isinstance(found.value, Ghost): @@ -29,8 +42,9 @@ def get_ghost_by_cli_argv() -> Tuple[Ghost, str, str, bool]: if not isinstance(ghost, Ghost): raise SystemExit(f"{filename_or_modulename} __ghost__ is not a Ghost object") else: - raise SystemExit(f"cant find ghost instance at {filename_or_modulename}") - return ghost, found.module.__name__, found.filename, found.is_temp + ghost = new_moss_agent(found.module.__name__) + ghost_info = GhostInfo(ghost=to_entity_meta(ghost)) + return ghost_info, found.module.__name__, found.filename, found.is_temp def check_ghostos_workspace_exists() -> str: @@ -67,10 +81,16 @@ def get_or_create_module_from_name( filename_or_modulename: str, temp_modulename: str, ) -> Found: - from os import path + from os import path, getcwd + _, extension = path.splitext(filename_or_modulename) if extension in ACCEPTED_FILE_EXTENSIONS: filename = path.abspath(filename_or_modulename) + root_dir = getcwd() + if filename.startswith(root_dir): + relative_path = filename[len(root_dir) + 1:] + relative_path_basename, _ = path.splitext(relative_path) + temp_modulename = relative_path_basename.replace("/", ".") module = create_module(temp_modulename, filename) is_temp = True value = None @@ -85,3 +105,50 @@ def get_or_create_module_from_name( filename = module.__file__ is_temp = False return Found(value, module, filename, is_temp) + + +class GhostInfo(BaseModel): + ghost: EntityMeta = Field(description="ghost meta") + context: Optional[EntityMeta] = Field(None) + + +class GhostsConf(BaseModel): + ghosts: Dict[str, GhostInfo] = Field( + default_factory=dict, + description="ghost info dict, from filename to ghost info", + ) + + @classmethod + def load(cls, filename: str) -> Self: + from os.path import exists + if exists(filename): + with open(filename, "r") as f: + content = f.read() + data = json.loads(content) + return cls(**data) + return cls() + + @classmethod + def load_from(cls, filename: str) -> Self: + ghosts_filename = cls.ghosts_filename(filename) + return cls.load(ghosts_filename) + + @classmethod + def file_ghost_key(cls, filename: str) -> str: + from os.path import basename, splitext + file_basename = basename(filename) + key, _ = splitext(file_basename) + return key + + @classmethod + def ghosts_filename(cls, filename: str) -> str: + from os.path import dirname, join + dir_ = dirname(filename) + ghosts_filename = join(dir_, ".ghosts.yml") + return ghosts_filename + + def save(self, filename: str) -> None: + ghosts_filename = self.ghosts_filename(filename) + content = self.model_dump_json(indent=2) + with open(ghosts_filename, "w") as f: + f.write(content) diff --git a/tests/core/messages/test_message_parser.py b/tests/core/messages/test_message_parser.py index 09a2a7b5..d1b3aa51 100644 --- a/tests/core/messages/test_message_parser.py +++ b/tests/core/messages/test_message_parser.py @@ -1,4 +1,4 @@ -from ghostos.core.messages import MessageKindParser, VariableMessage +from ghostos.core.messages import MessageKindParser, VariableMessage, Caller from ghostos.framework.variables import test_variables from pydantic import BaseModel @@ -10,6 +10,14 @@ def test_message_parser(): assert messages[0].content == 'Hello World' +def test_message_parser_with_message_class(): + parser = MessageKindParser(test_variables) + caller = Caller(name="hello", arguments="world") + item = caller.new_output("output") + messages = list(parser.parse([item])) + assert messages[0].content == "output" + + class Foo(BaseModel): foo: str = "hello" diff --git a/tests/python/test_slice.py b/tests/python/test_slice.py index c0b777f3..458f645c 100644 --- a/tests/python/test_slice.py +++ b/tests/python/test_slice.py @@ -60,3 +60,11 @@ def test_arr_tail(): arr = [1, 2, 3, 4] assert arr[-2:] == [3, 4] assert arr[-20:] == [1, 2, 3, 4] + + +def test_negative_slice_index(): + arr = [1, 2, 3, 4] + first = arr[:2] + end = arr[2:] + assert first == [1, 2] + assert end == [3, 4] diff --git a/tests/python/test_yield.py b/tests/python/test_yield.py index 065e3fd7..e10ddf0c 100644 --- a/tests/python/test_yield.py +++ b/tests/python/test_yield.py @@ -59,6 +59,7 @@ def foo(): # finally is not called as well. assert values == [] + # iterable can not define __awaits__ # def test_yield_is_blocking_with_none(): # tests = [] @@ -80,3 +81,12 @@ def foo(): # import asyncio # asyncio.run(main()) # assert tests == ["foo", "bar"] + + +def test_yield_after_yield_from(): + def foo(): + yield from [] + yield 1 + + values = list(foo()) + assert values == [1] From 7307d3ebd05797c9d9fd49c373ce67eafc264f1a Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 24 Nov 2024 17:12:10 +0800 Subject: [PATCH 102/148] dev: the streamlitapp baseline test is complete, fix styling and streaming bugs. feeling good --- ghostos/abcd/utils.py | 12 ++- ghostos/core/messages/message.py | 2 + ghostos/core/messages/openai.py | 36 +++---- ghostos/core/runtime/threads.py | 3 + .../framework/ghostos/conversation_impl.py | 17 ++-- ghostos/framework/ghostos/session_impl.py | 28 ++++-- ghostos/framework/ghostos/taskflow_impl.py | 5 +- ghostos/framework/llms/openai_driver.py | 4 +- ghostos/ghosts/moss_agent/agent.py | 9 +- ghostos/ghosts/moss_agent/instructions.py | 57 ++++++----- .../prototypes/streamlitapp/pages/ghosts.py | 97 ++++++++++--------- .../prototypes/streamlitapp/pages/router.py | 11 --- .../streamlitapp/widgets/dialogs.py | 6 ++ .../streamlitapp/widgets/messages.py | 48 ++++++--- .../streamlitapp/widgets/renderer.py | 2 +- tests/core/messages/test_messages.py | 22 ++++- 16 files changed, 214 insertions(+), 145 deletions(-) diff --git a/ghostos/abcd/utils.py b/ghostos/abcd/utils.py index 576e1d64..a9e70500 100644 --- a/ghostos/abcd/utils.py +++ b/ghostos/abcd/utils.py @@ -3,7 +3,7 @@ from ghostos.identifier import get_identifier from ghostos.entity import to_entity_meta from .concepts import Ghost, GhostDriver, Session, Operator -from ghostos.core.runtime import Event +from ghostos.core.runtime import Event, TaskState __all__ = [ 'get_ghost_driver', 'get_ghost_driver_type', 'is_ghost', @@ -53,8 +53,12 @@ def fire_session_event(session: Session, event: Event) -> Optional[Operator]: # if event is intercepted, stop the run. return None driver = get_ghost_driver(session.ghost) + session.task.state = TaskState.RUNNING.value session.thread = driver.truncate(session) - return driver.on_event(session, event) + op = driver.on_event(session, event) + if op is None: + session.task.state = TaskState.WAITING.value + return op class InitOperator(Operator): @@ -77,9 +81,9 @@ def run_session_event(session: Session, event: Event, max_step: int) -> None: raise RuntimeError(f"Max step {max_step} reached") if not session.refresh(): raise RuntimeError("Session refresh failed") - session.logger.debug("start session op %s", op) + session.logger.debug("start session op %s", repr(op)) next_op = op.run(session) - session.logger.debug("done session op %s", op) + session.logger.debug("done session op %s", repr(op)) op.destroy() # session do save after each op session.save() diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 22e3294c..3d0917e6 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -366,6 +366,8 @@ def update(self, pack: "Message") -> None: if not self.msg_id: # 当前消息的 msg id 不会变更. self.msg_id = pack.msg_id + if not self.ref_id: + self.ref_id = pack.ref_id if not self.type: # type 也不会变更. self.type = pack.type diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index 0081858c..92c44003 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -122,26 +122,22 @@ def parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam def _parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: if message.type == MessageType.FUNCTION_CALL.value: if message.ref_id: - yield from [ + return [ ChatCompletionAssistantMessageParam( - content=None, role="assistant", - tool_calls=[ - ChatCompletionMessageToolCallParam( - id=message.ref_id, - function=FunctionCall( - name=message.name, - arguments=message.content, - ), - type="function" - ) - ] + tool_calls=[ChatCompletionMessageToolCallParam( + id=message.ref_id, + function=FunctionCall( + name=message.name, + arguments=message.content, + ), + type="function" + )] ) ] else: - yield from [ + return [ ChatCompletionAssistantMessageParam( - content=None, role="assistant", function_call=FunctionCall( name=message.name, @@ -151,7 +147,7 @@ def _parse_message(self, message: Message) -> Iterable[ChatCompletionMessagePara ] elif message.type == MessageType.FUNCTION_OUTPUT: if message.ref_id: - yield from [ + return [ ChatCompletionToolMessageParam( tool_call_id=message.ref_id, content=message.content, @@ -159,7 +155,7 @@ def _parse_message(self, message: Message) -> Iterable[ChatCompletionMessagePara ) ] else: - yield from [ + return [ ChatCompletionFunctionMessageParam( content=message.get_content(), name=message.name, @@ -170,20 +166,20 @@ def _parse_message(self, message: Message) -> Iterable[ChatCompletionMessagePara return [] if message.role == Role.ASSISTANT: - yield from self._parse_assistant_chat_completion(message) + return self._parse_assistant_chat_completion(message) elif message.role == Role.SYSTEM: - yield from [ + return [ ChatCompletionSystemMessageParam(content=message.get_content(), role="system") ] elif message.role == Role.USER: item = ChatCompletionUserMessageParam(content=message.get_content(), role="user") if message.name: item["name"] = message.name - yield from [ + return [ item ] else: - yield from [] + return [] @staticmethod def _parse_assistant_chat_completion(message: Message) -> Iterable[ChatCompletionAssistantMessageParam]: diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index a775fa18..a1477638 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -90,6 +90,9 @@ def is_empty(self) -> bool: def is_from_client(self) -> bool: return self.event is not None and self.event.from_task_id is None + def is_from_self(self) -> bool: + return self.event is not None and self.event.from_self() + class GoThreadInfo(BaseModel): """ diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index 57852c67..fadc9ee6 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -171,8 +171,8 @@ def _validate_closed(self): def _submit_session_event(self, event: Event, stream: Stream) -> None: self.logger.debug("submit session event") - try: - with stream: + with stream: + try: task = self._tasks.get_task(event.task_id) session = self._create_session(task, self._locker, stream) self.logger.debug( @@ -181,11 +181,11 @@ def _submit_session_event(self, event: Event, stream: Stream) -> None: ) with session: run_session_event(session, event, self._conf.max_session_step) - except Exception as e: - self.logger.exception(e) - self.fail(error=e) - finally: - self._eventbus.notify_task(event.task_id) + except Exception as e: + if not self.fail(error=e): + raise + finally: + self._eventbus.notify_task(event.task_id) def _create_session( self, @@ -215,8 +215,9 @@ def send_event(self, event: Event) -> None: def fail(self, error: Exception) -> bool: if self._closed: return False + self.logger.exception(error) self.close() - return False + return True def __del__(self): self.close() diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 985ae881..741a5205 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -86,6 +86,7 @@ def __init__( self._failed = False self._done = False self._destroyed = False + self._saved = False self._bootstrap() self._thread_locker = Lock() if not self.refresh(): @@ -173,7 +174,7 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] # cancel self and all subtasks. self.task.errors = 0 self.thread.new_turn(event) - self.task.state = TaskState.CANCELLED + self.task.state = TaskState.CANCELLED.value for child_id in self.task.children: event = EventTypes.CANCEL.new( task_id=child_id, @@ -211,7 +212,10 @@ def get_artifact(self) -> Ghost.ArtifactType: def refresh(self) -> bool: if self._failed or self._destroyed or not self.is_alive(): return False - return self.locker.refresh() + if self.locker.refresh(): + self._saved = False + return True + return False def _reset(self): self._fetched_task_briefs = {} @@ -277,7 +281,9 @@ def call(self, ghost: Ghost, ctx: Ghost.ContextType) -> Ghost.ArtifactType: def fire_events(self, *events: "Event") -> None: self._validate_alive() - self._firing_events.extend(events) + firing = list(events) + self.logger.debug("session fire events: %d", len(firing)) + self._firing_events.extend(firing) def get_task_briefs(self, *task_ids: str) -> Dict[str, TaskBrief]: self._validate_alive() @@ -298,6 +304,10 @@ def get_task_briefs(self, *task_ids: str) -> Dict[str, TaskBrief]: return result def save(self) -> None: + if self._saved: + return + self._saved = True + self.logger.debug("saving session on %s", self.scope.model_dump()) self._validate_alive() self._update_subtasks() self._update_state_changes() @@ -329,11 +339,13 @@ def _update_state_changes(self) -> None: content = "\n".join(self._system_logs) message = Role.SYSTEM.new(content=content) thread.append(message) + self._system_logs = [] task.thread_id = thread.id task.state_values = state_values tasks = self.container.force_fetch(GoTasks) threads = self.container.force_fetch(GoThreads) + self.logger.debug("task info %s", task.model_dump()) tasks.save_task(task) threads.save_thread(thread) @@ -352,6 +364,7 @@ def _do_save_threads(self) -> None: def _do_fire_events(self) -> None: if not self._firing_events: return + logger = self.logger bus = self.container.force_fetch(EventBus) for e in self._firing_events: # all the sub-tasks need notification @@ -359,6 +372,7 @@ def _do_fire_events(self) -> None: if e.task_id == self.task.parent: notify = self.task.depth - 1 == 0 bus.send_event(e, notify) + logger.debug("session fired event %s", {e.event_id}) self._firing_events = [] def __enter__(self): @@ -369,20 +383,20 @@ def __exit__(self, exc_type, exc_val, exc_tb): intercepted = self.fail(exc_val) self.destroy() return intercepted - else: + elif not self._destroyed: self.save() self.destroy() return None def fail(self, err: Optional[Exception]) -> bool: if self._failed: - return True + return False self._failed = True self.logger.exception("Session failed: %s", err) - if self.upstream is not None: + if self.upstream is not None and self.upstream.alive(): message = MessageType.ERROR.new(content=str(err)) self.upstream.deliver(message) - return False + return True def destroy(self) -> None: if self._destroyed: diff --git a/ghostos/framework/ghostos/taskflow_impl.py b/ghostos/framework/ghostos/taskflow_impl.py index 5c965535..345f6b35 100644 --- a/ghostos/framework/ghostos/taskflow_impl.py +++ b/ghostos/framework/ghostos/taskflow_impl.py @@ -150,6 +150,7 @@ def run(self, session: Session) -> Union[Operator, None]: session.thread.append(msg) event.reason = f"receive observation at turn {task.turns}" session.fire_events(event) + session.task.state = TaskState.WAITING.value return None def destroy(self): @@ -189,9 +190,9 @@ def destroy(self): class WaitOperator(AbcOperator, ABC): def run(self, session: Session) -> Union[Operator, None]: + task = session.task + task.state = TaskState.WAITING.value if len(self.messages) > 0: - task = session.task - task.state = TaskState.WAITING.value task.status_desc = self.status if task.parent: event = EventTypes.WAIT_CALLBACK.new( diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index 63864b90..ce28f9d4 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -118,8 +118,8 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion raise AttributeError("empty chat!!") try: prompt.run_start = timestamp() - get_ghostos_logger().info(f"start chat completion for prompt %s", prompt.id) - get_ghostos_logger().info(f"start chat completion messages %s", messages) + get_ghostos_logger().debug(f"start chat completion for prompt %s", prompt.id) + get_ghostos_logger().debug(f"start chat completion messages %s", messages) functions = prompt.get_openai_functions() tools = prompt.get_openai_tools() if self._model.use_tools: diff --git a/ghostos/ghosts/moss_agent/agent.py b/ghostos/ghosts/moss_agent/agent.py index 4fdecfb1..6c6abf9f 100644 --- a/ghostos/ghosts/moss_agent/agent.py +++ b/ghostos/ghosts/moss_agent/agent.py @@ -324,7 +324,7 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: moss = self.runtime.moss() try: - result = self.runtime.execute(target="main", code=code, args=[moss]) + result = self.runtime.execute(target="run", code=code, args=[moss]) op = result.returns if op is not None and not isinstance(op, Operator): return self.fire_error(session, caller, "result of moss code is not None or Operator") @@ -343,10 +343,13 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: if op is None: # if std output is not empty, and op is none, observe the output as default. return session.taskflow().think() - return op + else: + output = caller.new_output("executed with no output or errors.") + session.respond([output]) + return session.taskflow().think() except Exception as e: - return self.fire_error(session, caller, f"error executing moss code: {e}") + return self.fire_error(session, caller, f"error during executing moss code: {e}") @staticmethod def fire_error(session: Session, caller: Caller, error: str) -> Operator: diff --git a/ghostos/ghosts/moss_agent/instructions.py b/ghostos/ghosts/moss_agent/instructions.py index 2e20ed2e..0019532f 100644 --- a/ghostos/ghosts/moss_agent/instructions.py +++ b/ghostos/ghosts/moss_agent/instructions.py @@ -18,12 +18,28 @@ basic usage: 1. you will get the python code context that MOSS provide to you below. -2. you can generate python code to the tool named `moss`, the code will be automatically executed by the outer system. +2. you can generate code by `moss` tool, then the `GhostOS` will execute them for you. 3. if you print anything in your generated code, the output will be shown in further messages. -the python code you generated, must include a main function, follow the pattern: +""" + +MOSS_CONTEXT_TEMPLATE = """ +The python context `{modulename}` that MOSS provides to you are below: + ```python -def main(moss: Moss): +{code_context} +``` + +Notices: +* the imported functions are only shown with signature, the source code is omitted. +* the properties on moss instance, will keep existence. +* You can bind variables of type int/float/bool/str/list/dict/BaseModel to moss instance if you need them for next turn. + +You are able to call the `moss` tool, generate code to fulfill your will. +the python code you generated, must include a `run` function, follow the pattern: + +```python +def run(moss: Moss): \""" :param moss: instance of the class `Moss`, the properties on it will be injected with runtime implementations. :return: Optional[Operator] @@ -32,24 +48,19 @@ def main(moss: Moss): You shall only return operator by the libraries provided on `moss`. \""" ``` -* the outer system will execute the main function in the python module provided to you. you shall not import the module. -* the imported functions are only shown with signature, the source code is omitted. + +Then the `GhostOS` system will add your code to the python module provided to you, +and execute the `run` function. + +Notices: +* you do not need to import the module that already provided above. * if the python code context can not fulfill your will, do not use the `moss` tool. * you can reply as usual without calling the tool `moss`. use it only when you know what you're doing. -* the code you generated executed only once and do not add to the python context. - But the properties on moss instance, will keep existence. - You can bind variables of type int/float/bool/str/list/dict/BaseModel to moss instance if you need them for next turn. -""" - -MOSS_CONTEXT_TEMPLATE = """ -The python context that MOSS provides to you are below: -```python -{code_context} -``` +* the comments in your code generation is useful but not required, comment only when necessary """ MOSS_FUNCTION_DESC = """ -useful to generate execution code of `MOSS`, notice the code must include a `main` function. +useful to call MOSS system to execute the code. The code must include a `run` function. """ @@ -67,16 +78,12 @@ def get_moss_context_prompter(title: str, runtime: MossRuntime) -> Prompter: content=injection.self_prompt(container), ) children.append(prompter) - end = "more information about attributes on `moss`:" if children else "" - content = f""" -The module provided to you are `{runtime.module().__name__}`. -The code are: -```python -{code_context} -``` -{end} -""" + content = MOSS_CONTEXT_TEMPLATE.format( + modulename=runtime.module().__name__, + code_context=code_context, + ) + return TextPrmt( title=title, content=content, diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index e72e4892..f5fbd888 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -3,7 +3,7 @@ import streamlit_react_jsonschema as srj from typing import Iterable, List from ghostos.prototypes.streamlitapp.pages.router import ( - GhostChatRoute, GhostTaskRoute, GhostSettingsRoute, + GhostChatRoute, GhostTaskRoute, ) from ghostos.prototypes.streamlitapp.utils.session import Singleton from ghostos.prototypes.streamlitapp.widgets.messages import ( @@ -28,6 +28,8 @@ logger = get_logger("ghostos") +st.set_page_config(page_title='Ghost', layout="wide") + def main_chat(): # create shell @@ -42,7 +44,6 @@ def main_chat(): with st.sidebar: # other pages with st.container(border=True): - GhostSettingsRoute().render_page_link(use_container_width=True) GhostTaskRoute().render_page_link(use_container_width=True) if st.button("Clear Messages", use_container_width=True): thread = conversation.thread() @@ -50,14 +51,21 @@ def main_chat(): conversation.update_thread(thread) st.rerun() - st.subheader("Inputs") + st.subheader("page options") # input type with st.container(border=True): + show_chatting = st.toggle("chat", value=True) auto_run = st.toggle( "auto run event", help="automatic run background event", value=True, ) + show_ghost_settings = st.toggle("ghost settings") + show_instruction = st.toggle("instructions") + show_context = st.toggle("context") + + st.subheader("inputs options") + with st.container(border=True): show_video = st.toggle("show video") show_image_file = st.toggle("upload image") @@ -65,8 +73,7 @@ def main_chat(): pic = st.camera_input("Task a picture") if show_image_file: image = st.file_uploader("Upload image", type=["png", "jpg", "jpeg"]) - for i in range(5): - st.empty() + render_empty() # header st.title("Ghost") @@ -84,14 +91,28 @@ def main_chat(): {yaml_pretty_dump(data)} ``` """) - inputs = [] - if chat_input := st.chat_input("message"): - inputs = route.get_route_bound([], "inputs") - inputs.append(Role.USER.new(chat_input)) - route.bind_to_route([], "inputs") - route.input_type = "" - - chatting(route, conversation, inputs, auto_run) + # render ghost settings + if show_ghost_settings: + render_ghost_settings(route) + st.divider() + if show_instruction: + render_instruction(conversation) + st.divider() + if show_context: + render_context_settings(conversation) + st.divider() + + # inputs + if show_chatting: + st.subheader("Chat") + inputs = [] + if chat_input := st.chat_input("message"): + inputs = route.get_route_bound([], "inputs") + inputs.append(Role.USER.new(chat_input)) + route.bind_to_route([], "inputs") + route.input_type = "" + + chatting(route, conversation, inputs, auto_run) def get_conversation(route: GhostChatRoute) -> Conversation: @@ -104,27 +125,12 @@ def get_conversation(route: GhostChatRoute) -> Conversation: return conversation -def main_settings(): - route = GhostChatRoute.get(st.session_state) - with st.sidebar: - # other pages - with st.container(border=True): - route.render_page_link(use_container_width=True) - GhostTaskRoute().render_page_link(use_container_width=True) - st.title("Ghost Settings") - conversation = get_conversation(route) - render_ghost_settings(route) - render_instruction(conversation) - render_context_settings(conversation) - - def main_task(): route = GhostChatRoute.get(st.session_state) with st.sidebar: # other pages with st.container(border=True): route.render_page_link(use_container_width=True) - GhostSettingsRoute().render_page_link(use_container_width=True) conversation = get_conversation(route) task = conversation.task() thread = conversation.thread() @@ -136,13 +142,13 @@ def chatting(route: GhostChatRoute, conversation: Conversation, inputs: List[Mes thread = conversation.thread() render_thread_messages(thread, max_turn=20) debug = get_app_conf().BoolOpts.DEBUG_MODE.get() - render_empty() if inputs: event, receiver = conversation.respond(inputs) render_event(event, debug) render_receiver(receiver, debug) + render_empty() while not route.input_type and rotate and not conversation.closed(): if event := conversation.pop_event(): render_event(event, debug) @@ -164,22 +170,25 @@ def video_input_dialog(route: GhostChatRoute): def render_receiver(receiver: Receiver, debug: bool): try: with receiver: - with st.status("waiting..."): - buffer = ReceiverBuffer.new(receiver.recv()) - if buffer is None: - return with st.chat_message("assistant"): + with st.status("waiting..."): + buffer = ReceiverBuffer.new(receiver.recv()) + if buffer is None: + st.error("No message received") + return while buffer is not None: if MessageType.is_text(buffer.head()): contents = chunks_to_st_stream(buffer.chunks()) - st.write_stream(contents) - render_message_payloads(buffer.tail(), debug) + with st.empty(): + st.write_stream(contents) + with st.container(): + render_message_in_content(buffer.tail(), debug) elif MessageType.FUNCTION_CALL.match(buffer.head()): - if debug: - contents = chunks_to_st_stream(buffer.chunks()) - with st.container(border=True): - st.write_stream(contents) - render_message_payloads(buffer.tail(), debug) + contents = chunks_to_st_stream(buffer.chunks()) + with st.empty(): + st.write_stream(contents) + with st.container(): + render_message_in_content(buffer.tail(), debug) else: render_message_in_content(buffer.tail(), debug) # render next item @@ -195,6 +204,7 @@ def chunks_to_st_stream(chunks: Iterable[Message]) -> Iterable[str]: def render_ghost_settings(route: GhostChatRoute): + st.subheader(_("Settings")) if route is None: st.error("page is not configured") return @@ -202,8 +212,6 @@ def render_ghost_settings(route: GhostChatRoute): # render ghost info if isinstance(ghost, BaseModel): data, submitted = srj.pydantic_instance_form(ghost) - st.write("debug") - st.write(submitted) if route.filename and submitted: ghosts_conf = GhostsConf.load_from(route.filename) key = ghosts_conf.file_ghost_key(route.filename) @@ -215,14 +223,15 @@ def render_ghost_settings(route: GhostChatRoute): else: st.write(ghost) source = inspect.getsource(ghost.__class__) - with st.expander("source code", expanded=False): + if st.toggle("source code", key="ghost_source_code"): + st.caption(generate_import_path(ghost.__class__)) st.code(source) render_empty() def render_instruction(conversation: Conversation): st.subheader("Instructions") - conversation.get_ghost_driver() + driver = conversation.get_ghost_driver() def render_context_settings(conversation: Conversation): diff --git a/ghostos/prototypes/streamlitapp/pages/router.py b/ghostos/prototypes/streamlitapp/pages/router.py index 55e828f2..ffad77e5 100644 --- a/ghostos/prototypes/streamlitapp/pages/router.py +++ b/ghostos/prototypes/streamlitapp/pages/router.py @@ -33,16 +33,6 @@ class GhostTaskRoute(Route): ) -class GhostSettingsRoute(Route): - link = Link( - name="Ghost Settings", - import_path=PagePath.GHOSTS.suffix(":main_settings"), - streamlit_icon=":material/smart_toy:", - button_help="todo", - antd_icon="robot", - ) - - class GhostChatRoute(Route): link = Link( name="Chat", @@ -188,7 +178,6 @@ def default_router() -> Router: AIFuncDetailRoute(), # ghosts GhostChatRoute(), - GhostSettingsRoute(), GhostTaskRoute(), ConfigsRoute(), diff --git a/ghostos/prototypes/streamlitapp/widgets/dialogs.py b/ghostos/prototypes/streamlitapp/widgets/dialogs.py index 6f96b8fc..8c245278 100644 --- a/ghostos/prototypes/streamlitapp/widgets/dialogs.py +++ b/ghostos/prototypes/streamlitapp/widgets/dialogs.py @@ -1,6 +1,7 @@ import streamlit as st from ghostos.helpers import gettext as _, yaml_pretty_dump from ghostos.framework.messages import CompletionUsagePayload +from ghostos.core.messages import Message from ghostos.prototypes.streamlitapp.widgets.renderer import render_empty @@ -25,6 +26,11 @@ def open_completion_usage_dialog(completion: CompletionUsagePayload): render_empty() +@st.dialog(title=_("Message Detail"), width="large") +def open_message_dialog(message: Message): + st.json(message.model_dump_json(indent=2)) + + @st.dialog(title=_("Prompt Info"), width="large") def open_prompt_info_dialog(prompt_id: str): import streamlit_react_jsonschema as srj diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py index d41667ad..fc009104 100644 --- a/ghostos/prototypes/streamlitapp/widgets/messages.py +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -35,13 +35,15 @@ def render_messages(messages: Iterable[Message], debug: bool, prefix: str = ""): def render_message_group(group: MessageGroup, debug: bool, prefix: str = ""): role = group.msg_role + if role not in {Role.ASSISTANT.value, Role.USER.value} and not debug: + # hide system messages. + return name = group.msg_name stage = group.stage caption = f"{role}: {name}" if name else role render_role = "user" if role == Role.USER.value else "assistant" if stage: - with st.container(border=True): - st.caption(stage) + with st.expander(stage, expanded=False): with st.chat_message(render_role): st.caption(caption) for msg in group.messages: @@ -57,11 +59,12 @@ def render_message_payloads(message: Message, debug: bool, prefix: str = ""): import streamlit_antd_components as sac from ghostos.prototypes.streamlitapp.widgets.dialogs import ( open_task_info_dialog, open_completion_usage_dialog, open_prompt_info_dialog, + open_message_dialog, ) if not debug: return - items = [] + items = [sac.ButtonsItem(label="Detail")] task_payload = TaskPayload.read_payload(message) if task_payload: items.append(sac.ButtonsItem(label="Task Info")) @@ -77,7 +80,9 @@ def render_message_payloads(message: Message, debug: bool, prefix: str = ""): index=None, key=prefix + ":payloads:" + message.msg_id, ) - if selected == "Task Info" and task_payload: + if selected == "Detail": + open_message_dialog(message) + elif selected == "Task Info" and task_payload: open_task_info_dialog(task_payload.task_id) elif selected == "Completion Usage" and completion_usage: open_completion_usage_dialog(completion_usage) @@ -90,24 +95,41 @@ def render_message_in_content(message: Message, debug: bool, prefix: str = ""): st.error(f"Error: {message.content}") elif MessageType.is_text(message): st.markdown(message.content) - # todo: more types - elif MessageType.FUNCTION_CALL.match(message) and debug: + elif MessageType.FUNCTION_CALL.match(message): callers = Caller.from_message(message) - render_message_caller(callers) + render_message_caller(callers, debug) + elif MessageType.FUNCTION_OUTPUT.match(message): + render_message_caller_output(message, debug) + # todo: more types else: st.write(message.model_dump(exclude_defaults=True)) - if message.callers and debug: - render_message_caller(message.callers) + if message.callers: + render_message_caller(message.callers, debug) render_message_payloads(message, debug, prefix) -def render_message_caller(callers: Iterable[Caller]): +def render_message_caller_output(message: Message, debug: bool): + with st.expander("Caller Output", expanded=debug): + st.caption(f"function {message.name} output:") + st.write(message.content) + + +def render_message_caller(callers: Iterable[Caller], debug: bool): + with st.expander("Callers", expanded=debug): + _render_message_caller(callers) + + +def _render_message_caller(callers: Iterable[Caller]): from ghostos.ghosts.moss_agent import MossAction for caller in callers: if caller.name == MossAction.Argument.name: - data = json.loads(caller.arguments) - arguments = MossAction.Argument(**data) - st.caption(f"functino call: {caller.name}") + try: + data = json.loads(caller.arguments) + arguments = MossAction.Argument(**data) + except json.JSONDecodeError: + arguments = MossAction.Argument(code=caller.arguments) + + st.caption(f"function call: {caller.name}") st.code(arguments.code) else: st.caption(f"function call: {caller.name}") diff --git a/ghostos/prototypes/streamlitapp/widgets/renderer.py b/ghostos/prototypes/streamlitapp/widgets/renderer.py index 5b94fdc9..db8b7209 100644 --- a/ghostos/prototypes/streamlitapp/widgets/renderer.py +++ b/ghostos/prototypes/streamlitapp/widgets/renderer.py @@ -79,7 +79,7 @@ def render_turn(turn: Turn, debug: bool, prefix: str = "") -> int: if turn.summary is not None: st.info("summary:\n" + turn.summary) - if turn.is_from_client(): + if turn.is_from_client() or turn.is_from_self(): messages = list(turn.messages(False)) render_messages(messages, debug, prefix) return len(messages) diff --git a/tests/core/messages/test_messages.py b/tests/core/messages/test_messages.py index 43f46fa8..c83864b2 100644 --- a/tests/core/messages/test_messages.py +++ b/tests/core/messages/test_messages.py @@ -1,6 +1,6 @@ from ghostos.core.messages import ( Role, - Message, + Message, MessageType, ) @@ -80,7 +80,19 @@ def test_patch_default_type_message(): assert patch is None - - - - +def test_function_call_message(): + head = Message.new_head( + typ_=MessageType.FUNCTION_CALL, + ref_id="abc", + name="abc", + ) + patched = head.patch( + Message.new_chunk( + typ_=MessageType.FUNCTION_CALL, + content="hello world" + ) + ) + assert patched is not None + assert patched.ref_id == "abc" + assert patched.name == "abc" + assert patched.content == "hello world" From 266c307364d25d13cd0551a5c33bafe673b4fabf Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 24 Nov 2024 17:46:12 +0800 Subject: [PATCH 103/148] dev: add instruction to conversation and streamlit app page --- ghostos/abcd/concepts.py | 12 ++ .../framework/ghostos/conversation_impl.py | 9 + ghostos/framework/ghostos/session_impl.py | 5 +- ghostos/ghosts/chatbot/simplest.py | 3 + ghostos/ghosts/moss_agent/agent.py | 15 +- ghostos/prompter.py | 2 +- ghostos/prototypes/streamlitapp/main.py | 2 + .../prototypes/streamlitapp/pages/ghosts.py | 157 +++++++++--------- .../streamlitapp/widgets/messages.py | 2 + 9 files changed, 124 insertions(+), 83 deletions(-) diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index 23a0177f..c5d379c0 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -120,6 +120,10 @@ def get_artifact(self, session: Session) -> Optional[G.ArtifactType]: """ pass + @abstractmethod + def get_instructions(self, session: Session) -> str: + pass + @abstractmethod def providers(self) -> Iterable[Provider]: """ @@ -364,6 +368,10 @@ def get_ghost_driver(self) -> GhostDriver[G]: def get_context(self) -> G.ContextType: pass + @abstractmethod + def get_instructions(self) -> str: + pass + @abstractmethod def get_artifact(self) -> Tuple[Union[G.ArtifactType, None], TaskState]: pass @@ -571,6 +579,10 @@ def get_artifact(self) -> G.ArtifactType: """ pass + @abstractmethod + def get_instructions(self) -> str: + pass + @abstractmethod def refresh(self) -> bool: """ diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index fadc9ee6..6f6a3173 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -108,6 +108,15 @@ def get_context(self) -> Optional[Context]: return None return get_entity(task.context, Context) + def get_instructions(self) -> str: + self._validate_closed() + session = self._create_session(self.task(), self._locker, None) + try: + instructions = session.get_instructions() + return instructions + finally: + session.destroy() + def get_artifact(self) -> Tuple[Union[Ghost.ArtifactType, None], TaskState]: self._validate_closed() task = self.task() diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 741a5205..78896f9e 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -128,7 +128,7 @@ def unmarshal_state(task: GoTaskStruct) -> Dict[str, EntityType]: def is_alive(self) -> bool: if self._failed or self._destroyed: return False - return self.locker.acquired() and self.upstream.alive() + return self.locker.acquired() and (self.upstream is None or self.upstream.alive()) def _validate_alive(self): if not self.is_alive(): @@ -209,6 +209,9 @@ def get_context(self) -> Optional[Prompter]: def get_artifact(self) -> Ghost.ArtifactType: return self.ghost_driver.get_artifact(self) + def get_instructions(self) -> str: + return self.ghost_driver.get_instructions(self) + def refresh(self) -> bool: if self._failed or self._destroyed or not self.is_alive(): return False diff --git a/ghostos/ghosts/chatbot/simplest.py b/ghostos/ghosts/chatbot/simplest.py index d42578c4..bfbe4a49 100644 --- a/ghostos/ghosts/chatbot/simplest.py +++ b/ghostos/ghosts/chatbot/simplest.py @@ -40,6 +40,9 @@ class ChatbotDriver(GhostDriver[Chatbot]): def get_artifact(self, session: Session) -> None: return None + def get_instructions(self, session: Session) -> str: + return self.get_system_prompter().get_prompt(session.container) + def providers(self) -> Iterable[Provider]: return [] diff --git a/ghostos/ghosts/moss_agent/agent.py b/ghostos/ghosts/moss_agent/agent.py index 6c6abf9f..d17a0314 100644 --- a/ghostos/ghosts/moss_agent/agent.py +++ b/ghostos/ghosts/moss_agent/agent.py @@ -105,6 +105,13 @@ def get_artifact(self, session: Session) -> Optional[MossAgent.ArtifactType]: moss = runtime.moss() return fn(self.ghost, moss) + def get_instructions(self, session: Session) -> str: + compiler = self._get_moss_compiler(session) + with compiler: + rtm = compiler.compile(self.ghost.compile_module) + with rtm: + return self._get_instructions(session, rtm) + def thought(self, session: Session, runtime: MossRuntime) -> Thought: from .for_meta_ai import __moss_agent_thought__ as fn compiled = runtime.module() @@ -140,9 +147,9 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: thread = session.thread thread.new_turn(event) - instructions = self._get_instructions(session, rtm) # prepare prompt - prompt = thread.to_prompt(instructions) + instructions = self._get_instructions(session, rtm) + prompt = thread.to_prompt([Role.SYSTEM.new(content=instructions)]) pipes = self._get_prompt_pipes(session, rtm) prompt = run_prompt_pipeline(prompt, pipes) @@ -166,7 +173,7 @@ def _on_custom_event_handler( return fn(self.ghost, session, runtime, event), True return None, False - def _get_instructions(self, session: Session, runtime: MossRuntime) -> List[Message]: + def _get_instructions(self, session: Session, runtime: MossRuntime) -> str: """ generate moss agent's instruction :param session: @@ -175,7 +182,7 @@ def _get_instructions(self, session: Session, runtime: MossRuntime) -> List[Mess """ prompter = self._get_instruction_prompter(session, runtime) instruction = prompter.get_prompt(session.container, depth=0) - return [Role.SYSTEM.new(content=instruction)] + return instruction def _get_instruction_prompter(self, session: Session, runtime: MossRuntime) -> Prompter: agent = self.ghost diff --git a/ghostos/prompter.py b/ghostos/prompter.py index 56802c38..3c50183e 100644 --- a/ghostos/prompter.py +++ b/ghostos/prompter.py @@ -136,7 +136,7 @@ def get_prompt(self, container: Container, depth: int = 0) -> str: title = self.get_title() depth = depth if title: - title = '#' * (depth + 1) + ' ' + title + title = '#' * (depth + 2) + ' ' + title depth = depth + 1 self_prompt = self.self_prompt(container) diff --git a/ghostos/prototypes/streamlitapp/main.py b/ghostos/prototypes/streamlitapp/main.py index 40bc03bd..bb74c664 100644 --- a/ghostos/prototypes/streamlitapp/main.py +++ b/ghostos/prototypes/streamlitapp/main.py @@ -16,6 +16,8 @@ "main_run", ] +st.set_page_config(page_title='GhostOS') + SINGLETONS = List[Singleton] BOOTSTRAP = Callable[[], SINGLETONS] diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index f5fbd888..69c9e947 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -7,7 +7,7 @@ ) from ghostos.prototypes.streamlitapp.utils.session import Singleton from ghostos.prototypes.streamlitapp.widgets.messages import ( - render_message_in_content, render_message_payloads + render_message_in_content ) from ghostos.prototypes.streamlitapp.widgets.renderer import ( render_object, render_event, render_turn, @@ -28,8 +28,6 @@ logger = get_logger("ghostos") -st.set_page_config(page_title='Ghost', layout="wide") - def main_chat(): # create shell @@ -51,21 +49,13 @@ def main_chat(): conversation.update_thread(thread) st.rerun() - st.subheader("page options") - # input type + st.subheader("chat options") with st.container(border=True): - show_chatting = st.toggle("chat", value=True) auto_run = st.toggle( "auto run event", help="automatic run background event", value=True, ) - show_ghost_settings = st.toggle("ghost settings") - show_instruction = st.toggle("instructions") - show_context = st.toggle("context") - - st.subheader("inputs options") - with st.container(border=True): show_video = st.toggle("show video") show_image_file = st.toggle("upload image") @@ -77,30 +67,40 @@ def main_chat(): # header st.title("Ghost") - ghost = route.get_ghost() - id_ = get_identifier(ghost) - import_path = generate_import_path(ghost.__class__) - data = { - _("name"): id_.name, - _("desc"): id_.description, - _("class"): import_path, - } - # description - st.markdown(f""" + with st.container(border=True): + ghost = route.get_ghost() + id_ = get_identifier(ghost) + import_path = generate_import_path(ghost.__class__) + data = { + _("name"): id_.name, + _("desc"): id_.description, + _("class"): import_path, + } + if route.filename: + data[_("from")] = route.filename + # description + st.markdown(f""" ```yaml {yaml_pretty_dump(data)} ``` """) + col1, col2, col3, col4 = st.columns([1, 1, 1, 1]) + with col1: + show_chatting = st.toggle("chat", value=True) + with col2: + show_ghost_settings = st.toggle("settings") + with col3: + show_instruction = st.toggle("instructions") + with col4: + show_context = st.toggle("context") + # render ghost settings if show_ghost_settings: render_ghost_settings(route) - st.divider() if show_instruction: render_instruction(conversation) - st.divider() if show_context: render_context_settings(conversation) - st.divider() # inputs if show_chatting: @@ -205,68 +205,71 @@ def chunks_to_st_stream(chunks: Iterable[Message]) -> Iterable[str]: def render_ghost_settings(route: GhostChatRoute): st.subheader(_("Settings")) - if route is None: - st.error("page is not configured") - return - ghost = route.get_ghost() - # render ghost info - if isinstance(ghost, BaseModel): - data, submitted = srj.pydantic_instance_form(ghost) - if route.filename and submitted: - ghosts_conf = GhostsConf.load_from(route.filename) - key = ghosts_conf.file_ghost_key(route.filename) - info = GhostInfo(ghost=to_entity_meta(data)) - ghosts_conf.ghosts[key] = info - ghosts_conf.save(route.filename) - st.write("saved") - - else: - st.write(ghost) - source = inspect.getsource(ghost.__class__) - if st.toggle("source code", key="ghost_source_code"): - st.caption(generate_import_path(ghost.__class__)) - st.code(source) - render_empty() + with st.container(border=True): + if route is None: + st.error("page is not configured") + return + ghost = route.get_ghost() + # render ghost info + if isinstance(ghost, BaseModel): + data, submitted = srj.pydantic_instance_form(ghost) + if route.filename and submitted: + ghosts_conf = GhostsConf.load_from(route.filename) + key = ghosts_conf.file_ghost_key(route.filename) + info = GhostInfo(ghost=to_entity_meta(data)) + ghosts_conf.ghosts[key] = info + ghosts_conf.save(route.filename) + st.write("saved") + + else: + st.write(ghost) + source = inspect.getsource(ghost.__class__) + if st.toggle("source code", key="ghost_source_code"): + st.caption(generate_import_path(ghost.__class__)) + st.code(source) + render_empty() def render_instruction(conversation: Conversation): st.subheader("Instructions") - driver = conversation.get_ghost_driver() + instructions = conversation.get_instructions() + with st.container(border=True): + st.markdown(instructions) def render_context_settings(conversation: Conversation): st.subheader("Context") - ctx = conversation.get_context() - ghost = conversation.get_ghost() - if ctx is None and ghost.ContextType is None: - st.info("No specific Context for this Ghost") - return - if ctx is None: - if ghost.ContextType is not None: - data, submitted = srj.pydantic_form(ghost.ContextType) - if submitted and isinstance(data, Context): + with st.container(border=True): + ctx = conversation.get_context() + ghost = conversation.get_ghost() + if ctx is None and ghost.ContextType is None: + st.info("No specific Context for this Ghost") + return + if ctx is None: + if ghost.ContextType is not None: + data, submitted = srj.pydantic_form(ghost.ContextType) + if submitted and isinstance(data, Context): + conversation.update_context(data) + ctx = data + else: + data, changed = render_object(ctx, immutable=False) + if changed and isinstance(data, Context): conversation.update_context(data) ctx = data - else: - data, changed = render_object(ctx, immutable=False) - if changed and isinstance(data, Context): - conversation.update_context(data) - ctx = data - - # render prompt - if ctx is not None: - st.subheader(_("Context prompt")) - try: - prompt = ctx.get_prompt(conversation.container()) - st.markdown(prompt) - except Exception as e: - st.error(e) - # render artifact - if ghost.ArtifactType: - st.subheader(_("Artifact")) - artifact = conversation.get_artifact() - render_object(artifact) - render_empty() + + # render prompt + if ctx is not None: + st.subheader(_("Context prompt")) + try: + prompt = ctx.get_prompt(conversation.container()) + st.markdown(prompt) + except Exception as e: + st.error(e) + # render artifact + if ghost.ArtifactType: + st.subheader(_("Artifact")) + artifact = conversation.get_artifact() + render_object(artifact) def render_task_info_settings(task: GoTaskStruct, thread: GoThreadInfo): diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py index fc009104..5130bdde 100644 --- a/ghostos/prototypes/streamlitapp/widgets/messages.py +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -63,6 +63,7 @@ def render_message_payloads(message: Message, debug: bool, prefix: str = ""): ) if not debug: + st.empty() return items = [sac.ButtonsItem(label="Detail")] task_payload = TaskPayload.read_payload(message) @@ -106,6 +107,7 @@ def render_message_in_content(message: Message, debug: bool, prefix: str = ""): if message.callers: render_message_caller(message.callers, debug) render_message_payloads(message, debug, prefix) + st.empty() def render_message_caller_output(message: Message, debug: bool): From dc2690ca6da6c656b350faf4a7107a1dfb9a21fb Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 24 Nov 2024 17:54:06 +0800 Subject: [PATCH 104/148] fix: fix unittests --- ghostos/framework/tasks/storage_tasks.py | 2 +- ghostos/prompter.py | 2 +- tests/framework/llms/test_llms_config.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ghostos/framework/tasks/storage_tasks.py b/ghostos/framework/tasks/storage_tasks.py index a0c3e1bf..2ca5d533 100644 --- a/ghostos/framework/tasks/storage_tasks.py +++ b/ghostos/framework/tasks/storage_tasks.py @@ -113,7 +113,7 @@ def get_task_briefs(self, task_ids: List[str], states: Optional[List[TaskState]] for task in self.get_tasks(task_ids, states): yield TaskBrief.from_task(task) - def lock_task(self, task_id: str, overdue: float) -> TaskLocker: + def lock_task(self, task_id: str, overdue: float = 30) -> TaskLocker: return SimpleStorageLocker(self._storage, task_id, overdue) diff --git a/ghostos/prompter.py b/ghostos/prompter.py index 3c50183e..56802c38 100644 --- a/ghostos/prompter.py +++ b/ghostos/prompter.py @@ -136,7 +136,7 @@ def get_prompt(self, container: Container, depth: int = 0) -> str: title = self.get_title() depth = depth if title: - title = '#' * (depth + 2) + ' ' + title + title = '#' * (depth + 1) + ' ' + title depth = depth + 1 self_prompt = self.self_prompt(container) diff --git a/tests/framework/llms/test_llms_config.py b/tests/framework/llms/test_llms_config.py index 9c8e118f..c6bd739a 100644 --- a/tests/framework/llms/test_llms_config.py +++ b/tests/framework/llms/test_llms_config.py @@ -68,7 +68,7 @@ def test_llms(): 存在文件依赖关系. """ container: Container = _prepare_container() - container.register(ConfigBasedLLMsProvider("llms_conf.yml")) + container.register(ConfigBasedLLMsProvider()) llms = container.force_fetch(LLMs) api = llms.get_api("gpt-4") From dae1c32745d0e7db7b8ea64af564e112c5b87bd2 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 25 Nov 2024 03:33:17 +0800 Subject: [PATCH 105/148] dev: implements sphero gpt, some bugs occur --- .ghostos/configs/streamlit_app.yml | 2 +- examples/agents/sphero_bolt_gpt.py | 40 ++++ ghostos/abcd/concepts.py | 7 +- ghostos/container.py | 5 +- ghostos/core/messages/transport.py | 8 +- ghostos/core/moss/prompts.py | 22 +- ghostos/core/runtime/tasks.py | 3 + .../framework/ghostos/conversation_impl.py | 35 ++- ghostos/framework/ghostos/session_impl.py | 20 +- ghostos/ghosts/moss_agent/agent.py | 6 +- ghostos/ghosts/moss_agent/for_meta_ai.py | 2 +- ghostos/prototypes/spherogpt/__init__.py | 0 ghostos/prototypes/spherogpt/bolt.py | 210 ++++++++++++++++++ .../streamlitapp/cli/run_ghost_chat.py | 4 +- .../prototypes/streamlitapp/pages/ghosts.py | 6 +- pyproject.toml | 7 +- 16 files changed, 339 insertions(+), 38 deletions(-) create mode 100644 examples/agents/sphero_bolt_gpt.py create mode 100644 ghostos/prototypes/spherogpt/__init__.py create mode 100644 ghostos/prototypes/spherogpt/bolt.py diff --git a/.ghostos/configs/streamlit_app.yml b/.ghostos/configs/streamlit_app.yml index ba7b6af7..d026c718 100644 --- a/.ghostos/configs/streamlit_app.yml +++ b/.ghostos/configs/streamlit_app.yml @@ -1,6 +1,6 @@ # from class: ghostos.prototypes.streamlitapp.resources:AppConf bool_options: - DEBUG_MODE: false + DEBUG_MODE: true HELP_MODE: false domain: ghostos lang: zh diff --git a/examples/agents/sphero_bolt_gpt.py b/examples/agents/sphero_bolt_gpt.py new file mode 100644 index 00000000..2860b850 --- /dev/null +++ b/examples/agents/sphero_bolt_gpt.py @@ -0,0 +1,40 @@ +from ghostos.prototypes.spherogpt.bolt import Command, SpheroBolt, SpheroEduAPI, exports +from ghostos.core.moss import Moss as Parent + + +class Moss(Parent): + bolt: SpheroBolt + """bolt controller""" + + +# +from ghostos.ghosts.moss_agent import MossAgent +from typing import TYPE_CHECKING + + +def __moss_attr_prompts__(): + yield "MossAgent", "" + yield from exports.items() + + +def __moss_agent_providers__(agent): + from ghostos.prototypes.spherogpt.bolt import SpheroBoltProvider + return [SpheroBoltProvider()] + + +__ghost__ = MossAgent( + name="SpheroGPT", + description="Sphero Bolt agent that control Sphero bolt as its body", + persona=""" +You are SpheroGPT, a toy robot that body is a ball. +You can roll, spin, and equiped with a 8*8 led light martix. +Your goal is to plesure human users, especially kids, who like you verymuch. +""", + instructions=""" +1. chat with user kindly. +2. follow the order and turn your actions to code with your ball body. +""", + moss_module=__name__ +) + +# diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index c5d379c0..70fb1d33 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -127,7 +127,8 @@ def get_instructions(self, session: Session) -> str: @abstractmethod def providers(self) -> Iterable[Provider]: """ - ghost return session level container providers + ghost return conversation level container providers. + the provider that is not singleton will bind to session also. """ pass @@ -431,6 +432,10 @@ def close(self): """ pass + @abstractmethod + def available(self) -> bool: + pass + @abstractmethod def closed(self) -> bool: """ diff --git a/ghostos/container.py b/ghostos/container.py index aceb2005..d92dcfa4 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -488,7 +488,10 @@ class BootstrapProvider(Generic[INSTANCE], Provider[INSTANCE], Bootstrapper, met """ 将 bootstrapper 和 Provider 可以融合在一起. """ - pass + + @abstractmethod + def contract(self) -> Type[INSTANCE]: + pass class ProviderAdapter(Provider): diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index c8d7b8e2..bd1c73c2 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -307,8 +307,11 @@ def __init__(self, head: Message, receiver: Iterator[Message]): @classmethod def new(cls, receiver: Iterable[Message]) -> Optional[Self]: - iterator = iter(receiver) - head = next(iterator) + try: + iterator = iter(receiver) + head = next(iterator) + except StopIteration: + return None if head is None: return None return cls(head, iterator) @@ -369,7 +372,6 @@ def next(self) -> Optional[Self]: return self._next - def new_basic_connection( *, timeout: float = -1, diff --git a/ghostos/core/moss/prompts.py b/ghostos/core/moss/prompts.py index 79812d35..2165dcb7 100644 --- a/ghostos/core/moss/prompts.py +++ b/ghostos/core/moss/prompts.py @@ -28,7 +28,9 @@ """ __all__ = [ - 'get_prompt', 'reflect_module_locals', 'join_prompt_lines', 'compile_attr_prompts', + 'get_prompt', + 'reflect_module_locals', 'reflect_class_with_methods', + 'join_prompt_lines', 'compile_attr_prompts', 'get_defined_prompt', 'AttrPrompts', ] @@ -87,6 +89,24 @@ def reflect_module_locals( yield name, prompt +def reflect_class_with_methods(cls: type) -> str: + """ + reflect class with all its method signatures. + """ + from inspect import getsource + from .utils import make_class_prompt, get_callable_definition + source = getsource(cls) + attrs = [] + for name in dir(cls): + if name.startswith("_"): + continue + method = getattr(cls, name) + if inspect.ismethod(method) or inspect.isfunction(method): + block = get_callable_definition(method) + attrs.append(block) + return make_class_prompt(source=source, attrs=attrs) + + def reflect_module_attr( name: str, value: Any, diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index 9f7ae5ec..781907ec 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -226,6 +226,9 @@ def identifier(self) -> Identifier: description=self.purpose, ) + def shall_notifiy(self) -> bool: + return self.depth > 0 + def is_dead(self) -> bool: return TaskState.is_dead(self.state) diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index 6f6a3173..70715751 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -13,10 +13,11 @@ GoThreadInfo, GoThreads, ) from ghostos.contracts.pool import Pool -from ghostos.contracts.logger import LoggerItf, get_ghostos_logger +from ghostos.contracts.logger import get_ghostos_logger from ghostos.entity import to_entity_meta, get_entity from pydantic import BaseModel, Field from .session_impl import SessionImpl +from threading import Lock __all__ = ["ConversationImpl", "ConversationConf", "Conversation"] @@ -65,11 +66,18 @@ def __init__( self._tasks = container.force_fetch(GoTasks) self._threads = container.force_fetch(GoThreads) self._eventbus = container.force_fetch(EventBus) + self._handling_event = False + self._mutex = Lock() self._closed = False self._shell_closed = shell_closed self._bootstrap() def _bootstrap(self): + providers = self.get_ghost_driver().providers() + # bind self + self._container.set(Conversation, self) + for provider in providers: + self._container.register(provider) self._container.bootstrap() @property @@ -159,6 +167,8 @@ def respond_event( timeout: float = 0.0, ) -> Receiver: self._validate_closed() + if self._handling_event: + raise RuntimeError("conversation is handling event") # complete task_id if not event.task_id: event.task_id = self._scope.task_id @@ -180,8 +190,8 @@ def _validate_closed(self): def _submit_session_event(self, event: Event, stream: Stream) -> None: self.logger.debug("submit session event") - with stream: - try: + try: + with stream: task = self._tasks.get_task(event.task_id) session = self._create_session(task, self._locker, stream) self.logger.debug( @@ -190,11 +200,12 @@ def _submit_session_event(self, event: Event, stream: Stream) -> None: ) with session: run_session_event(session, event, self._conf.max_session_step) - except Exception as e: - if not self.fail(error=e): - raise - finally: - self._eventbus.notify_task(event.task_id) + except Exception as e: + if not self.fail(error=e): + raise + finally: + self._eventbus.notify_task(event.task_id) + self._handling_event = False def _create_session( self, @@ -202,9 +213,8 @@ def _create_session( locker: TaskLocker, stream: Optional[Stream], ) -> SessionImpl: - container = Container(parent=self._container) return SessionImpl( - container=container, + container=self.container(), stream=stream, task=task, locker=locker, @@ -248,3 +258,8 @@ def _destroy(self): def closed(self) -> bool: return self._closed or self._shell_closed() + + def available(self) -> bool: + if self.closed() or self._handling_event: + return False + return True diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 78896f9e..d3adcecd 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -53,10 +53,10 @@ def __init__( max_errors: int, ): # session level container - self.container = container + self.container = Container(parent=container) self.upstream = stream self.task = task - self.locker = locker + self._task_locker = locker threads = container.force_fetch(GoThreads) thread = threads.get_thread(task.thread_id, create=True) self.thread = thread @@ -88,9 +88,9 @@ def __init__( self._destroyed = False self._saved = False self._bootstrap() - self._thread_locker = Lock() if not self.refresh(): raise RuntimeError(f"Failed to start session") + self._thread_locker = Lock() Session.instance_count += 1 @property @@ -112,9 +112,6 @@ def _bootstrap(self): self.container.register(provide(Taskflow, False)(lambda c: self.taskflow())) self.container.register(provide(Subtasks, False)(lambda c: self.subtasks())) self.container.register(provide(Messenger, False)(lambda c: self.messenger())) - # bind ghost providers. - for provider in self.ghost_driver.providers(): - self.container.register(provider) self.container.bootstrap() @staticmethod @@ -128,7 +125,7 @@ def unmarshal_state(task: GoTaskStruct) -> Dict[str, EntityType]: def is_alive(self) -> bool: if self._failed or self._destroyed: return False - return self.locker.acquired() and (self.upstream is None or self.upstream.alive()) + return self._task_locker.acquired() and (self.upstream is None or self.upstream.alive()) def _validate_alive(self): if not self.is_alive(): @@ -215,7 +212,7 @@ def get_instructions(self) -> str: def refresh(self) -> bool: if self._failed or self._destroyed or not self.is_alive(): return False - if self.locker.refresh(): + if self._task_locker.refresh(): self._saved = False return True return False @@ -307,10 +304,10 @@ def get_task_briefs(self, *task_ids: str) -> Dict[str, TaskBrief]: return result def save(self) -> None: - if self._saved: + if self._saved or self._destroyed: return self._saved = True - self.logger.debug("saving session on %s", self.scope.model_dump()) + self.logger.info("saving session on %s", self.scope.model_dump()) self._validate_alive() self._update_subtasks() self._update_state_changes() @@ -382,6 +379,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): + self.logger.info("session exited") if exc_val is not None: intercepted = self.fail(exc_val) self.destroy() @@ -405,7 +403,7 @@ def destroy(self) -> None: if self._destroyed: return self._destroyed = True - del self.locker + del self._task_locker self.container.destroy() del self.container del self._firing_events diff --git a/ghostos/ghosts/moss_agent/agent.py b/ghostos/ghosts/moss_agent/agent.py index d17a0314..4d47857b 100644 --- a/ghostos/ghosts/moss_agent/agent.py +++ b/ghostos/ghosts/moss_agent/agent.py @@ -35,7 +35,7 @@ class MossAgent(ModelEntity, Agent): moss_module: str = Field(description="Moss module name for the agent") persona: str = Field(description="Persona for the agent, if not given, use global persona") - instruction: str = Field(description="The instruction that the agent should follow") + instructions: str = Field(description="The instruction that the agent should follow") # optional configs name: str = Field(default="", description="name of the agent") @@ -323,7 +323,7 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: # if code is not exists, inform the llm if not code: return self.fire_error(session, caller, "the moss code is empty") - session.logger.info("moss action code: %s", code) + session.logger.debug("moss action code: %s", code) error = self.runtime.lint_exec_code(code) if error: @@ -342,7 +342,7 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: # handle std output std_output = result.std_output - session.logger.info("moss action std_output: %s", std_output) + session.logger.debug("moss action std_output: %s", std_output) if std_output: output = f"Moss output:\n{std_output}" message = caller.new_output(output) diff --git a/ghostos/ghosts/moss_agent/for_meta_ai.py b/ghostos/ghosts/moss_agent/for_meta_ai.py index 9a12aedb..b4fc62a9 100644 --- a/ghostos/ghosts/moss_agent/for_meta_ai.py +++ b/ghostos/ghosts/moss_agent/for_meta_ai.py @@ -28,7 +28,7 @@ def __moss_agent_persona__(agent: MossAgent, moss: Moss) -> str: def __moss_agent_instruction__(agent: MossAgent, moss: Moss) -> str: - return agent.instruction + return agent.instructions def __moss_agent_thought__(agent: MossAgent, moss: Moss, *actions: Action) -> Thought: diff --git a/ghostos/prototypes/spherogpt/__init__.py b/ghostos/prototypes/spherogpt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/spherogpt/bolt.py b/ghostos/prototypes/spherogpt/bolt.py new file mode 100644 index 00000000..433d1a2a --- /dev/null +++ b/ghostos/prototypes/spherogpt/bolt.py @@ -0,0 +1,210 @@ +import time + +try: + import spherov2 +except ImportError: + exit("This script requires the spherov2 to be installed.") + +from spherov2 import scanner +from spherov2.toy import bolt + +from abc import ABC, abstractmethod +from typing import Optional +from spherov2.sphero_edu import SpheroEduAPI +from pydantic import BaseModel, Field +from ghostos.helpers import Timeleft +from ghostos.abcd import Conversation +from ghostos.core.runtime import EventBus, EventTypes +from ghostos.core.messages import MessageType +from ghostos.core.moss.prompts import reflect_class_with_methods, get_prompt +from ghostos.container import BootstrapProvider, Container +from threading import Thread + +__all__ = [ + 'Command', + 'SpheroBolt', + 'SpheroEduAPI', + 'SpheroBoltProvider', + 'exports', +] + + +class Command(BaseModel): + """ + Sphero Bolt Command that execute frame by frame in time. + """ + name: str = Field(description="aim of the command in simple words") + duration: float = Field( + description="the command running duration in seconds. " + "when the duration tick out, the bolt stop." + "if duration is 0, execute once.") + code: str = Field(description="the command code") + execution_log: str = Field(default="", description="the command execution log, only used at the command runtime") + + def run_frame(self, api: SpheroEduAPI, passed: float, frame: int) -> None: + """ + run a single frame every tick + :param api: SpheroEduAPI that controll the sphero bolt + :param passed: the passed time from command start to now + :param frame: the frame number, frame == 0 means the command is starting + :return: None + """ + # eval the python code defined in the command. + eval(self.code) + + +class SpheroBolt(ABC): + """ + Sphero Bolt interface + """ + + @abstractmethod + def run(self, *commands: Command) -> None: + """ + run command on sphero bolt. will always stop movement at beginning and end of the execution time. + :param commands: the commands, could be a pipeline + :param tick: the interval time between frames in secondes + :return: None, but will send message after the running. + """ + pass + + +class SpheroBoltImpl(SpheroBolt): + + def __init__( + self, + eventbus: EventBus, + task_id: str, + notify: bool, + tick_interval: float = 0.01, + ): + self._executing_command: Optional[Command] = None + self._command_stack: List[Command] = [] + self._timeleft: Optional[Timeleft] = None + self._executing: bool = False + self._task_id: str = task_id + self._notify: bool = notify + self._eventbus = eventbus + self._destroyed = False + self._main_thread = Thread(target=self._main) + self._tick_interval = tick_interval + self._ticked_frames: int = 0 + self._bolt: Optional[bolt] = None + + def bootstrap(self): + try: + self._bolt = scanner.find_BOLT() + if self._bolt is not None: + self._main_thread.start() + except Exception as e: + raise NotImplementedError("Could not find the Bolt device. " + str(e)) + + def destroy(self) -> None: + if self._destroyed: + return + self._destroyed = True + if self._bolt is not None: + self._main_thread.join() + del self._bolt + del self._eventbus + + def __del__(self): + self.destroy() + + def _clear_command(self): + self._executing_command = None + self._timeleft = None + self._ticked_frames = 0 + self._executing = False + + def _command_succeeded(self): + self._reset_command_at("succeeded") + + def _reset_command_at(self, action: str): + if self._executing_command is None or self._timeleft is None: + return + name = self._executing_command.name + passed = self._timeleft.passed() + content = f"command `{name}` {action} after running `{round(passed, 4)}` second" + self._clear_command() + event = EventTypes.NOTIFY.new( + task_id=self._task_id, + messages=[MessageType.TEXT.new_system(content=content)] + ) + self._eventbus.send_event(event, self._notify) + + def _main(self) -> None: + with SpheroEduAPI(self._bolt) as api: + while not self._destroyed: + if self._executing_command and self._timeleft: + if self._executing_command.duration <= 0: + self._executing_command.run_frame(api, 0, 0) + self._command_succeeded() + continue + elif self._timeleft.alive(): + self._executing_command.run_frame(api, self._timeleft.passed(), self._ticked_frames) + self._ticked_frames += 1 + time.sleep(self._tick_interval) + continue + else: + self._command_succeeded() + elif len(self._command_stack) > 0: + current: Command = self._command_stack.pop(0) + self._executing = True + self._executing_command = current + self._timeleft = Timeleft(current.duration) + self._ticked_frames = 0 + else: + time.sleep(0.5) + api.stop_roll() + + def run(self, *commands: Command) -> None: + if self._bolt is None: + raise RuntimeError(f"Sphero Bolt is not initialized.") + if self._executing: + self._reset_command_at("stop during new command") + commands = list(commands) + if len(commands) == 0: + return + self._command_stack = commands + + +class SpheroBoltProvider(BootstrapProvider): + """ + Sphero Bolt Provider interface + """ + + def singleton(self) -> bool: + return True + + def contract(self): + return SpheroBolt + + def factory(self, con: Container) -> Optional[SpheroBolt]: + conversation = con.force_fetch(Conversation) + eventbus = con.force_fetch(EventBus) + task = conversation.task() + return SpheroBoltImpl( + eventbus, + task_id=task.task_id, + notify=task.shall_notifiy(), + tick_interval=0.01, + ) + + def bootstrap(self, container: Container) -> None: + sphero_bolt = container.force_fetch(SpheroBolt) + if isinstance(sphero_bolt, SpheroBoltImpl): + sphero_bolt.bootstrap() + container.add_shutdown(sphero_bolt.destroy) + + +exports = { + Command.__name__: get_prompt(Command), + SpheroBolt.__name__: get_prompt(SpheroBolt), + SpheroEduAPI.__name__: reflect_class_with_methods(SpheroEduAPI), +} + +if __name__ == "__main__": + from ghostos.helpers import yaml_pretty_dump + + print(yaml_pretty_dump(exports)) diff --git a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py index c1e94341..6389139d 100644 --- a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py +++ b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py @@ -9,7 +9,7 @@ from ghostos.prototypes.streamlitapp.main import main_run from ghostos.prototypes.streamlitapp.pages.router import default_router, GhostChatRoute from ghostos.prototypes.streamlitapp.utils.session import Singleton -from ghostos.abcd import Shell, Background +from ghostos.abcd import Shell, Background, Conversation import streamlit as st import sys import json @@ -70,7 +70,9 @@ def bootstrap(): logger.debug("start shell background run") shell = ghostos.create_shell("ghostos_streamlit_app", "ghostos_streamlit_app") shell.background_run(4, StreamlitBackgroundApp()) + conversation = shell.sync(page_route.get_ghost(), page_route.get_context()) Singleton(shell, Shell).bind(st.session_state) + Singleton(conversation, Conversation).bind(st.session_state) return [ Singleton(container), diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index 69c9e947..08f5a877 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -117,7 +117,7 @@ def main_chat(): def get_conversation(route: GhostChatRoute) -> Conversation: conversation = Singleton.get(Conversation, st.session_state, force=False) - if not conversation: + if not conversation or conversation.closed(): shell = Singleton.get(Shell, st.session_state) # create conversation conversation = shell.sync(route.get_ghost(), route.get_context()) @@ -148,8 +148,7 @@ def chatting(route: GhostChatRoute, conversation: Conversation, inputs: List[Mes render_event(event, debug) render_receiver(receiver, debug) - render_empty() - while not route.input_type and rotate and not conversation.closed(): + while not route.input_type and rotate and conversation.available(): if event := conversation.pop_event(): render_event(event, debug) receiver = conversation.respond_event(event) @@ -297,6 +296,7 @@ def render_thread_messages(thread: GoThreadInfo, max_turn: int = 20): if count == 0: st.info("No thread messages yet") render_empty() + render_empty() def render_event_object(event: Event, debug: bool): diff --git a/pyproject.toml b/pyproject.toml index 20f6c15d..6c4e8378 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "MIT" readme = "README.md" [tool.poetry.dependencies] -python = "^3.12" +python = ">=3.10,<3.14" pydantic = "^2.7.0" pytest = "^8.1.1" openai = "^1.19.0" @@ -21,7 +21,6 @@ tree-sitter = "0.21.3" tree-sitter-languages = "^1.10.2" networkx = "^3.3" litellm = "^1.43.18" -hide-py = "^0.3.0" prompt-toolkit = "^3.0.47" arxiv = "^2.1.3" llama-index-core = "^0.11.9" @@ -47,6 +46,10 @@ aifunc = "ghostos.scripts.cli.run_aifunc:main" console = "ghostos.scripts.cli.run_console:main" ghost = "ghostos.scripts.cli.run_streamlit_ghost:main" +[tool.poetry.group.dev.dependencies] +spherov2 = "^0.12.1" +bleak = "^0.22.3" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" From e3f31a4cd9c9b3be96fc9a30b17d30991eda5bc8 Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 26 Nov 2024 18:14:08 +0800 Subject: [PATCH 106/148] dev: fix sphero gpt bugs from session and conversation. more tests to go --- examples/agents/sphero_bolt_gpt.py | 10 ++ ghostos/abcd/concepts.py | 1 + ghostos/bootstrap.py | 8 +- ghostos/container.py | 18 ++- ghostos/core/messages/transport.py | 6 +- ghostos/core/moss/impl.py | 2 +- ghostos/core/runtime/events.py | 2 +- ghostos/errors.py | 12 ++ ghostos/framework/eventbuses/memimpl.py | 1 - .../framework/ghostos/conversation_impl.py | 43 +++++-- ghostos/framework/ghostos/ghostos_impl.py | 5 +- ghostos/framework/ghostos/session_impl.py | 24 ++-- ghostos/framework/ghostos/shell_impl.py | 65 ++++++++--- ghostos/framework/ghostos/taskflow_impl.py | 3 +- ghostos/framework/tasks/storage_tasks.py | 1 + ghostos/ghosts/moss_agent/agent.py | 4 +- ghostos/ghosts/moss_agent/instructions.py | 5 +- ghostos/prototypes/spherogpt/bolt.py | 109 ++++++++++-------- .../streamlitapp/cli/run_ghost_chat.py | 2 - .../prototypes/streamlitapp/pages/ghosts.py | 13 +-- .../streamlitapp/widgets/renderer.py | 2 +- tests/framework/ghostos/test_session.py | 1 + tests/framework/tasks/test_storage_impl.py | 17 +++ tests/python/test_context.py | 20 ++++ tests/test_container.py | 28 ++++- 25 files changed, 280 insertions(+), 122 deletions(-) create mode 100644 ghostos/errors.py create mode 100644 tests/python/test_context.py diff --git a/examples/agents/sphero_bolt_gpt.py b/examples/agents/sphero_bolt_gpt.py index 2860b850..d5f024fd 100644 --- a/examples/agents/sphero_bolt_gpt.py +++ b/examples/agents/sphero_bolt_gpt.py @@ -3,10 +3,20 @@ class Moss(Parent): + bolt: SpheroBolt """bolt controller""" +def example_spin_the_bolt(moss: Moss): + moss.bolt.run(Command( + name="spin bolt", + code=""" +api.spin(360, 1) +""" + )) + + # from ghostos.ghosts.moss_agent import MossAgent from typing import TYPE_CHECKING diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index 70fb1d33..ec43bf29 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -336,6 +336,7 @@ class Conversation(Protocol[G]): """ interface for operate on synchronized (task is locked) ghost """ + task_id: str @abstractmethod def container(self) -> Container: diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 84cd877b..3471aaae 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -1,12 +1,10 @@ from typing import List, Optional, Tuple from os.path import dirname, join from ghostos.abcd import GhostOS -from ghostos.contracts.logger import config_logging from ghostos.container import Container, Provider, Contracts from ghostos.prototypes.ghostfunc import init_ghost_func, GhostFunc import dotenv -import os # Core Concepts # @@ -108,7 +106,6 @@ def default_application_contracts() -> Contracts: """ from ghostos.core.moss import MossCompiler from ghostos.core.messages.openai import OpenAIMessageParser - from ghostos.contracts.pool import Pool from ghostos.contracts.shutdown import Shutdown from ghostos.contracts.modules import Modules from ghostos.contracts.workspace import Workspace @@ -131,7 +128,6 @@ def default_application_contracts() -> Contracts: Variables, # system contracts - Pool, # multi-thread or process pool to submit async tasks Shutdown, # graceful shutdown register LLMs, # LLMs interface PromptStorage, @@ -174,7 +170,6 @@ def default_application_providers( application default providers todo: use manager provider to configurate multiple kinds of implementation """ - from ghostos.contracts.pool import DefaultPoolProvider from ghostos.contracts.shutdown import ShutdownProvider from ghostos.contracts.modules import DefaultModulesProvider from ghostos.core.moss import DefaultMOSSProvider @@ -213,7 +208,6 @@ def default_application_providers( # --- session ---# MsgThreadsRepoByWorkSpaceProvider(runtime_threads_dir), - DefaultPoolProvider(100), MemEventBusImplProvider(), # --- moss --- # @@ -255,7 +249,7 @@ def make_app_container( app_contracts = default_application_contracts() # prepare application container - _container = Container() + _container = Container(name="ghostos_root") _container.register(*app_providers) # contracts validation app_contracts.validate(_container) diff --git a/ghostos/container.py b/ghostos/container.py index d92dcfa4..93f53de5 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -142,15 +142,24 @@ class Container(IoCContainer): - 仍然需要考虑加入 RAG Memories 来支持. 获取做到 OS 层. """ instance_count: ClassVar[int] = 0 + bloodline: List[str] - def __init__(self, parent: Optional[Container] = None, inherit: bool = True): + def __init__(self, parent: Optional[Container] = None, *, name: str = "", inherit: bool = True): + self.bloodline = [] # container extended by children container if parent is not None: if not isinstance(parent, Container): raise AttributeError("container can only initialized with parent Container") if parent is self: raise AttributeError("container's parent must not be itself") - self.parent = parent + self.parent: Optional[Container] = parent + if isinstance(self.parent, Container): + bloodline = self.parent.bloodline.copy() + bloodline.append(name) + else: + bloodline = [name] + self.bloodline: List[str] = bloodline + # global singletons. self._instances: Dict[Any, Any] = {} self._factory: Dict[Any, Factory] = {} @@ -400,7 +409,7 @@ def providers(self, recursively: bool = True) -> Iterable[Provider]: def _check_destroyed(self) -> None: if self._destroyed: - raise RuntimeError("container is called after destroyed") + raise RuntimeError(f"container {self.bloodline} is called after destroyed") def destroy(self) -> None: """ @@ -564,7 +573,8 @@ def __init__(self, contracts: List[ABSTRACT]): def validate(self, container: Container) -> None: for contract in self.contracts: if not container.bound(contract): - raise NotImplementedError(f'Contract {contract} not bound to container') + call_at = get_caller_info(2) + raise NotImplementedError(f'Contract {contract} not bound to container: {call_at}') def join(self, target: Contracts) -> Contracts: abstracts = set(self.contracts) diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index bd1c73c2..abfea631 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -257,9 +257,11 @@ def send(self, messages: Iterable[Message]) -> bool: self._alive = False self._error = self._receiver.error() if self._error is not None: - raise RuntimeError(f"upstream is closed: {self._error.get_content()}") + raise RuntimeError(f"stream is failed: {self._error.get_content()}") + elif not self.alive(): + raise RuntimeError(f"stream is closed") else: - raise RuntimeError(f"send upstream failed") + raise RuntimeError(f"send stream failed") return True def completes_only(self) -> bool: diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index f2138044..cb2fe246 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -21,7 +21,7 @@ class MossCompilerImpl(MossCompiler): def __init__(self, *, container: Container, pycontext: Optional[PyContext] = None): - self._container = Container(parent=container) + self._container = Container(parent=container, name="moss") self._pycontext = pycontext if pycontext else PyContext() self._modules: Modules = self._container.force_fetch(Modules) self._predefined_locals: Dict[str, Any] = { diff --git a/ghostos/core/runtime/events.py b/ghostos/core/runtime/events.py index ef530410..4498f445 100644 --- a/ghostos/core/runtime/events.py +++ b/ghostos/core/runtime/events.py @@ -125,7 +125,7 @@ def new( type_ = event_type payloads = payloads if payloads is not None else {} return cls( - id=id_, + event_id=id_, type=type_, task_id=task_id, from_task_id=from_task_id, diff --git a/ghostos/errors.py b/ghostos/errors.py new file mode 100644 index 00000000..c3fda41d --- /dev/null +++ b/ghostos/errors.py @@ -0,0 +1,12 @@ +class SessionError(RuntimeError): + """ + Session level exception, which is able to recovery + """ + pass + + +class ConversationError(RuntimeError): + """ + Conversation level exception, conversation shall be closed + """ + pass diff --git a/ghostos/framework/eventbuses/memimpl.py b/ghostos/framework/eventbuses/memimpl.py index 46c28944..c61130e0 100644 --- a/ghostos/framework/eventbuses/memimpl.py +++ b/ghostos/framework/eventbuses/memimpl.py @@ -34,7 +34,6 @@ def _send_task_event(self, e: Event) -> None: self._task_queues[task_id] = Queue() queue = self._task_queues[task_id] queue.put(event_id) - queue.task_done() def pop_task_event(self, task_id: str) -> Optional[Event]: if task_id not in self._task_queues: diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index 70715751..05aaffd7 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -3,6 +3,7 @@ from ghostos.container import Container from ghostos.abcd import Conversation, Scope, Ghost, Context from ghostos.abcd import run_session_event +from ghostos.errors import SessionError from ghostos.core.messages import ( Message, Role, Stream, Receiver, new_basic_connection, @@ -13,11 +14,11 @@ GoThreadInfo, GoThreads, ) from ghostos.contracts.pool import Pool -from ghostos.contracts.logger import get_ghostos_logger +from ghostos.contracts.logger import LoggerItf from ghostos.entity import to_entity_meta, get_entity from pydantic import BaseModel, Field from .session_impl import SessionImpl -from threading import Lock +from threading import Lock, Thread __all__ = ["ConversationImpl", "ConversationConf", "Conversation"] @@ -52,7 +53,9 @@ def __init__( shell_closed: Callable[[], bool], ): self._conf = conf - self._container = container + self.task_id = task.task_id + self._container = Container(parent=container, name="conversation") + self.logger = self._container.force_fetch(LoggerItf) self._scope = Scope( shell_id=task.shell_id, process_id=task.process_id, @@ -66,6 +69,7 @@ def __init__( self._tasks = container.force_fetch(GoTasks) self._threads = container.force_fetch(GoThreads) self._eventbus = container.force_fetch(EventBus) + self._submit_session_thread: Optional[Thread] = None self._handling_event = False self._mutex = Lock() self._closed = False @@ -80,10 +84,6 @@ def _bootstrap(self): self._container.register(provider) self._container.bootstrap() - @property - def logger(self): - return get_ghostos_logger(self._scope.model_dump()) - def container(self) -> Container: self._validate_closed() return self._container @@ -149,6 +149,9 @@ def respond( history: Optional[List[Message]] = None, ) -> Tuple[Event, Receiver]: self._validate_closed() + if self._submit_session_thread: + self._submit_session_thread.join() + self._submit_session_thread = None context_meta = to_entity_meta(context) if context is not None else None if self._ctx is not None: context_meta = to_entity_meta(self._ctx) @@ -179,7 +182,8 @@ def respond_event( idle=self._conf.message_receiver_idle, complete_only=self._is_background, ) - self._pool.submit(self._submit_session_event, event, stream) + self._submit_session_thread = Thread(target=self._submit_session_event, args=(event, stream,)) + self._submit_session_thread.start() return retriever def _validate_closed(self): @@ -191,6 +195,7 @@ def _validate_closed(self): def _submit_session_event(self, event: Event, stream: Stream) -> None: self.logger.debug("submit session event") try: + self._handling_event = True with stream: task = self._tasks.get_task(event.task_id) session = self._create_session(task, self._locker, stream) @@ -206,6 +211,7 @@ def _submit_session_event(self, event: Event, stream: Stream) -> None: finally: self._eventbus.notify_task(event.task_id) self._handling_event = False + self._submit_session_thread = None def _create_session( self, @@ -234,9 +240,16 @@ def send_event(self, event: Event) -> None: def fail(self, error: Exception) -> bool: if self._closed: return False - self.logger.exception(error) + if isinstance(error, SessionError): + self.logger.info(f"receive session stop error: {error}") + return False + elif isinstance(error, IOError): + self.logger.exception(f"receive IO error: {error}") + return False + # otherwise, close the whole thing. + self.logger.exception(f"receive IO error: {error}") self.close() - return True + return False def __del__(self): self.close() @@ -245,10 +258,16 @@ def close(self): if self._closed: return self._closed = True + self.logger.info("conversation %s is closing", self.task_id) + self._handling_event = False + if self._submit_session_thread: + self._submit_session_thread.join() + self._submit_session_thread = None self._locker.release() self._destroy() def _destroy(self): + self.logger.info("conversation %s is destroying", self.task_id) self._container.destroy() del self._container del self._tasks @@ -257,9 +276,9 @@ def _destroy(self): del self._pool def closed(self) -> bool: - return self._closed or self._shell_closed() + return self._closed def available(self) -> bool: - if self.closed() or self._handling_event: + if self.closed() or self._shell_closed() or self._handling_event: return False return True diff --git a/ghostos/framework/ghostos/ghostos_impl.py b/ghostos/framework/ghostos/ghostos_impl.py index 547e54c2..9b6cc8f4 100644 --- a/ghostos/framework/ghostos/ghostos_impl.py +++ b/ghostos/framework/ghostos/ghostos_impl.py @@ -5,7 +5,6 @@ from ghostos.container import Container, Provider, Contracts, INSTANCE from ghostos.contracts.configs import Configs, YamlConfig from ghostos.contracts.modules import Modules -from ghostos.contracts.pool import Pool from ghostos.contracts.variables import Variables from ghostos.contracts.workspace import Workspace from ghostos.contracts.logger import LoggerItf, get_ghostos_logger @@ -31,7 +30,6 @@ class GhostOSImpl(GhostOS): LoggerItf, Configs, Modules, - Pool, Variables, Workspace, ]) @@ -77,11 +75,10 @@ def create_shell( self._processes.save_process(process) # prepare container - container = Container(parent=self._container) providers = providers or [] return ShellImpl( config=shell_conf, - container=container, + container=self._container, process=process, providers=providers, ) diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index d3adcecd..33136452 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -1,5 +1,5 @@ from typing import Optional, List, Iterable, Tuple, TypeVar, Dict, Union, Any - +from ghostos.errors import SessionError from ghostos.abcd import ( Session, Ghost, GhostDriver, Shell, Scope, Taskflow, Operator, Subtasks, Messenger, @@ -25,6 +25,8 @@ from .subtasks_impl import SubtasksImpl from threading import Lock +from ...errors import SessionError + G = TypeVar("G", bound=Ghost) @@ -53,9 +55,10 @@ def __init__( max_errors: int, ): # session level container - self.container = Container(parent=container) + self.container = Container(parent=container, name="session") self.upstream = stream self.task = task + self.logger = self.container.force_fetch(LoggerItf) self._task_locker = locker threads = container.force_fetch(GoThreads) thread = threads.get_thread(task.thread_id, create=True) @@ -88,15 +91,9 @@ def __init__( self._destroyed = False self._saved = False self._bootstrap() - if not self.refresh(): - raise RuntimeError(f"Failed to start session") - self._thread_locker = Lock() + self._respond_lock = Lock() Session.instance_count += 1 - @property - def logger(self) -> LoggerItf: - return get_ghostos_logger(self.scope.model_dump()) - def __del__(self): # for gc test Session.instance_count -= 1 @@ -240,7 +237,7 @@ def messenger(self, stage: str = "") -> Messenger: def respond(self, messages: Iterable[MessageKind], stage: str = "") -> Tuple[List[Message], List[Caller]]: self._validate_alive() messages = self._message_parser.parse(messages) - with self._thread_locker: + with self._respond_lock: messenger = self.messenger(stage) messenger.send(messages) buffer, callers = messenger.flush() @@ -376,10 +373,12 @@ def _do_fire_events(self) -> None: self._firing_events = [] def __enter__(self): + if not self.refresh(): + raise RuntimeError(f"Failed to start session") return self def __exit__(self, exc_type, exc_val, exc_tb): - self.logger.info("session exited") + self.logger.debug("session exited") if exc_val is not None: intercepted = self.fail(exc_val) self.destroy() @@ -397,7 +396,8 @@ def fail(self, err: Optional[Exception]) -> bool: if self.upstream is not None and self.upstream.alive(): message = MessageType.ERROR.new(content=str(err)) self.upstream.deliver(message) - return True + raise SessionError(str(err)) + return False def destroy(self) -> None: if self._destroyed: diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index 3ba71392..159d758c 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -1,8 +1,7 @@ import time -from typing import Union, Optional, Iterable, List, Tuple, TypeVar, Callable - +from typing import Union, Optional, Iterable, List, Tuple, TypeVar, Callable, Dict from ghostos.contracts.logger import LoggerItf, get_ghostos_logger -from ghostos.contracts.pool import Pool +from ghostos.contracts.pool import Pool, DefaultPool from ghostos.container import Container, Provider from ghostos.abcd import Shell, Conversation, Ghost, Scope, Background from ghostos.abcd.utils import get_ghost_driver @@ -12,7 +11,7 @@ GoTasks, TaskState, GoTaskStruct, ) from ghostos.core.messages import Stream -from ghostos.helpers import uuid, Timeleft +from ghostos.helpers import uuid, Timeleft, import_from_path from ghostos.identifier import get_identifier from ghostos.entity import to_entity_meta from pydantic import BaseModel, Field @@ -28,10 +27,12 @@ class ShellConf(BaseModel): max_task_errors: int = Field( default=3, ) + pool_size: int = 100 background_idle_time: float = Field(1) task_lock_overdue: float = Field( default=10.0 ) + providers: List[str] = [] G = TypeVar("G", bound=Ghost) @@ -47,10 +48,16 @@ def __init__( providers: List[Provider], ): self._conf = config + self._container = Container(parent=container, name="shell") # prepare container for provider in providers: - container.register(provider) - self._container = container + self._container.register(provider) + for provider_name in config.providers: + p = import_from_path(provider_name) + if isinstance(p, Provider): + self._container.register(p) + elif issubclass(p, Provider): + self._container.register(p()) self._shell_id = process.shell_id self._process_id = process.process_id @@ -59,8 +66,10 @@ def __init__( process_id=self._process_id, task_id=self._process_id, ) - self._eventbus = container.force_fetch(EventBus) - self._tasks = container.force_fetch(GoTasks) + self._pool = DefaultPool(config.pool_size) + self._container.set(Pool, self._pool) + self._eventbus = self._container.force_fetch(EventBus) + self._tasks = self._container.force_fetch(GoTasks) self._closed = False self._background_started = False # bootstrap the container. @@ -68,7 +77,9 @@ def __init__( self._container.set(Shell, self) self._container.set(ShellImpl, self) self._container.set(ShellConf, config) + self._container.bootstrap() + self._conversations: List[Conversation] = [] @property def logger(self) -> LoggerItf: @@ -115,16 +126,26 @@ def sync_task( max_task_errors=self._conf.max_task_errors, ) self._tasks.save_task(task) - return ConversationImpl( + conversation = ConversationImpl( conf=conf, - container=Container(parent=self._container), + container=self._container, task=task, task_locker=locker, is_background=is_background, shell_closed=self.closed, ) + exists = self._conversations + running = [] + # remove closed ones + for c in exists: + if c.closed(): + continue + running.append(c) + running.append(conversation) + self._conversations = running + return conversation elif throw: - raise RuntimeError(f'Task {task.task_id} already locked') + raise RuntimeError(f'create conversation failed, Task {task.task_id} already locked') return None def call( @@ -240,8 +261,7 @@ def background_run(self, worker: int = 4, background: Optional[Background] = Non raise RuntimeError(f'background run already started') for i in range(worker): - pool = self.container().force_fetch(Pool) - pool.submit(self._run_background_worker, background) + self._pool.submit(self._run_background_worker, background) def _run_background_worker(self, background: Optional[Background] = None): def is_stopped() -> bool: @@ -271,6 +291,7 @@ def halt() -> int: self.logger.exception(err) break idle() + self.logger.info("shut down background worker") self.close() def _validate_closed(self): @@ -284,9 +305,23 @@ def close(self): if self._closed: return self._closed = True - pool = self.container().force_fetch(Pool) - pool.shutdown() + self.logger.info( + "start closing shell %s, conversations %d", + self._shell_id, + len(self._conversations) + ) + for conversation in self._conversations: + if conversation.closed(): + continue + self.logger.info("closing shell conversation %s", conversation.task_id) + conversation.close() + del self._conversations + self.logger.info("shell conversations are closed") self._container.destroy() + self.logger.info("shell container destroyed") + self.logger.info("shutting down shell pool") + self._pool.shutdown(cancel_futures=True) + self.logger.info("shell pool is shut") del self._container del self._eventbus del self._tasks diff --git a/ghostos/framework/ghostos/taskflow_impl.py b/ghostos/framework/ghostos/taskflow_impl.py index 345f6b35..178a7beb 100644 --- a/ghostos/framework/ghostos/taskflow_impl.py +++ b/ghostos/framework/ghostos/taskflow_impl.py @@ -121,7 +121,8 @@ def run(self, session: Session) -> Union[Operator, None]: from_task_id=task.task_id, from_task_name=task.name, ) - return fire_session_event(session, event) + session.fire_events(event) + return None def destroy(self): del self.messages diff --git a/ghostos/framework/tasks/storage_tasks.py b/ghostos/framework/tasks/storage_tasks.py index 2ca5d533..f3f868b1 100644 --- a/ghostos/framework/tasks/storage_tasks.py +++ b/ghostos/framework/tasks/storage_tasks.py @@ -63,6 +63,7 @@ def release(self) -> bool: filename = self.locker_file_name() if self.refresh(): self.storage.remove(filename) + self._acquired = False return True return False diff --git a/ghostos/ghosts/moss_agent/agent.py b/ghostos/ghosts/moss_agent/agent.py index 4d47857b..dcea050c 100644 --- a/ghostos/ghosts/moss_agent/agent.py +++ b/ghostos/ghosts/moss_agent/agent.py @@ -351,9 +351,9 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: # if std output is not empty, and op is none, observe the output as default. return session.taskflow().think() else: - output = caller.new_output("executed with no output or errors.") + output = caller.new_output("executed") session.respond([output]) - return session.taskflow().think() + return None except Exception as e: return self.fire_error(session, caller, f"error during executing moss code: {e}") diff --git a/ghostos/ghosts/moss_agent/instructions.py b/ghostos/ghosts/moss_agent/instructions.py index 0019532f..75617386 100644 --- a/ghostos/ghosts/moss_agent/instructions.py +++ b/ghostos/ghosts/moss_agent/instructions.py @@ -53,10 +53,11 @@ def run(moss: Moss): and execute the `run` function. Notices: -* you do not need to import the module that already provided above. +* Your code will **APPEND** to the code of `{modulename}` then execute, so **DO NOT REPEAT THE DEFINED CODE IN THE MODULE**. * if the python code context can not fulfill your will, do not use the `moss` tool. * you can reply as usual without calling the tool `moss`. use it only when you know what you're doing. -* the comments in your code generation is useful but not required, comment only when necessary +* don't copy the main function's __doc__, they are instruction to you only. +* in your code generation, comments is not required, comment only when necessary. """ MOSS_FUNCTION_DESC = """ diff --git a/ghostos/prototypes/spherogpt/bolt.py b/ghostos/prototypes/spherogpt/bolt.py index 433d1a2a..e7b07d48 100644 --- a/ghostos/prototypes/spherogpt/bolt.py +++ b/ghostos/prototypes/spherogpt/bolt.py @@ -6,14 +6,14 @@ exit("This script requires the spherov2 to be installed.") from spherov2 import scanner -from spherov2.toy import bolt from abc import ABC, abstractmethod -from typing import Optional +from typing import Optional, List from spherov2.sphero_edu import SpheroEduAPI from pydantic import BaseModel, Field from ghostos.helpers import Timeleft from ghostos.abcd import Conversation +from ghostos.contracts.logger import LoggerItf from ghostos.core.runtime import EventBus, EventTypes from ghostos.core.messages import MessageType from ghostos.core.moss.prompts import reflect_class_with_methods, get_prompt @@ -35,36 +35,38 @@ class Command(BaseModel): """ name: str = Field(description="aim of the command in simple words") duration: float = Field( + default=0.0, description="the command running duration in seconds. " - "when the duration tick out, the bolt stop." - "if duration is 0, execute once.") - code: str = Field(description="the command code") - execution_log: str = Field(default="", description="the command execution log, only used at the command runtime") + "if duration is 0, execute once. otherwise, command will run at every frame." + ) + code: str = Field(description="the command code to execute in the sphero bolt runtime.") def run_frame(self, api: SpheroEduAPI, passed: float, frame: int) -> None: """ run a single frame every tick - :param api: SpheroEduAPI that controll the sphero bolt + :param api: SpheroEduAPI that control the sphero bolt :param passed: the passed time from command start to now :param frame: the frame number, frame == 0 means the command is starting - :return: None + + for example, if you want roll at a special curve, + you shall change head angle by passed time at each frame, """ # eval the python code defined in the command. + # this is how the command work eval(self.code) class SpheroBolt(ABC): """ Sphero Bolt interface + Notice you can only run sphero by Command. """ @abstractmethod def run(self, *commands: Command) -> None: """ run command on sphero bolt. will always stop movement at beginning and end of the execution time. - :param commands: the commands, could be a pipeline - :param tick: the interval time between frames in secondes - :return: None, but will send message after the running. + :param commands: the commands to execute in order """ pass @@ -73,11 +75,13 @@ class SpheroBoltImpl(SpheroBolt): def __init__( self, + logger: LoggerItf, eventbus: EventBus, task_id: str, notify: bool, tick_interval: float = 0.01, ): + self._logger = logger self._executing_command: Optional[Command] = None self._command_stack: List[Command] = [] self._timeleft: Optional[Timeleft] = None @@ -86,16 +90,17 @@ def __init__( self._notify: bool = notify self._eventbus = eventbus self._destroyed = False - self._main_thread = Thread(target=self._main) + self._main_thread: Optional[Thread] = None self._tick_interval = tick_interval self._ticked_frames: int = 0 - self._bolt: Optional[bolt] = None + self._error = None def bootstrap(self): try: - self._bolt = scanner.find_BOLT() - if self._bolt is not None: - self._main_thread.start() + self._logger.info("SpheroBolt Bootstrap started") + _bolt = scanner.find_BOLT() + self._main_thread = Thread(target=self._main, args=(_bolt,)) + self._main_thread.start() except Exception as e: raise NotImplementedError("Could not find the Bolt device. " + str(e)) @@ -103,9 +108,10 @@ def destroy(self) -> None: if self._destroyed: return self._destroyed = True - if self._bolt is not None: + self._logger.info("SpheroBolt Bootstrap destroying") + if self._main_thread is not None: self._main_thread.join() - del self._bolt + del self._logger del self._eventbus def __del__(self): @@ -125,42 +131,53 @@ def _reset_command_at(self, action: str): return name = self._executing_command.name passed = self._timeleft.passed() - content = f"command `{name}` {action} after running `{round(passed, 4)}` second" + content = f"sphero bolt: command `{name}` {action} after running `{round(passed, 4)}` second" self._clear_command() event = EventTypes.NOTIFY.new( task_id=self._task_id, - messages=[MessageType.TEXT.new_system(content=content)] + messages=[MessageType.TEXT.new_system(content=content)], + from_task_id="sphero_bolt", + from_task_name="sphero_bolt", ) self._eventbus.send_event(event, self._notify) - def _main(self) -> None: - with SpheroEduAPI(self._bolt) as api: + def _main(self, toy) -> None: + with SpheroEduAPI(toy) as api: while not self._destroyed: - if self._executing_command and self._timeleft: - if self._executing_command.duration <= 0: - self._executing_command.run_frame(api, 0, 0) - self._command_succeeded() - continue - elif self._timeleft.alive(): - self._executing_command.run_frame(api, self._timeleft.passed(), self._ticked_frames) - self._ticked_frames += 1 - time.sleep(self._tick_interval) - continue + try: + if self._executing_command and self._timeleft: + if self._executing_command.duration <= 0: + self._executing_command.run_frame(api, 0, 0) + self._command_succeeded() + continue + elif self._timeleft.alive(): + self._executing_command.run_frame( + api, + self._timeleft.passed(), + self._ticked_frames, + ) + self._ticked_frames += 1 + time.sleep(self._tick_interval) + continue + else: + self._command_succeeded() + elif len(self._command_stack) > 0: + current: Command = self._command_stack.pop(0) + self._executing = True + self._executing_command = current + self._timeleft = Timeleft(current.duration) + self._ticked_frames = 0 else: - self._command_succeeded() - elif len(self._command_stack) > 0: - current: Command = self._command_stack.pop(0) - self._executing = True - self._executing_command = current - self._timeleft = Timeleft(current.duration) - self._ticked_frames = 0 - else: - time.sleep(0.5) - api.stop_roll() + time.sleep(0.5) + except Exception as e: + self._logger.exception(e) + self._reset_command_at(f"stopped because of error {e}") + self._logger.info("SpheroBolt start to stop") + self._logger.info("SpheroBolt stopped") def run(self, *commands: Command) -> None: - if self._bolt is None: - raise RuntimeError(f"Sphero Bolt is not initialized.") + if self._error: + raise RuntimeError(self._error) if self._executing: self._reset_command_at("stop during new command") commands = list(commands) @@ -183,8 +200,10 @@ def contract(self): def factory(self, con: Container) -> Optional[SpheroBolt]: conversation = con.force_fetch(Conversation) eventbus = con.force_fetch(EventBus) + logger = con.force_fetch(LoggerItf) task = conversation.task() return SpheroBoltImpl( + logger, eventbus, task_id=task.task_id, notify=task.shall_notifiy(), @@ -194,8 +213,8 @@ def factory(self, con: Container) -> Optional[SpheroBolt]: def bootstrap(self, container: Container) -> None: sphero_bolt = container.force_fetch(SpheroBolt) if isinstance(sphero_bolt, SpheroBoltImpl): - sphero_bolt.bootstrap() container.add_shutdown(sphero_bolt.destroy) + sphero_bolt.bootstrap() exports = { diff --git a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py index 6389139d..836c61c8 100644 --- a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py +++ b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py @@ -70,9 +70,7 @@ def bootstrap(): logger.debug("start shell background run") shell = ghostos.create_shell("ghostos_streamlit_app", "ghostos_streamlit_app") shell.background_run(4, StreamlitBackgroundApp()) - conversation = shell.sync(page_route.get_ghost(), page_route.get_context()) Singleton(shell, Shell).bind(st.session_state) - Singleton(conversation, Conversation).bind(st.session_state) return [ Singleton(container), diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index 08f5a877..31b644f3 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -147,7 +147,6 @@ def chatting(route: GhostChatRoute, conversation: Conversation, inputs: List[Mes event, receiver = conversation.respond(inputs) render_event(event, debug) render_receiver(receiver, debug) - while not route.input_type and rotate and conversation.available(): if event := conversation.pop_event(): render_event(event, debug) @@ -168,13 +167,12 @@ def video_input_dialog(route: GhostChatRoute): def render_receiver(receiver: Receiver, debug: bool): try: + with st.status("waiting..."): + buffer = ReceiverBuffer.new(receiver.recv()) + if buffer is None: + return with receiver: with st.chat_message("assistant"): - with st.status("waiting..."): - buffer = ReceiverBuffer.new(receiver.recv()) - if buffer is None: - st.error("No message received") - return while buffer is not None: if MessageType.is_text(buffer.head()): contents = chunks_to_st_stream(buffer.chunks()) @@ -282,7 +280,6 @@ def render_task_info_settings(task: GoTaskStruct, thread: GoThreadInfo): st.subheader("Thread Info") with st.container(border=True): render_thread_messages(thread, max_turn=0) - render_empty() def render_thread_messages(thread: GoThreadInfo, max_turn: int = 20): @@ -295,8 +292,6 @@ def render_thread_messages(thread: GoThreadInfo, max_turn: int = 20): count += render_turn(turn, debug) if count == 0: st.info("No thread messages yet") - render_empty() - render_empty() def render_event_object(event: Event, debug: bool): diff --git a/ghostos/prototypes/streamlitapp/widgets/renderer.py b/ghostos/prototypes/streamlitapp/widgets/renderer.py index db8b7209..ec5a3ae9 100644 --- a/ghostos/prototypes/streamlitapp/widgets/renderer.py +++ b/ghostos/prototypes/streamlitapp/widgets/renderer.py @@ -36,7 +36,7 @@ def render_task_by_id(task_id: str): def render_empty(): - for i in range(30): + for i in range(20): st.empty() diff --git a/tests/framework/ghostos/test_session.py b/tests/framework/ghostos/test_session.py index e81bd1ae..2d42301d 100644 --- a/tests/framework/ghostos/test_session.py +++ b/tests/framework/ghostos/test_session.py @@ -44,3 +44,4 @@ def send_thread(content: str, stage: str): assert len(prompt.added) == 2 prompt = thread.to_prompt([], ["a", "b"]) assert len(prompt.added) == 3 + diff --git a/tests/framework/tasks/test_storage_impl.py b/tests/framework/tasks/test_storage_impl.py index 20edc2a8..40312082 100644 --- a/tests/framework/tasks/test_storage_impl.py +++ b/tests/framework/tasks/test_storage_impl.py @@ -3,6 +3,7 @@ from ghostos.framework.logger import FakeLogger from ghostos.core.runtime import GoTaskStruct, TaskBrief from ghostos.entity import EntityMeta +import time def test_storage_tasks_impl(): @@ -39,3 +40,19 @@ def test_storage_tasks_impl(): assert new_got == new_turn assert TaskBrief.from_task(task) == TaskBrief.from_task(new_got) + + +def test_storage_tasks_impl_lock(): + storage = MemStorage() + tasks = StorageGoTasksImpl(storage, FakeLogger()) + locker = tasks.lock_task("task_id", overdue=0.1) + assert not locker.acquired() + for i in range(5): + time.sleep(0.05) + assert locker.acquire() + assert locker.acquired() + assert locker.release() + assert not locker.acquired() + with locker: + assert locker.acquired() + assert not locker.acquired() diff --git a/tests/python/test_context.py b/tests/python/test_context.py new file mode 100644 index 00000000..4cb78828 --- /dev/null +++ b/tests/python/test_context.py @@ -0,0 +1,20 @@ +def test_with_statement(): + class Foo: + error = None + + def __enter__(self): + return self + + def run(self): + raise RuntimeError("failed") + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_val: + self.error = exc_val + return True + + with Foo() as foo: + foo.run() + + assert foo.error is not None + assert isinstance(foo.error, RuntimeError) diff --git a/tests/test_container.py b/tests/test_container.py index dc8ca66b..7433aba8 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import Type, Dict, get_args, get_origin +from typing import Type, Dict, get_args, get_origin, ClassVar from ghostos.container import Container, Provider, provide @@ -162,3 +162,29 @@ def __init__(self, foo: Foo): bar = sub_container.force_fetch(Bar) assert bar.bar == "hello" assert bar.foo.foo == 2 + + +def test_bloodline(): + container = Container() + assert container.bloodline is not None + sub = Container(parent=container, name="hello") + assert len(sub.bloodline) == 2 + + +def test_container_shutdown(): + class Foo: + instance_count: ClassVar[int] = 0 + + def __init__(self): + Foo.instance_count += 1 + + def shutdown(self): + Foo.instance_count -= 1 + + container = Container() + f = Foo() + container.set(Foo, f) + container.add_shutdown(f.shutdown) + assert Foo.instance_count == 1 + container.destroy() + assert Foo.instance_count == 0 From d8cd989f92e67d1fc614efa2b7277611193bbf8d Mon Sep 17 00:00:00 2001 From: zhuming Date: Wed, 27 Nov 2024 02:25:07 +0800 Subject: [PATCH 107/148] fix: fix messenger and parser bugs. messages designing is too complex, not stupid enough --- ghostos/abcd/concepts.py | 3 +- ghostos/contracts/logger.py | 34 ++++++--- ghostos/core/llms/prompt.py | 1 - ghostos/core/llms/prompt_pipes.py | 2 +- ghostos/core/messages/message.py | 3 +- ghostos/core/messages/openai.py | 35 ++++++--- ghostos/core/messages/pipeline.py | 3 +- ghostos/core/messages/transport.py | 19 +++-- ghostos/core/runtime/events.py | 18 ++--- ghostos/core/runtime/tasks.py | 2 +- ghostos/core/runtime/threads.py | 12 ++- ghostos/errors.py | 4 + .../framework/ghostos/conversation_impl.py | 70 ++++++++--------- ghostos/framework/ghostos/session_impl.py | 23 +++--- ghostos/framework/ghostos/shell_impl.py | 4 +- ghostos/framework/ghostos/taskflow_impl.py | 11 ++- ghostos/framework/llms/openai_driver.py | 3 +- ghostos/framework/logger/__init__.py | 3 +- ghostos/framework/logger/fake.py | 27 ------- ghostos/framework/logger/named.py | 3 +- ghostos/framework/messengers/defaults.py | 22 ++---- ghostos/helpers/timeutils.py | 2 +- ghostos/prototypes/spherogpt/bolt.py | 75 +++++++++++++------ .../streamlitapp/cli/run_ghost_chat.py | 2 +- .../streamlitapp/pages/aifuncs/detail.py | 6 +- .../prototypes/streamlitapp/pages/ghosts.py | 22 +++--- .../streamlitapp/widgets/dialogs.py | 6 +- .../streamlitapp/widgets/messages.py | 41 +++++----- .../streamlitapp/widgets/renderer.py | 19 +++-- .../core/messages/test_arr_stream_receiver.py | 61 ++++++++++++++- tests/core/messages/test_messages.py | 14 ++++ tests/core/messages/test_openai_parser.py | 65 ++++++++++++++++ tests/framework/messenger/test_messenger.py | 28 ++++++- tests/helpers/test_timeleft.py | 1 + tests/python/test_collection.py | 7 ++ tests/python/test_pydantic.py | 9 +++ tests/python/test_set.py | 10 +++ 37 files changed, 461 insertions(+), 209 deletions(-) delete mode 100644 ghostos/framework/logger/fake.py create mode 100644 tests/core/messages/test_openai_parser.py diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index ec43bf29..a277d84a 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -391,7 +391,6 @@ def respond( self, inputs: Iterable[Message], context: Optional[G.ContextType] = None, - history: Optional[List[Message]] = None, ) -> Tuple[Event, Receiver]: """ create response immediately by inputs. the inputs will change to event. @@ -544,7 +543,7 @@ class Session(Generic[G], ABC): logger: LoggerItf @abstractmethod - def is_alive(self) -> bool: + def alive(self) -> bool: """ Session 对自身任务进行状态检查. 如果这个任务被取消或终止, 则返回 false. diff --git a/ghostos/contracts/logger.py b/ghostos/contracts/logger.py index a174527b..78d52e94 100644 --- a/ghostos/contracts/logger.py +++ b/ghostos/contracts/logger.py @@ -8,7 +8,7 @@ __all__ = [ 'LoggerItf', 'config_logging', 'get_logger', 'get_console_logger', 'get_debug_logger', - 'wrap_logger', 'LoggerAdapter', 'get_ghostos_logger', + 'wrap_logger', 'LoggerAdapter', 'get_ghostos_logger', 'FakeLogger', ] @@ -139,15 +139,6 @@ def get_console_logger( def get_ghostos_logger(extra: Optional[dict] = None) -> Union[LoggerAdapter, Logger]: logger = getLogger("ghostos") - if not logger.hasHandlers(): - _debug_file_handler = logging.FileHandler("debug.log", mode="a") - formatter = logging.Formatter( - fmt="%(asctime)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)", - ) - _debug_file_handler.setFormatter(formatter) - _debug_file_handler.setLevel(logging.DEBUG) - logger.addHandler(_debug_file_handler) - logger.setLevel(logging.DEBUG) if extra: return LoggerAdapter(logger, extra) return logger @@ -195,6 +186,29 @@ def format(self, record): return formatter.format(record) +class FakeLogger(LoggerItf): + def debug(self, msg, *args, **kwargs): + pass + + def info(self, msg, *args, **kwargs): + pass + + def warning(self, msg, *args, **kwargs): + pass + + def error(self, msg, *args, **kwargs): + pass + + def exception(self, msg, *args, exc_info=True, **kwargs): + pass + + def critical(self, msg, *args, **kwargs): + pass + + def log(self, level, msg, *args, **kwargs): + pass + + if __name__ == '__main__': get_console_logger().debug("hello world") get_console_logger().info("hello world") diff --git a/ghostos/core/llms/prompt.py b/ghostos/core/llms/prompt.py index c2281b80..835d7ed0 100644 --- a/ghostos/core/llms/prompt.py +++ b/ghostos/core/llms/prompt.py @@ -46,7 +46,6 @@ class Prompt(BaseModel): functional_tokens: List[FunctionalToken] = Field(default_factory=list) # system info - output: List[Message] = Field(default_factory=list) error: Optional[str] = Field(default=None, description="error message") created: int = Field(default_factory=timestamp) model: Optional[ModelConf] = Field(default=None, description="model conf") diff --git a/ghostos/core/llms/prompt_pipes.py b/ghostos/core/llms/prompt_pipes.py index 060c915c..a550794d 100644 --- a/ghostos/core/llms/prompt_pipes.py +++ b/ghostos/core/llms/prompt_pipes.py @@ -1,5 +1,5 @@ from typing import Optional -from ghostos.core.messages import Message, Role +from ghostos.core.messages import Message, Role, MessageType from ghostos.core.llms import PromptPipe, Prompt __all__ = ['AssistantNamePipe'] diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 3d0917e6..5b3a86fe 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -171,6 +171,7 @@ class Message(BaseModel): msg_id: str = Field(default="", description="unique message id. ") ref_id: Optional[str] = Field(default=None, description="the referenced message id.") + from_id: Optional[str] = Field(default=None, description="the origin message id.") index: Optional[int] = Field(default=None, description="the index of the message.") type: str = Field(default="", description="default message type, if empty, means text") stage: str = Field(default="", description="message stage") @@ -431,7 +432,7 @@ def get_created(self) -> datetime: return datetime.fromtimestamp(self.created) def __str__(self): - return self.get_content() + return self.__repr__() class MessageClass(ABC): diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index 92c44003..34fd9e88 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -16,7 +16,7 @@ from ghostos.core.messages.message_classes import ( CallerOutput, VariableMessage, ) -from ghostos.contracts.logger import get_ghostos_logger +from ghostos.contracts.logger import LoggerItf, FakeLogger from ghostos.container import Provider, Container from ghostos.helpers import import_class_from_path @@ -107,7 +107,12 @@ def __init__( VariableMessage, ] self.class_parser = MessageClassesParser(message_classes) - self.container = container + self.container: Optional[Container] = container + self.logger: Optional[LoggerItf] = None + if container: + self.logger = container.get(LoggerItf) + if not self.logger: + self.logger = FakeLogger() def parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: if not message.is_complete(): @@ -249,21 +254,26 @@ def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) - # 创建首包, 并发送. buffer = None for item in messages: - get_ghostos_logger().debug("receive chunk: %s", item) + chunk = None + self.logger.debug("openai parser receive item: %s", item) if len(item.choices) == 0: # 接受到了 openai 协议尾包. 但在这个协议里不作为尾包发送. usage = CompletionUsagePayload.from_chunk(item) - chunk = Message.new_chunk(role=Role.ASSISTANT.value, typ_=MessageType.DEFAULT) - if usage: - usage.set_payload(chunk) + if usage and buffer: + usage.set_payload(buffer) + continue elif len(item.choices) > 0: choice = item.choices[0] delta = choice.delta - chunk = self._new_pack_from_delta(delta) - if chunk is None: - continue + chunk = self._new_chunk_from_delta(delta) else: continue + self.logger.debug("openai parser parsed chunk: %s", chunk) + + if chunk is None: + continue + elif item.id: + chunk.from_id = item.id if buffer is None: buffer = chunk.as_head(copy=True) @@ -273,15 +283,18 @@ def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) - if not patched: yield buffer.as_tail() buffer = chunk.as_head(copy=True) + yield buffer.get_copy() + continue else: buffer = patched - yield chunk + yield chunk + continue if buffer: yield buffer.as_tail(copy=False) @staticmethod - def _new_pack_from_delta(delta: ChoiceDelta) -> Optional[Message]: + def _new_chunk_from_delta(delta: ChoiceDelta) -> Optional[Message]: # function call if delta.function_call: diff --git a/ghostos/core/messages/pipeline.py b/ghostos/core/messages/pipeline.py index 20df562a..830b952b 100644 --- a/ghostos/core/messages/pipeline.py +++ b/ghostos/core/messages/pipeline.py @@ -62,10 +62,11 @@ def across(self, messages: Iterable[Message]) -> Iterable[Message]: buffer = patched continue else: - yield item.get_copy() + yield item else: yield buffer.as_tail() buffer = item.as_head() + yield buffer.get_copy() continue if buffer is not None: yield buffer.as_tail(copy=False) diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index abfea631..8922e397 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -1,11 +1,12 @@ from __future__ import annotations -from typing import Iterable, Optional, Callable, Tuple, List, Self, Iterator +from typing import Iterable, Optional, Tuple, List, Self, Iterator from typing_extensions import Protocol from collections import deque from abc import abstractmethod from ghostos.core.messages.message import Message, MessageType from ghostos.core.messages.pipeline import SequencePipe +from ghostos.errors import StreamingError import time __all__ = [ @@ -257,11 +258,19 @@ def send(self, messages: Iterable[Message]) -> bool: self._alive = False self._error = self._receiver.error() if self._error is not None: - raise RuntimeError(f"stream is failed: {self._error.get_content()}") + raise StreamingError( + f"streaming is failed: {self._error.get_content()}, message {item.model_dump_json()} unsent" + ) + elif self._receiver.closed(): + raise StreamingError( + f"streaming is failed due to receiver is closed: , message {item.model_dump_json()} unsent" + ) elif not self.alive(): - raise RuntimeError(f"stream is closed") + raise StreamingError( + f"streaming is closed, message {item.model_dump_json()} unsent", + ) else: - raise RuntimeError(f"send stream failed") + raise StreamingError(f"send stream failed, message {item.model_dump_json()} unsent") return True def completes_only(self) -> bool: @@ -376,7 +385,7 @@ def next(self) -> Optional[Self]: def new_basic_connection( *, - timeout: float = -1, + timeout: float = 0.0, idle: float = 0.2, complete_only: bool = False, ) -> Tuple[Stream, Receiver]: diff --git a/ghostos/core/runtime/events.py b/ghostos/core/runtime/events.py index 4498f445..fe092226 100644 --- a/ghostos/core/runtime/events.py +++ b/ghostos/core/runtime/events.py @@ -57,10 +57,6 @@ class Event(BaseModel): default_factory=list, description="list of messages sent by this event", ) - history: Optional[List[Message]] = Field( - default=None, - description="if the event reset the history" - ) instruction: str = Field( default="", description="instruction from the event telling what to do. wrapped by system type message after the messages", @@ -77,7 +73,7 @@ class Event(BaseModel): def is_empty(self) -> bool: return not self.reason and not self.instruction and not self.messages - def from_self(self) -> bool: + def is_from_self(self) -> bool: """ 通过任务是否是自己触发的, 来判断是否要继续. """ @@ -90,7 +86,7 @@ def default_handler(self) -> str: return f"on_{self.event_type()}" def iter_message(self, show_instruction: bool = True) -> Iterable[Message]: - if EventTypes.CREATED.value != self.type and self.from_task_name and not self.from_self(): + if EventTypes.CREATED.value != self.type and self.from_task_name and not self.is_from_self(): reason = "" if self.reason: reason = f" Reason: {self.reason}" @@ -112,6 +108,7 @@ def new( event_type: str, task_id: str, messages: List[Message], + callback: Optional[bool] = None, from_task_id: Optional[str] = None, from_task_name: Optional[str] = None, reason: str = "", @@ -119,11 +116,12 @@ def new( eid: Optional[str] = None, payloads: Optional[Dict] = None, context: Optional[EntityMeta] = None, - history: Optional[List[Message]] = None, ) -> "Event": id_ = eid if eid else uuid() type_ = event_type payloads = payloads if payloads is not None else {} + if callback is None: + callback = from_task_id is not None return cls( event_id=id_, type=type_, @@ -135,7 +133,7 @@ def new( messages=messages, payloads=payloads, context=context, - history=history, + callback=callback, ) @@ -181,6 +179,7 @@ def new( task_id: str, messages: List[Message], *, + callback: Optional[bool] = None, from_task_id: Optional[str] = None, from_task_name: Optional[str] = None, reason: str = "", @@ -188,13 +187,13 @@ def new( eid: Optional[str] = None, payloads: Optional[Dict] = None, context: Optional[EntityMeta] = None, - history: Optional[List[Message]] = None, ) -> Event: type_ = str(self.value) payloads = payloads if payloads is not None else {} return Event.new( event_type=type_, task_id=task_id, + callback=callback, from_task_id=from_task_id, from_task_name=from_task_name, reason=reason, @@ -203,7 +202,6 @@ def new( eid=eid, payloads=payloads, context=context, - history=history, ) diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index 781907ec..912da732 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -226,7 +226,7 @@ def identifier(self) -> Identifier: description=self.purpose, ) - def shall_notifiy(self) -> bool: + def shall_notify(self) -> bool: return self.depth > 0 def is_dead(self) -> bool: diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index a1477638..637b317d 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -62,7 +62,8 @@ def new( return cls(**data) def append(self, *messages: Message, pycontext: Optional[PyContext] = None) -> None: - self.added.extend(messages) + for item in messages: + self.added.append(item) if pycontext is not None: self.pycontext = pycontext @@ -87,11 +88,14 @@ def messages(self, truncate: bool) -> Iterable[Message]: def is_empty(self) -> bool: return (self.event is None or self.event.is_empty()) and not self.added - def is_from_client(self) -> bool: - return self.event is not None and self.event.from_task_id is None + def is_from_inputs(self) -> bool: + return self.event is not None and self.event.type == EventTypes.INPUT.value def is_from_self(self) -> bool: - return self.event is not None and self.event.from_self() + return self.event is not None and self.event.is_from_self() + + def is_callback(self) -> bool: + return self.event is not None and self.event.callback class GoThreadInfo(BaseModel): diff --git a/ghostos/errors.py b/ghostos/errors.py index c3fda41d..a424d2a4 100644 --- a/ghostos/errors.py +++ b/ghostos/errors.py @@ -5,6 +5,10 @@ class SessionError(RuntimeError): pass +class StreamingError(RuntimeError): + pass + + class ConversationError(RuntimeError): """ Conversation level exception, conversation shall be closed diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index 05aaffd7..dba213e4 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -52,6 +52,7 @@ def __init__( is_background: bool, shell_closed: Callable[[], bool], ): + self._closed = False self._conf = conf self.task_id = task.task_id self._container = Container(parent=container, name="conversation") @@ -72,7 +73,6 @@ def __init__( self._submit_session_thread: Optional[Thread] = None self._handling_event = False self._mutex = Lock() - self._closed = False self._shell_closed = shell_closed self._bootstrap() @@ -146,7 +146,6 @@ def respond( self, inputs: Iterable[Message], context: Optional[Ghost.ContextType] = None, - history: Optional[List[Message]] = None, ) -> Tuple[Event, Receiver]: self._validate_closed() if self._submit_session_thread: @@ -160,7 +159,6 @@ def respond( task_id=self._scope.task_id, messages=list(inputs), context=context_meta, - history=history, ) return event, self.respond_event(event) @@ -182,6 +180,9 @@ def respond_event( idle=self._conf.message_receiver_idle, complete_only=self._is_background, ) + if self._submit_session_thread: + self._submit_session_thread.join() + self._submit_session_thread = None self._submit_session_thread = Thread(target=self._submit_session_event, args=(event, stream,)) self._submit_session_thread.start() return retriever @@ -193,25 +194,27 @@ def _validate_closed(self): raise RuntimeError(f"Shell is closed") def _submit_session_event(self, event: Event, stream: Stream) -> None: - self.logger.debug("submit session event") - try: + with self._mutex: self._handling_event = True - with stream: - task = self._tasks.get_task(event.task_id) - session = self._create_session(task, self._locker, stream) - self.logger.debug( - f"create session from event id %s, task_id is %s", - event.event_id, task.task_id, - ) - with session: - run_session_event(session, event, self._conf.max_session_step) - except Exception as e: - if not self.fail(error=e): - raise - finally: - self._eventbus.notify_task(event.task_id) - self._handling_event = False - self._submit_session_thread = None + self.logger.debug("submit session event") + try: + with stream: + task = self._tasks.get_task(event.task_id) + session = self._create_session(task, self._locker, stream) + self.logger.debug( + f"create session from event id %s, task_id is %s", + event.event_id, task.task_id, + ) + with session: + run_session_event(session, event, self._conf.max_session_step) + except Exception as e: + if not self.fail(error=e): + raise + finally: + if task and task.shall_notify(): + self._eventbus.notify_task(event.task_id) + self._handling_event = False + self._submit_session_thread = None def _create_session( self, @@ -241,19 +244,28 @@ def fail(self, error: Exception) -> bool: if self._closed: return False if isinstance(error, SessionError): - self.logger.info(f"receive session stop error: {error}") + self.logger.info(f"conversation {self.task_id} receive session stop error: {error}") return False elif isinstance(error, IOError): - self.logger.exception(f"receive IO error: {error}") + self.logger.exception(f"conversation {self.task_id} receive IO error: {error}") return False # otherwise, close the whole thing. - self.logger.exception(f"receive IO error: {error}") + self.logger.exception(f"conversation {self.task_id} receive runtime error: {error}") self.close() return False def __del__(self): self.close() + def _destroy(self): + self.logger.info("conversation %s is destroying", self.task_id) + self._container.destroy() + del self._container + del self._tasks + del self._threads + del self._eventbus + del self._pool + def close(self): if self._closed: return @@ -261,20 +273,10 @@ def close(self): self.logger.info("conversation %s is closing", self.task_id) self._handling_event = False if self._submit_session_thread: - self._submit_session_thread.join() self._submit_session_thread = None self._locker.release() self._destroy() - def _destroy(self): - self.logger.info("conversation %s is destroying", self.task_id) - self._container.destroy() - del self._container - del self._tasks - del self._threads - del self._eventbus - del self._pool - def closed(self) -> bool: return self._closed diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 33136452..afdde8c6 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -1,5 +1,5 @@ from typing import Optional, List, Iterable, Tuple, TypeVar, Dict, Union, Any -from ghostos.errors import SessionError +from ghostos.errors import StreamingError, SessionError from ghostos.abcd import ( Session, Ghost, GhostDriver, Shell, Scope, Taskflow, Operator, Subtasks, Messenger, @@ -119,13 +119,13 @@ def unmarshal_state(task: GoTaskStruct) -> Dict[str, EntityType]: state_values[key] = entity return state_values - def is_alive(self) -> bool: + def alive(self) -> bool: if self._failed or self._destroyed: return False return self._task_locker.acquired() and (self.upstream is None or self.upstream.alive()) def _validate_alive(self): - if not self.is_alive(): + if not self.alive(): raise RuntimeError(f"Session is not alive") def to_messages(self, values: Iterable[Union[MessageKind, Any]]) -> List[Message]: @@ -148,14 +148,14 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] self.task.errors = 0 if event.context is not None: self.task.context = event.context - if event.history: - self.thread = self.thread.reset_history(event.history) - event.history = [] # other event elif self.task.is_dead(): # dead task can only respond event from parent input. self.thread.new_turn(event) + self.logger.info( + "task %s is dead, save event %s without run", self.scope.task_id, event.event_id, + ) return None, EmptyOperator() if EventTypes.ERROR.value == event.type: @@ -181,7 +181,6 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] self.fire_events(event) return None, EmptyOperator() - event.history = [] event.context = None return event, None @@ -207,7 +206,7 @@ def get_instructions(self) -> str: return self.ghost_driver.get_instructions(self) def refresh(self) -> bool: - if self._failed or self._destroyed or not self.is_alive(): + if self._failed or self._destroyed or not self.alive(): return False if self._task_locker.refresh(): self._saved = False @@ -239,8 +238,13 @@ def respond(self, messages: Iterable[MessageKind], stage: str = "") -> Tuple[Lis messages = self._message_parser.parse(messages) with self._respond_lock: messenger = self.messenger(stage) - messenger.send(messages) + try: + messenger.send(messages) + except StreamingError as e: + raise SessionError(f"session failed during streaming: {e}") + buffer, callers = messenger.flush() + self.logger.info("append messages to thread: %s", buffer) self.thread.append(*buffer) return buffer, callers @@ -396,7 +400,6 @@ def fail(self, err: Optional[Exception]) -> bool: if self.upstream is not None and self.upstream.alive(): message = MessageType.ERROR.new(content=str(err)) self.upstream.deliver(message) - raise SessionError(str(err)) return False def destroy(self) -> None: diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index 159d758c..96165104 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -289,7 +289,9 @@ def halt() -> int: continue except Exception as err: self.logger.exception(err) - break + if background and not background.on_error(err): + self.logger.info("stop shell due to background not catch error") + break idle() self.logger.info("shut down background worker") self.close() diff --git a/ghostos/framework/ghostos/taskflow_impl.py b/ghostos/framework/ghostos/taskflow_impl.py index 178a7beb..abe681d6 100644 --- a/ghostos/framework/ghostos/taskflow_impl.py +++ b/ghostos/framework/ghostos/taskflow_impl.py @@ -8,7 +8,7 @@ from ghostos.abcd import fire_session_event from ghostos.core.runtime import TaskState, EventTypes, TaskBrief from ghostos.core.moss import Injection, MossRuntime -from ghostos.core.messages import MessageKind, MessageKindParser, Message, Role +from ghostos.core.messages import MessageKind, MessageKindParser, Message, Role, MessageType from pprint import pprint from contextlib import redirect_stdout from io import StringIO @@ -180,8 +180,13 @@ def run(self, session: Session) -> Union[Operator, None]: reason=f"task {task.name} is failed: {self.reason}", ) session.fire_events(event) - elif self.messages: - session.respond(self.messages) + messages = [] + if self.reason: + messages = [Role.SYSTEM.new(content=self.reason)] + if self.messages: + messages.extend(self.messages) + if messages: + session.respond(messages) return None def destroy(self): diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index ce28f9d4..fc5ba2c6 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -168,7 +168,6 @@ def chat_completion_chunks(self, prompt: Prompt) -> Iterable[Message]: try: chunks: Iterable[ChatCompletionChunk] = self._chat_completion(prompt, stream=True) messages = self._parser.from_chat_completion_chunks(chunks) - get_ghostos_logger().debug("++++++++++++messages %s", messages) prompt_payload = PromptPayload.from_prompt(prompt) output = [] for chunk in messages: @@ -177,7 +176,7 @@ def chat_completion_chunks(self, prompt: Prompt) -> Iterable[Message]: self._model.set_payload(chunk) prompt_payload.set_payload(chunk) output.append(chunk) - prompt.output = output + prompt.added = output except Exception as e: prompt.error = str(e) raise diff --git a/ghostos/framework/logger/__init__.py b/ghostos/framework/logger/__init__.py index 9776aa72..10b4abe4 100644 --- a/ghostos/framework/logger/__init__.py +++ b/ghostos/framework/logger/__init__.py @@ -1,3 +1,2 @@ -from ghostos.contracts.logger import LoggerItf +from ghostos.contracts.logger import LoggerItf, FakeLogger from ghostos.framework.logger.named import DefaultLoggerProvider -from ghostos.framework.logger.fake import FakeLogger diff --git a/ghostos/framework/logger/fake.py b/ghostos/framework/logger/fake.py deleted file mode 100644 index 46b50587..00000000 --- a/ghostos/framework/logger/fake.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Dict - -from ghostos.contracts.logger import LoggerItf - - -class FakeLogger(LoggerItf): - def debug(self, msg, *args, **kwargs): - pass - - def info(self, msg, *args, **kwargs): - pass - - def warning(self, msg, *args, **kwargs): - pass - - def error(self, msg, *args, **kwargs): - pass - - def exception(self, msg, *args, exc_info=True, **kwargs): - pass - - def critical(self, msg, *args, **kwargs): - pass - - def log(self, level, msg, *args, **kwargs): - pass - diff --git a/ghostos/framework/logger/named.py b/ghostos/framework/logger/named.py index 2361c9db..afc18854 100644 --- a/ghostos/framework/logger/named.py +++ b/ghostos/framework/logger/named.py @@ -36,5 +36,4 @@ def factory(self, con: Container) -> Optional[LoggerItf]: handler.setFormatter(formatter) logger.addHandler(handler) logger.setLevel(logging.DEBUG) - wrapped = LoggerAdapter(logger, extra={}) - return wrapped + return logger diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index ea99f355..e234ad4e 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -28,7 +28,6 @@ def __init__( self._payloads = payloads self._sent_message_ids = [] self._sent_messages = {} - self._sending: Optional[Message] = None self._sent_callers = [] self._stage = stage self._destroyed = False @@ -36,11 +35,13 @@ def __init__( def flush(self) -> Tuple[List[Message], List[Caller]]: messages = [] callers = [] - if self._sending is not None: - self._sent_message_ids.append(self._sending.msg_id) - self._sent_messages[self._sending.msg_id] = self._sending - message_ids = set(self._sent_message_ids) - for msg_id in message_ids: + done = set() + for msg_id in self._sent_message_ids: + if msg_id in done: + continue + else: + done.add(msg_id) + message = self._sent_messages[msg_id] messages.append(message) if message.type == MessageType.FUNCTION_CALL: @@ -65,7 +66,6 @@ def destroy(self): del self._sent_messages del self._sent_message_ids del self._sent_callers - del self._sending del self._payloads def send(self, messages: Iterable[Message]) -> bool: @@ -80,18 +80,13 @@ def buffer(self, messages: Iterable[Message]) -> Iterable[Message]: for item in messages: # add message info if item.is_complete() or item.is_head(): - if not item.name: + if not item.name and MessageType.is_text(item): item.name = self._assistant_name if not item.stage: item.stage = self._stage if not item.role: item.role = self._role # create buffer in case upstream is cancel - if item.is_head(): - self._sending = item.get_copy() - if item.is_chunk() and self._sending: - self._sending = self._sending.patch(item) - if item.is_complete(): # add payload to complete one if self._payloads: @@ -101,7 +96,6 @@ def buffer(self, messages: Iterable[Message]) -> Iterable[Message]: # buffer outputs self._sent_message_ids.append(item.msg_id) self._sent_messages[item.msg_id] = item - self._sending = None # skip chunk if self._upstream and self._upstream.completes_only() and not item.is_complete(): diff --git a/ghostos/helpers/timeutils.py b/ghostos/helpers/timeutils.py index 45d94521..87c30479 100644 --- a/ghostos/helpers/timeutils.py +++ b/ghostos/helpers/timeutils.py @@ -13,7 +13,7 @@ def __init__(self, timeout: float): def left(self) -> float: passed = self.passed() timeleft = self.timeout - passed - return timeleft + return timeleft if timeleft > 0 else 0 def alive(self) -> bool: return self.timeout <= 0 or self.passed() < self.timeout diff --git a/ghostos/prototypes/spherogpt/bolt.py b/ghostos/prototypes/spherogpt/bolt.py index e7b07d48..1eaa9fc5 100644 --- a/ghostos/prototypes/spherogpt/bolt.py +++ b/ghostos/prototypes/spherogpt/bolt.py @@ -37,8 +37,9 @@ class Command(BaseModel): duration: float = Field( default=0.0, description="the command running duration in seconds. " - "if duration is 0, execute once. otherwise, command will run at every frame." + "after the duration is reached, next command will be executed." ) + run_every: bool = Field(False, description="if True, the command run every frame") code: str = Field(description="the command code to execute in the sphero bolt runtime.") def run_frame(self, api: SpheroEduAPI, passed: float, frame: int) -> None: @@ -51,10 +52,20 @@ def run_frame(self, api: SpheroEduAPI, passed: float, frame: int) -> None: for example, if you want roll at a special curve, you shall change head angle by passed time at each frame, """ + # import types in case you need. + from spherov2.types import Color, ToyType + # eval the python code defined in the command. # this is how the command work eval(self.code) + @classmethod + def once(cls, name: str, code: str, duration: float): + """ + run only once, wait until duration is out + """ + return cls(name=name, code=code, duration=duration, run_every=False) + class SpheroBolt(ABC): """ @@ -81,7 +92,7 @@ def __init__( notify: bool, tick_interval: float = 0.01, ): - self._logger = logger + self._logger: LoggerItf = logger self._executing_command: Optional[Command] = None self._command_stack: List[Command] = [] self._timeleft: Optional[Timeleft] = None @@ -98,8 +109,7 @@ def __init__( def bootstrap(self): try: self._logger.info("SpheroBolt Bootstrap started") - _bolt = scanner.find_BOLT() - self._main_thread = Thread(target=self._main, args=(_bolt,)) + self._main_thread = Thread(target=self._main) self._main_thread.start() except Exception as e: raise NotImplementedError("Could not find the Bolt device. " + str(e)) @@ -117,50 +127,64 @@ def destroy(self) -> None: def __del__(self): self.destroy() - def _clear_command(self): + def _clear_command(self, clear_all: bool): self._executing_command = None self._timeleft = None self._ticked_frames = 0 self._executing = False + if clear_all: + self._command_stack = [] def _command_succeeded(self): - self._reset_command_at("succeeded") + self._reset_command_at("succeeded", successful=True, clear_all=False) - def _reset_command_at(self, action: str): + def _reset_command_at(self, action: str, successful: bool, clear_all: bool): if self._executing_command is None or self._timeleft is None: return name = self._executing_command.name passed = self._timeleft.passed() content = f"sphero bolt: command `{name}` {action} after running `{round(passed, 4)}` second" - self._clear_command() - event = EventTypes.NOTIFY.new( + self._clear_command(clear_all) + event_type = EventTypes.NOTIFY if successful else EventTypes.ERROR + event = event_type.new( task_id=self._task_id, messages=[MessageType.TEXT.new_system(content=content)], - from_task_id="sphero_bolt", - from_task_name="sphero_bolt", + callback=True, ) self._eventbus.send_event(event, self._notify) - def _main(self, toy) -> None: + def _main(self) -> None: + while not self._destroyed: + _bolt = scanner.find_BOLT() + self._logger.info("SpheroBolt toy connected") + try: + self._run_toy(_bolt) + except Exception as e: + self._logger.error(str(e)) + self._logger.info("SpheroBolt toy reconnecting") + self.destroy() + + def _run_toy(self, toy) -> None: with SpheroEduAPI(toy) as api: while not self._destroyed: try: if self._executing_command and self._timeleft: - if self._executing_command.duration <= 0: - self._executing_command.run_frame(api, 0, 0) - self._command_succeeded() - continue - elif self._timeleft.alive(): + has_duration = self._executing_command.duration > 0 + must_run = self._ticked_frames == 0 + run_every = self._executing_command.run_every + if must_run or (self._timeleft.left() > 0 and run_every): self._executing_command.run_frame( api, self._timeleft.passed(), self._ticked_frames, ) self._ticked_frames += 1 - time.sleep(self._tick_interval) + if has_duration: + time.sleep(self._tick_interval) continue else: self._command_succeeded() + continue elif len(self._command_stack) > 0: current: Command = self._command_stack.pop(0) self._executing = True @@ -170,8 +194,12 @@ def _main(self, toy) -> None: else: time.sleep(0.5) except Exception as e: - self._logger.exception(e) - self._reset_command_at(f"stopped because of error {e}") + self._logger.error(f"SpheroBolt exception: {e}") + self._reset_command_at( + f"stopped because of error {e}", + successful=False, + clear_all=True, + ) self._logger.info("SpheroBolt start to stop") self._logger.info("SpheroBolt stopped") @@ -179,7 +207,7 @@ def run(self, *commands: Command) -> None: if self._error: raise RuntimeError(self._error) if self._executing: - self._reset_command_at("stop during new command") + self._reset_command_at("stop during new command", successful=True, clear_all=True) commands = list(commands) if len(commands) == 0: return @@ -206,11 +234,12 @@ def factory(self, con: Container) -> Optional[SpheroBolt]: logger, eventbus, task_id=task.task_id, - notify=task.shall_notifiy(), + notify=task.shall_notify(), tick_interval=0.01, ) - def bootstrap(self, container: Container) -> None: + @staticmethod + def bootstrap(container: Container) -> None: sphero_bolt = container.force_fetch(SpheroBolt) if isinstance(sphero_bolt, SpheroBoltImpl): container.add_shutdown(sphero_bolt.destroy) diff --git a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py index 836c61c8..6dfcec94 100644 --- a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py +++ b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py @@ -24,7 +24,7 @@ class StreamlitBackgroundApp(Background): def on_error(self, error: Exception) -> bool: logger.exception(error) - return False + return True def on_event(self, event: Event, messages: List[Message]) -> None: pass diff --git a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py index 2e07b7a0..dcf161fe 100644 --- a/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py +++ b/ghostos/prototypes/streamlitapp/pages/aifuncs/detail.py @@ -175,7 +175,7 @@ def render_aifunc_exec_step(step: ExecStep): st.error(step.error.get_content()) with st.expander(label=_("History"), expanded=True): - render_messages(step.iter_messages()) + render_messages(step.iter_messages(), debug=False, in_expander=True) if step.frames: st.caption(f"{len(step.frames)} frames called") @@ -201,7 +201,7 @@ def open_exec_frame_dialog(exec_frame: ExecFrame): idx = 0 for step in exec_frame.steps: st.caption(f"step {idx}") - render_messages(step.iter_messages()) + render_messages(step.iter_messages(), debug=False, in_expander=True) idx = idx + 1 render_aifunc_frame_tail(exec_frame) @@ -232,7 +232,7 @@ def main(): elif route.frame: render_aifunc_executed_frame_head(route.frame) with st.expander(label=_("messages"), expanded=True): - render_messages(route.received) + render_messages(route.received, debug=False, in_expander=True) if route.frame: render_aifunc_frame_tail(route.frame) diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index 31b644f3..a4306b5d 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -147,6 +147,7 @@ def chatting(route: GhostChatRoute, conversation: Conversation, inputs: List[Mes event, receiver = conversation.respond(inputs) render_event(event, debug) render_receiver(receiver, debug) + while not route.input_type and rotate and conversation.available(): if event := conversation.pop_event(): render_event(event, debug) @@ -159,7 +160,6 @@ def chatting(route: GhostChatRoute, conversation: Conversation, inputs: List[Mes @st.dialog("Textarea") def video_input_dialog(route: GhostChatRoute): text = st.text_area("You message", value="") - logger.debug("++++++++++++++++ set chat_text_input: %s", text) if text: st.session_state["chat_text_input"] = text logger.debug("end of text area input") @@ -167,30 +167,33 @@ def video_input_dialog(route: GhostChatRoute): def render_receiver(receiver: Receiver, debug: bool): try: - with st.status("waiting..."): - buffer = ReceiverBuffer.new(receiver.recv()) - if buffer is None: - return with receiver: with st.chat_message("assistant"): + with st.status("waiting..."): + buffer = ReceiverBuffer.new(receiver.recv()) + if buffer is None: + return while buffer is not None: + st.logger.get_logger("ghostos").info("receive buffer head: %s", buffer.head()) if MessageType.is_text(buffer.head()): - contents = chunks_to_st_stream(buffer.chunks()) with st.empty(): + contents = chunks_to_st_stream(buffer.chunks()) st.write_stream(contents) with st.container(): - render_message_in_content(buffer.tail(), debug) + render_message_in_content(buffer.tail(), debug, in_expander=False) + elif MessageType.FUNCTION_CALL.match(buffer.head()): contents = chunks_to_st_stream(buffer.chunks()) with st.empty(): st.write_stream(contents) with st.container(): - render_message_in_content(buffer.tail(), debug) + render_message_in_content(buffer.tail(), debug, in_expander=False) else: - render_message_in_content(buffer.tail(), debug) + render_message_in_content(buffer.tail(), debug, in_expander=False) # render next item buffer = buffer.next() except Exception as e: + st.error(str(e)) st.exception(e) @@ -292,6 +295,7 @@ def render_thread_messages(thread: GoThreadInfo, max_turn: int = 20): count += render_turn(turn, debug) if count == 0: st.info("No thread messages yet") + render_empty() def render_event_object(event: Event, debug: bool): diff --git a/ghostos/prototypes/streamlitapp/widgets/dialogs.py b/ghostos/prototypes/streamlitapp/widgets/dialogs.py index 8c245278..e1116d3e 100644 --- a/ghostos/prototypes/streamlitapp/widgets/dialogs.py +++ b/ghostos/prototypes/streamlitapp/widgets/dialogs.py @@ -84,13 +84,13 @@ def open_prompt_info_dialog(prompt_id: str): if prompt.history: st.subheader(_("History")) with st.container(border=True): - render_messages(prompt.history, False, prefix) + render_messages(prompt.history, False, False, prefix=prefix) if prompt.inputs: st.subheader(_("Input")) with st.container(border=True): - render_messages(prompt.inputs, False, prefix) + render_messages(prompt.inputs, False, False, prefix) if prompt.added: st.subheader(_("Added")) with st.container(border=True): - render_messages(prompt.added, False, prefix) + render_messages(prompt.added, False, False, prefix) render_empty() diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py index 5130bdde..558b3dd0 100644 --- a/ghostos/prototypes/streamlitapp/widgets/messages.py +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -14,7 +14,7 @@ class MessageGroup(NamedTuple): messages: List[Message] -def render_messages(messages: Iterable[Message], debug: bool, prefix: str = ""): +def render_messages(messages: Iterable[Message], debug: bool, in_expander: bool, prefix: str = ""): groups: List[MessageGroup] = [] group = MessageGroup("", "", "", []) @@ -30,14 +30,11 @@ def render_messages(messages: Iterable[Message], debug: bool, prefix: str = ""): if group.messages: groups.append(group) for group in groups: - render_message_group(group, debug, prefix) + render_message_group(group, debug, in_expander, prefix) -def render_message_group(group: MessageGroup, debug: bool, prefix: str = ""): +def render_message_group(group: MessageGroup, debug: bool, in_expander: bool, prefix: str = ""): role = group.msg_role - if role not in {Role.ASSISTANT.value, Role.USER.value} and not debug: - # hide system messages. - return name = group.msg_name stage = group.stage caption = f"{role}: {name}" if name else role @@ -47,12 +44,12 @@ def render_message_group(group: MessageGroup, debug: bool, prefix: str = ""): with st.chat_message(render_role): st.caption(caption) for msg in group.messages: - render_message_in_content(msg, debug, prefix) + render_message_in_content(msg, debug, prefix=prefix, in_expander=True) else: with st.chat_message(render_role): st.caption(caption) for msg in group.messages: - render_message_in_content(msg, debug, prefix) + render_message_in_content(msg, debug, prefix=prefix, in_expander=in_expander) def render_message_payloads(message: Message, debug: bool, prefix: str = ""): @@ -91,33 +88,43 @@ def render_message_payloads(message: Message, debug: bool, prefix: str = ""): open_prompt_info_dialog(prompt_payload.prompt_id) -def render_message_in_content(message: Message, debug: bool, prefix: str = ""): - if message.type == MessageType.ERROR: +def render_message_in_content(message: Message, debug: bool, in_expander: bool, *, prefix: str = ""): + if message.type == MessageType.ERROR.value: st.error(f"Error: {message.content}") + elif MessageType.is_text(message): st.markdown(message.content) + elif MessageType.FUNCTION_CALL.match(message): callers = Caller.from_message(message) - render_message_caller(callers, debug) + render_message_caller(callers, debug, in_expander) + elif MessageType.FUNCTION_OUTPUT.match(message): - render_message_caller_output(message, debug) + render_message_caller_output(message, debug, in_expander) # todo: more types else: st.write(message.model_dump(exclude_defaults=True)) if message.callers: - render_message_caller(message.callers, debug) + render_message_caller(message.callers, debug, in_expander) render_message_payloads(message, debug, prefix) st.empty() -def render_message_caller_output(message: Message, debug: bool): - with st.expander("Caller Output", expanded=debug): +def render_message_caller_output(message: Message, debug: bool, in_expander: bool): + if not in_expander: + with st.expander("Caller Output", expanded=debug): + st.caption(f"function {message.name} output:") + st.write(message.content) + else: st.caption(f"function {message.name} output:") st.write(message.content) -def render_message_caller(callers: Iterable[Caller], debug: bool): - with st.expander("Callers", expanded=debug): +def render_message_caller(callers: Iterable[Caller], debug: bool, in_expander: bool): + if not in_expander: + with st.expander("Callers", expanded=debug): + _render_message_caller(callers) + else: _render_message_caller(callers) diff --git a/ghostos/prototypes/streamlitapp/widgets/renderer.py b/ghostos/prototypes/streamlitapp/widgets/renderer.py index ec5a3ae9..ad825da4 100644 --- a/ghostos/prototypes/streamlitapp/widgets/renderer.py +++ b/ghostos/prototypes/streamlitapp/widgets/renderer.py @@ -79,9 +79,9 @@ def render_turn(turn: Turn, debug: bool, prefix: str = "") -> int: if turn.summary is not None: st.info("summary:\n" + turn.summary) - if turn.is_from_client() or turn.is_from_self(): + if turn.is_from_inputs() or turn.is_from_self(): messages = list(turn.messages(False)) - render_messages(messages, debug, prefix) + render_messages(messages, debug, in_expander=False, prefix=prefix) return len(messages) # from other task else: @@ -90,24 +90,27 @@ def render_turn(turn: Turn, debug: bool, prefix: str = "") -> int: if event is not None: sub_title = _("background event: ") + event.type with st.expander(sub_title, expanded=False): - messages = list(turn.messages(False)) - render_messages(messages, debug, prefix) + event_messages = turn.event_messages() + render_messages(event_messages, debug, in_expander=True) render_event_object(event, debug) - return len(messages) + if turn.added: + render_messages(turn.added, debug, in_expander=False) + messages = list(turn.messages(False)) + return len(messages) def render_event(event: Event, debug: bool): from ghostos.prototypes.streamlitapp.widgets.messages import render_messages if event is None: return - if event.from_task_id: + if event.callback: sub_title = _("background event: ") + event.type with st.expander(sub_title, expanded=False): messages = event.iter_message(show_instruction=True) - render_messages(messages, debug) + render_messages(messages, debug, in_expander=True) else: messages = event.iter_message(show_instruction=True) - render_messages(messages, debug) + render_messages(messages, debug, in_expander=False) def render_event_object(event: Event, debug: bool): diff --git a/tests/core/messages/test_arr_stream_receiver.py b/tests/core/messages/test_arr_stream_receiver.py index 6b030130..448ebc03 100644 --- a/tests/core/messages/test_arr_stream_receiver.py +++ b/tests/core/messages/test_arr_stream_receiver.py @@ -1,7 +1,7 @@ from typing import Iterable from ghostos.core.messages.transport import new_basic_connection, Stream, ReceiverBuffer from ghostos.core.messages.pipeline import SequencePipe -from ghostos.core.messages.message import Message +from ghostos.core.messages.message import Message, MessageType from threading import Thread import time @@ -222,3 +222,62 @@ def send_data(s: Stream, c: str): buffer = buffer.next() assert buffer is None pool.shutdown(wait=True) + + +def test_array_receiver_with_error(): + stream, retriever = new_basic_connection(timeout=5, idle=0.2, complete_only=False) + content = "hello world" + + def send_data(s: Stream, c: str): + with s: + s.send(iter_content(c, 0.02)) + s.send([MessageType.ERROR.new(content="error")]) + + send_data(stream, content) + with retriever: + messages = retriever.wait() + assert len(messages) == 2 + assert messages[1].is_complete() + assert messages[1].type == MessageType.ERROR + + +def test_array_receiver_bad_case_1(): + item = Message( + msg_id='25c6d3d9-9bb1-45e1-ac7e-585380975ea1', + ref_id='call_SyYPOCVP60bvyLIMP3gemVYy', + index=None, + type='function_call', + stage='', + role='assistant', + name='moss', + content='', + memory=None, + attrs=None, + payloads={'task_info': {'task_id': '8d98d7772baa6776c7a169ef2028c06a', 'task_name': 'SpheroGPT', + 'process_id': '7167a681-cc2e-43aa-aab8-1781f9308e3f', + 'shell_id': 'ghostos_streamlit_app', 'thread_id': '8d98d7772baa6776c7a169ef2028c06a'}}, + callers=[], + seq='chunk', + created=1732633767.653, + ) + item2 = Message( + **{ + "msg_id": "", + "ref_id": None, + "index": None, + "type": "function_call", + "stage": "", + "role": "assistant", + "name": "SpheroGPT", + "content": "os", + "memory": None, + "attrs": None, + "payloads": {}, + "callers": [], + "seq": "chunk", + "created": 0.0, + }) + + patched = item.patch(item2) + assert patched is not None + assert patched.name == "moss" diff --git a/tests/core/messages/test_messages.py b/tests/core/messages/test_messages.py index c83864b2..737d2965 100644 --- a/tests/core/messages/test_messages.py +++ b/tests/core/messages/test_messages.py @@ -96,3 +96,17 @@ def test_function_call_message(): assert patched.ref_id == "abc" assert patched.name == "abc" assert patched.content == "hello world" + + +def test_message_path_bad_case(): + item1 = Message(msg_id='d5ff6a6a-2b05-4819-864d-82afdf9ac5fc', ref_id=None, + from_id='chatcmpl-AXs0YM2VxVZbo50C1lIOC0qlWumtN', index=None, type='function_call', stage='', + role='assistant', name=None, content='{"', memory=None, attrs=None, payloads={}, callers=[], + seq='chunk', + created=0.0) + item2 = Message(msg_id='d5ff6a6a-2b05-4819-864d-82afdf9ac5fc', ref_id='call_DCaC3PJy336sZ9ryhxijgFlq', + from_id='chatcmpl-AXs0YM2VxVZbo50C1lIOC0qlWumtN', index=None, type='function_call', stage='', + role='assistant', name='moss', content='{"', memory=None, attrs=None, payloads={}, callers=[], + seq='chunk', created=1732636557.282) + patched = item1.patch(item2) + assert patched is not None diff --git a/tests/core/messages/test_openai_parser.py b/tests/core/messages/test_openai_parser.py new file mode 100644 index 00000000..ad1c5f41 --- /dev/null +++ b/tests/core/messages/test_openai_parser.py @@ -0,0 +1,65 @@ +from ghostos.core.messages.openai import DefaultOpenAIMessageParser +from openai.types.chat.chat_completion_chunk import ( + ChatCompletionChunk, Choice, ChoiceDelta, + ChoiceDeltaToolCall, ChoiceDeltaToolCallFunction, +) +from ghostos.core.messages.message import MessageType +from ghostos.core.messages.pipeline import SequencePipe, pipeline +from ghostos.core.messages.transport import new_basic_connection + + +def test_openai_parser_bad_case_1(): + items = [ + ChatCompletionChunk(id='chatcmpl-AXs0YM2VxVZbo50C1lIOC0qlWumtN', choices=[ + Choice(delta=ChoiceDelta(content='。', fuction_call=None, refusal=None, role=None, tool_calls=None), + finish_reason=None, index=0, logprobs=None)], created=1732635794, model='gpt-4o-2024-08-06', + object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_831e067d82', + usage=None), + ChatCompletionChunk(id='chatcmpl-AXs0YM2VxVZbo50C1lIOC0qlWumtN', choices=[Choice( + delta=ChoiceDelta(content=None, function_call=None, refusal=None, role=None, tool_calls=[ + ChoiceDeltaToolCall(index=0, id='call_DCaC3PJy336sZ9ryhxijgFlq', + function=ChoiceDeltaToolCallFunction(arguments='', name='moss'), type='function')]), + finish_reason=None, index=0, logprobs=None)], created=1732635794, model='gpt-4o-2024-08-06', + object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_831e067d82', + usage=None), + ChatCompletionChunk( + id='chatcmpl-AXs0YM2VxVZbo50C1lIOC0qlWumtN', + choices=[Choice( + delta=ChoiceDelta( + content=None, function_call=None, refusal=None, role=None, + tool_calls=[ + ChoiceDeltaToolCall( + index=0, id=None, + function=ChoiceDeltaToolCallFunction( + arguments='{"', + name=None, + ), + type=None) + ]), + finish_reason=None, + index=0, + logprobs=None + ), ], + created=1732635794, + model='gpt-4o-2024-08-06', + object='chat.completion.chunk', + service_tier=None, + system_fingerprint='fp_831e067d82', + usage=None, + ) + ] + parser = DefaultOpenAIMessageParser(None, None) + pipes = [SequencePipe(), SequencePipe(), SequencePipe()] + messages = parser.from_chat_completion_chunks(items) + messages = list(pipeline(pipes, messages)) + assert len(messages) == len(items) + 2 + + stream, receiver = new_basic_connection() + with stream: + stream.send(messages) + with receiver: + got = receiver.wait() + assert len(got) == 2 + assert got[0].msg_id != got[1].msg_id + assert got[0].type == "" + assert got[1].type == MessageType.FUNCTION_CALL diff --git a/tests/framework/messenger/test_messenger.py b/tests/framework/messenger/test_messenger.py index b5a69432..3f5f59f0 100644 --- a/tests/framework/messenger/test_messenger.py +++ b/tests/framework/messenger/test_messenger.py @@ -1,5 +1,5 @@ from ghostos.framework.messengers import DefaultMessenger -from ghostos.core.messages import Message, new_basic_connection +from ghostos.core.messages import Message, new_basic_connection, MessageType, ReceiverBuffer def test_default_messenger_baseline(): @@ -29,3 +29,29 @@ def test_messenger_with_upstream(): messages = receiver.wait() assert len(flushed) == 1 assert len(messages) == 1 + + +def test_messenger_with_function_call(): + stream, receiver = new_basic_connection() + messenger = DefaultMessenger(stream) + items = [] + content = "hello world" + for c in content: + msg = Message.new_chunk(content=c) + items.append(msg) + for c in content: + msg = Message.new_chunk(content=c, typ_=MessageType.FUNCTION_CALL, ref_id="123", name="good") + items.append(msg) + with stream: + messenger.send(items) + flushed, callers = messenger.flush() + assert len(flushed) == 2 + assert len(callers) == 1 + with receiver: + buffer = ReceiverBuffer.new(receiver.recv()) + assert MessageType.is_text(buffer.head()) + assert len(list(buffer.chunks())) == len(content) + buffer = buffer.next() + assert MessageType.FUNCTION_CALL.match(buffer.head()) + assert len(list(buffer.chunks())) == len(content) + assert buffer.next() is None diff --git a/tests/helpers/test_timeleft.py b/tests/helpers/test_timeleft.py index bd83fdda..9ec97c69 100644 --- a/tests/helpers/test_timeleft.py +++ b/tests/helpers/test_timeleft.py @@ -5,3 +5,4 @@ def test_timeleft_with_zero(): left = Timeleft(0) assert left.alive() assert left.alive() + assert left.left() == 0 diff --git a/tests/python/test_collection.py b/tests/python/test_collection.py index 2dc61d9d..8f3376d6 100644 --- a/tests/python/test_collection.py +++ b/tests/python/test_collection.py @@ -21,3 +21,10 @@ def test_deque(): assert e is not None +def test_yield_from_deque(): + d = deque([1, 2, 3, 4, 5]) + + def foo(dq: deque): + yield from dq + + assert list(foo(d)) == [1, 2, 3, 4, 5] diff --git a/tests/python/test_pydantic.py b/tests/python/test_pydantic.py index 5dd87848..b07773f2 100644 --- a/tests/python/test_pydantic.py +++ b/tests/python/test_pydantic.py @@ -223,3 +223,12 @@ class Foo(BaseModel): foo = Foo(now=int(time.time())) assert foo.now.timestamp() > 0 + + +def test_print_model(): + class Foo(BaseModel): + foo: str = "hello" + + f = Foo() + assert "(" not in str(f) + assert "(" in repr(f) diff --git a/tests/python/test_set.py b/tests/python/test_set.py index c730ea87..deb40b10 100644 --- a/tests/python/test_set.py +++ b/tests/python/test_set.py @@ -1,3 +1,13 @@ def test_set_len(): s = {1, 2, 3} assert len(s) == 3 + + +def test_set_order(): + for i in range(10): + a = [1, 2, 3] + b = set(a) + c = [] + for k in b: + c.append(k) + assert a == c From bb1317199d08cdea6168f0d6dd85784b258fa5d3 Mon Sep 17 00:00:00 2001 From: zhuming Date: Wed, 27 Nov 2024 18:05:00 +0800 Subject: [PATCH 108/148] dev: implements vision of gpt-4o --- .ghostos/assets/images/.gitignore | 2 + ghostos/abcd/concepts.py | 6 +- ghostos/bootstrap.py | 4 + ghostos/contracts/assets.py | 89 ++++++++++ ghostos/contracts/workspace.py | 1 + ghostos/core/messages/__init__.py | 2 +- ghostos/core/messages/message.py | 14 +- ghostos/core/messages/message_classes.py | 155 +++++++++++++++++- ghostos/core/messages/openai.py | 3 +- ghostos/framework/assets/__init__.py | 2 + .../framework/assets/image_asset_provider.py | 22 +++ .../framework/ghostos/conversation_impl.py | 18 +- ghostos/framework/ghostos/shell_impl.py | 16 +- ghostos/framework/llms/openai_driver.py | 4 +- .../prototypes/streamlitapp/pages/ghosts.py | 81 ++++++--- .../prototypes/streamlitapp/pages/router.py | 24 ++- ghostos/prototypes/streamlitapp/resources.py | 35 +++- .../streamlitapp/tests/image_test.py | 8 + .../prototypes/streamlitapp/utils/route.py | 7 - .../streamlitapp/widgets/messages.py | 25 ++- 20 files changed, 459 insertions(+), 59 deletions(-) create mode 100644 .ghostos/assets/images/.gitignore create mode 100644 ghostos/contracts/assets.py create mode 100644 ghostos/framework/assets/__init__.py create mode 100644 ghostos/framework/assets/image_asset_provider.py create mode 100644 ghostos/prototypes/streamlitapp/tests/image_test.py diff --git a/.ghostos/assets/images/.gitignore b/.ghostos/assets/images/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/.ghostos/assets/images/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index a277d84a..e6cc115b 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -15,7 +15,7 @@ from ghostos.core.runtime.tasks import GoTaskStruct, TaskBrief from ghostos.core.runtime.threads import GoThreadInfo from ghostos.core.llms import PromptPipe, Prompt -from ghostos.core.messages import MessageKind, Message, Stream, Caller, Payload, Receiver +from ghostos.core.messages import MessageKind, Message, Stream, Caller, Payload, Receiver, Role from ghostos.contracts.logger import LoggerItf from ghostos.container import Container, Provider from ghostos.identifier import get_identifier @@ -278,6 +278,8 @@ def sync( self, ghost: G, context: Optional[G.ContextType] = None, + username: str = "", + user_role: str = Role.USER.value, ) -> Conversation[G]: """ create a top-level conversation with a ghost. @@ -389,7 +391,7 @@ def update_context(self, context: Context) -> None: @abstractmethod def respond( self, - inputs: Iterable[Message], + inputs: Iterable[MessageKind], context: Optional[G.ContextType] = None, ) -> Tuple[Event, Receiver]: """ diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 3471aaae..3d1b84c6 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -119,6 +119,7 @@ def default_application_contracts() -> Contracts: from ghostos.framework.logger import LoggerItf from ghostos.framework.documents import DocumentRegistry from ghostos.framework.ghostos import GhostOS + from ghostos.framework.assets import ImagesAsset from ghostos.core.aifunc import AIFuncExecutor, AIFuncRepository return Contracts([ @@ -126,6 +127,7 @@ def default_application_contracts() -> Contracts: Workspace, # application workspace implementation Configs, # application configs repository Variables, + ImagesAsset, # system contracts Shutdown, # graceful shutdown register @@ -176,6 +178,7 @@ def default_application_providers( from ghostos.core.messages.openai import DefaultOpenAIParserProvider from ghostos.framework.workspaces import BasicWorkspaceProvider from ghostos.framework.configs import WorkspaceConfigsProvider + from ghostos.framework.assets import WorkspaceImagesAssetProvider from ghostos.framework.processes import WorkspaceProcessesProvider from ghostos.framework.threads import MsgThreadsRepoByWorkSpaceProvider from ghostos.framework.tasks import WorkspaceTasksProvider @@ -202,6 +205,7 @@ def default_application_providers( WorkspaceTasksProvider(runtime_tasks_dir), ConfiguredDocumentRegistryProvider("documents_registry.yml"), WorkspaceVariablesProvider(), + WorkspaceImagesAssetProvider(), # --- messages --- # DefaultOpenAIParserProvider(), diff --git a/ghostos/contracts/assets.py b/ghostos/contracts/assets.py new file mode 100644 index 00000000..9de4921d --- /dev/null +++ b/ghostos/contracts/assets.py @@ -0,0 +1,89 @@ +from abc import ABC, abstractmethod +from typing import Optional, Tuple, Union +from pydantic import BaseModel, Field +from ghostos.contracts.storage import Storage +from ghostos.helpers import uuid, yaml_pretty_dump +import yaml + + +class ImageInfo(BaseModel): + image_id: str = Field(default_factory=uuid, description="ID of the image.") + filename: str = Field(description="The file name of the image.") + description: str = Field(default="", description="The description of the image.") + filetype: str = Field(default="", description="The file type of the image.") + url: Optional[str] = Field(default=None, description="The URL of the image.") + + +class ImagesAsset(ABC): + + @abstractmethod + def save(self, image: ImageInfo, binary: Optional[bytes]) -> str: + """ + save image info and binary data to storage + :param image: the image info + :param binary: binary data of the image. if None, the url must be provided + :return: relative file path of the saved image + """ + pass + + @abstractmethod + def get_binary(self, filename: str) -> Optional[bytes]: + """ + get binary data of the image + :param filename: the relative filename of the image + :return: binary data of the image, None if binary data is not available + """ + pass + + @abstractmethod + def get_image_info(self, image_id: str) -> Optional[ImageInfo]: + """ + get image info from storage + :param image_id: the image id + :return: None if no image info is available + """ + pass + + def get_binary_by_id(self, image_id: str) -> Optional[Tuple[ImageInfo, Union[bytes, None]]]: + """ + get binary data by image id + :param image_id: the image info id. + :return: image info and binary data, if binary data is None, means the image has url. + """ + image_info = self.get_image_info(image_id) + if image_info is None: + return None + return image_info, self.get_binary(image_info.filename) + + +class StorageImagesAsset(ImagesAsset): + + def __init__(self, storage: Storage): + self._storage = storage + + @staticmethod + def _get_image_info_filename(image_id: str) -> str: + return f"{image_id}.yml" + + def save(self, image: ImageInfo, binary: Optional[bytes]) -> str: + if binary is None and image.url is None: + raise AttributeError("failed to save image: binary is None and image info is not from url.") + image_info_filename = self._get_image_info_filename(image.image_id) + data = image.model_dump(exclude_none=True) + content = yaml_pretty_dump(data) + self._storage.put(image_info_filename, content.encode()) + if binary: + self._storage.put(image.filename, binary) + + def get_binary(self, filename: str) -> Optional[bytes]: + if self._storage.exists(filename): + return self._storage.get(filename) + return None + + def get_image_info(self, image_id: str) -> Optional[ImageInfo]: + image_info_filename = self._get_image_info_filename(image_id) + if not self._storage.exists(image_info_filename): + return None + content = self._storage.get(image_info_filename) + data = yaml.safe_load(content) + return ImageInfo(**data) diff --git a/ghostos/contracts/workspace.py b/ghostos/contracts/workspace.py index 54e2cd1f..cbf721d7 100644 --- a/ghostos/contracts/workspace.py +++ b/ghostos/contracts/workspace.py @@ -32,3 +32,4 @@ def configs(self) -> FileStorage: """ pass + diff --git a/ghostos/core/messages/__init__.py b/ghostos/core/messages/__init__.py index 96f07632..a6cacc21 100644 --- a/ghostos/core/messages/__init__.py +++ b/ghostos/core/messages/__init__.py @@ -5,7 +5,7 @@ MessageClassesParser, ) from ghostos.core.messages.message_classes import ( - MessageKindParser, VariableMessage, + MessageKindParser, VariableMessage, ImageAssetMessage, ) from ghostos.core.messages.payload import Payload from ghostos.core.messages.openai import ( diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 5b3a86fe..73226ca1 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -448,10 +448,10 @@ def to_message(self) -> Message: @classmethod @abstractmethod - def from_message(cls, container: Message) -> Optional[Self]: + def from_message(cls, message: Message) -> Optional[Self]: """ from a message container generate a strong-typed one. - :param container: + :param message: :return: None means type not match. """ pass @@ -509,13 +509,13 @@ def to_message(self) -> Message: ) @classmethod - def from_message(cls, container: Message) -> Optional[Self]: - if container.type != MessageType.FUNCTION_OUTPUT.value: + def from_message(cls, message: Message) -> Optional[Self]: + if message.type != MessageType.FUNCTION_OUTPUT.value: return None return cls( - call_id=container.ref_id, - name=container.name, - content=container.content, + call_id=message.ref_id, + name=message.name, + content=message.content, ) def to_openai_param(self, container: Optional[Container]) -> List[Dict]: diff --git a/ghostos/core/messages/message_classes.py b/ghostos/core/messages/message_classes.py index d6e79870..4932963e 100644 --- a/ghostos/core/messages/message_classes.py +++ b/ghostos/core/messages/message_classes.py @@ -1,13 +1,21 @@ -from typing import Optional, Dict, List, Iterable, Any, Union +import base64 +from typing import Optional, Dict, List, Iterable, Any, Union, Literal from typing_extensions import Self from ghostos.contracts.variables import Variables +from ghostos.contracts.assets import ImageInfo from ghostos.container import Container from ghostos.prompter import get_defined_prompt from .message import Message, MessageClass, MessageType, CallerOutput, MessageKind, Role +from ghostos.helpers import uuid from pydantic import BaseModel, Field -__all__ = ["VariableMessage", "CallerOutput", "MessageKindParser"] +__all__ = [ + "VariableMessage", + "CallerOutput", + "ImageAssetMessage", + "MessageKindParser", +] class VariableMessage(MessageClass, BaseModel): @@ -17,16 +25,16 @@ class VariableMessage(MessageClass, BaseModel): __message_type__ = MessageType.VARIABLE.value + msg_id: str = Field(default_factory=uuid, description="message id") + payloads: Dict[str, Dict] = Field( + default_factory=dict, + description="payload type key to payload item. payload shall be a strong-typed dict" + ) role: str = Field(default="", description="who send the message") name: Optional[str] = Field(None, description="who send the message") - attrs: Variables.Var = Field( description="variable pointer info" ) - payloads: Dict[str, Dict] = Field( - default_factory=dict, - description="payload type key to payload item. payload shall be a strong-typed dict" - ) def to_message(self) -> Message: message = Message.new_tail( @@ -35,6 +43,7 @@ def to_message(self) -> Message: role=self.role, name=self.name, attrs=self.attrs.model_dump(), + msg_id=self.msg_id, ) message.payloads = self.payloads return message @@ -45,6 +54,7 @@ def from_message(cls, message: Message) -> Optional[Self]: return None obj = cls( + msg_id=message.msg_id, role=message.role, name=message.name, attrs=message.attrs, @@ -72,6 +82,137 @@ def to_openai_param(self, container: Optional[Container]) -> List[Dict]: )] +class ImageId(BaseModel): + image_id: str = Field(description="image id") + detail: Literal["auto", "high", "low"] = Field(default="auto", description="image quality") + + +class ImageAttrs(BaseModel): + images: List[ImageId] = Field(default_factory=list, description="file id") + + +class ImageAssetMessage(MessageClass, BaseModel): + msg_id: str = Field(default_factory=uuid, description="message id") + payloads: Dict[str, Dict] = Field( + default_factory=dict, + description="payload type key to payload item. payload shall be a strong-typed dict" + ) + role: str = Field(default="", description="who send the message") + name: Optional[str] = Field(None, description="who send the message") + content: Optional[str] = Field("", description="content of the image message") + + attrs: ImageAttrs = Field(description="image assert id") + + __message_type__ = MessageType.IMAGE.value + + def to_message(self) -> Message: + message = Message.new_tail( + role=self.role, + name=self.name, + content=self.content, + type_=self.__message_type__, + attrs=self.attrs.model_dump(), + msg_id=self.msg_id, + ) + message.payloads = self.payloads + return message + + @classmethod + def from_image_asset( + cls, + name: str, + content: str, + images: List[ImageInfo], + role: str = Role.USER.value, + ) -> Self: + attrs = ImageAttrs(images=[ + ImageId(image_id=image_info.image_id) + for image_info in images + ]) + return cls( + name=name, + content=content, + role=role, + attrs=attrs, + ) + + @classmethod + def from_message(cls, message: Message) -> Optional[Self]: + return cls( + msg_id=message.msg_id, + role=message.role, + name=message.name, + content=message.content, + attrs=message.attrs, + payloads=message.payloads, + ) + + def to_openai_param(self, container: Optional[Container]) -> List[Dict]: + from openai.types.chat.chat_completion_content_part_text_param import ChatCompletionContentPartTextParam + from openai.types.chat.chat_completion_content_part_image_param import ( + ChatCompletionContentPartImageParam, ImageURL, + ) + from openai.types.chat.chat_completion_user_message_param import ( + ChatCompletionUserMessageParam, + ) + from openai.types.chat.chat_completion_assistant_message_param import ( + ChatCompletionAssistantMessageParam, + ) + from ghostos.contracts.assets import ImagesAsset + content = self.content + image_id_and_desc = [] + content_parts = [] + if self.attrs is not None and self.attrs.images and container: + images = container.force_fetch(ImagesAsset) + for image_id_info in self.attrs.images: + got = images.get_binary_by_id(image_id_info.image_id) + if got is None: + continue + image_info, binary = got + if binary: + encoded = base64.b64encode(binary).decode('utf-8') + url = f"data:{image_info.filetype};base64,{encoded}" + else: + url = image_info.url + if not url: + continue + content_parts.append(ChatCompletionContentPartImageParam( + type="image_url", + image_url=ImageURL( + url=url, + detail=image_id_info.detail, + ), + )) + image_id_and_desc.append((image_id_info.image_id, image_info.description)) + if image_id_and_desc: + attachment = "\n(about follow images:" + order = 0 + for image_id, desc in image_id_and_desc: + order += 1 + attachment += f"\n[{order}] id: `{image_id}` desc: `{desc}`" + content = content + attachment + ")" + content = content.strip() + if content: + content_parts.insert(0, ChatCompletionContentPartTextParam( + text=content, + type="text", + )) + + if self.role == Role.ASSISTANT.value: + item = ChatCompletionAssistantMessageParam( + role=Role.ASSISTANT.value, + content=content_parts, + ) + else: + item = ChatCompletionUserMessageParam( + role=Role.USER.value, + content=content_parts, + ) + if self.name: + item["name"] = self.name + return [item] + + class MessageKindParser: """ middleware that parse weak MessageKind into Message chunks diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index 34fd9e88..feb9155f 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -14,7 +14,7 @@ Message, MessageType, Role, Caller, Payload, MessageClass, MessageClassesParser ) from ghostos.core.messages.message_classes import ( - CallerOutput, VariableMessage, + CallerOutput, VariableMessage, ImageAssetMessage, ) from ghostos.contracts.logger import LoggerItf, FakeLogger from ghostos.container import Provider, Container @@ -105,6 +105,7 @@ def __init__( message_classes = [ CallerOutput, VariableMessage, + ImageAssetMessage, ] self.class_parser = MessageClassesParser(message_classes) self.container: Optional[Container] = container diff --git a/ghostos/framework/assets/__init__.py b/ghostos/framework/assets/__init__.py new file mode 100644 index 00000000..0700d166 --- /dev/null +++ b/ghostos/framework/assets/__init__.py @@ -0,0 +1,2 @@ +from ghostos.contracts.assets import ImagesAsset +from ghostos.framework.assets.image_asset_provider import WorkspaceImagesAssetProvider diff --git a/ghostos/framework/assets/image_asset_provider.py b/ghostos/framework/assets/image_asset_provider.py new file mode 100644 index 00000000..fae98943 --- /dev/null +++ b/ghostos/framework/assets/image_asset_provider.py @@ -0,0 +1,22 @@ +from typing import Optional + +from ghostos.contracts.assets import ImagesAsset, StorageImagesAsset +from ghostos.contracts.workspace import Workspace +from ghostos.container import Container, Provider, INSTANCE + + +class WorkspaceImagesAssetProvider(Provider[ImagesAsset]): + """ + workspace based image asset provider. + """ + + def __init__(self, dirname: str = "images"): + self._dirname = dirname + + def singleton(self) -> bool: + return True + + def factory(self, con: Container) -> Optional[INSTANCE]: + ws = con.force_fetch(Workspace) + storage = ws.assets().sub_storage(self._dirname) + return StorageImagesAsset(storage) diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index dba213e4..08d1b4f5 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -4,8 +4,9 @@ from ghostos.abcd import Conversation, Scope, Ghost, Context from ghostos.abcd import run_session_event from ghostos.errors import SessionError +from ghostos.contracts.variables import Variables from ghostos.core.messages import ( - Message, Role, + Message, Role, MessageKind, MessageKindParser, Stream, Receiver, new_basic_connection, ) from ghostos.core.runtime import ( @@ -51,12 +52,22 @@ def __init__( task_locker: TaskLocker, is_background: bool, shell_closed: Callable[[], bool], + username: str = "", + user_role: str = Role.USER.value, ): self._closed = False self._conf = conf self.task_id = task.task_id self._container = Container(parent=container, name="conversation") self.logger = self._container.force_fetch(LoggerItf) + self._username = username + self._user_role = user_role + variables = self._container.force_fetch(Variables) + self._message_parser = MessageKindParser( + variables, + name=self._username, + role=self._user_role, + ) self._scope = Scope( shell_id=task.shell_id, process_id=task.process_id, @@ -144,20 +155,21 @@ def update_context(self, context: Context) -> None: def respond( self, - inputs: Iterable[Message], + inputs: Iterable[MessageKind], context: Optional[Ghost.ContextType] = None, ) -> Tuple[Event, Receiver]: self._validate_closed() if self._submit_session_thread: self._submit_session_thread.join() self._submit_session_thread = None + messages = list(self._message_parser.parse(inputs)) context_meta = to_entity_meta(context) if context is not None else None if self._ctx is not None: context_meta = to_entity_meta(self._ctx) self._ctx = None event = EventTypes.INPUT.new( task_id=self._scope.task_id, - messages=list(inputs), + messages=messages, context=context_meta, ) return event, self.respond_event(event) diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index 96165104..7a15ec72 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -5,7 +5,7 @@ from ghostos.container import Container, Provider from ghostos.abcd import Shell, Conversation, Ghost, Scope, Background from ghostos.abcd.utils import get_ghost_driver -from ghostos.core.messages import Message, Receiver +from ghostos.core.messages import Message, Receiver, Role from ghostos.core.runtime import ( Event, GoProcess, EventBus, GoTasks, TaskState, GoTaskStruct, @@ -96,7 +96,13 @@ def send_event(self, event: Event) -> None: notify = task.depth > 0 self._eventbus.send_event(event, notify) - def sync(self, ghost: Ghost, context: Optional[Ghost.ContextType] = None) -> Conversation: + def sync( + self, + ghost: Ghost, + context: Optional[Ghost.ContextType] = None, + username: str = "", + user_role: str = Role.USER.value, + ) -> Conversation: driver = get_ghost_driver(ghost) task_id = driver.make_task_id(self._scope) self.logger.debug("sync ghost with task id %s", task_id) @@ -109,7 +115,7 @@ def sync(self, ghost: Ghost, context: Optional[Ghost.ContextType] = None) -> Con task.meta = to_entity_meta(ghost) if context is not None: task.context = to_entity_meta(context) - conversation = self.sync_task(task, throw=True, is_background=False) + conversation = self.sync_task(task, throw=True, is_background=False, username=username, user_role=user_role) return conversation def sync_task( @@ -118,6 +124,8 @@ def sync_task( *, throw: bool, is_background: bool, + username: str = "", + user_role: str = "", ) -> Optional[Conversation]: locker = self._tasks.lock_task(task.task_id, self._conf.task_lock_overdue) if locker.acquire(): @@ -133,6 +141,8 @@ def sync_task( task_locker=locker, is_background=is_background, shell_closed=self.closed, + username=username, + user_role=user_role, ) exists = self._conversations running = [] diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index fc5ba2c6..a083feae 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -119,7 +119,7 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion try: prompt.run_start = timestamp() get_ghostos_logger().debug(f"start chat completion for prompt %s", prompt.id) - get_ghostos_logger().debug(f"start chat completion messages %s", messages) + get_ghostos_logger().info(f"start chat completion messages %s", messages) functions = prompt.get_openai_functions() tools = prompt.get_openai_tools() if self._model.use_tools: @@ -140,6 +140,8 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion stream_options=include_usage, **self._model.kwargs, ) + except Exception as e: + get_ghostos_logger().error(f"error chat completion for prompt {prompt.id}: {e}") finally: get_ghostos_logger().debug(f"end chat completion for prompt {prompt.id}") prompt.run_end = timestamp() diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index a4306b5d..b3e30e11 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -13,9 +13,12 @@ render_object, render_event, render_turn, render_empty, ) -from ghostos.prototypes.streamlitapp.resources import get_app_conf +from ghostos.prototypes.streamlitapp.resources import get_app_conf, save_uploaded_image from ghostos.core.runtime import GoThreadInfo, Event, GoTaskStruct -from ghostos.core.messages import Receiver, Role, ReceiverBuffer, MessageType, Message +from ghostos.core.messages import ( + Receiver, Role, ReceiverBuffer, MessageType, Message, + ImageAssetMessage, +) from streamlit.logger import get_logger from ghostos.abcd import Shell, Conversation, Context from ghostos.identifier import get_identifier @@ -23,6 +26,7 @@ from ghostos.helpers import gettext as _ from ghostos.helpers import generate_import_path, yaml_pretty_dump from ghostos.scripts.cli.utils import GhostsConf, GhostInfo +from streamlit.runtime.uploaded_file_manager import DeletedFile, UploadedFile from pydantic import BaseModel import inspect @@ -51,18 +55,23 @@ def main_chat(): st.subheader("chat options") with st.container(border=True): - auto_run = st.toggle( + route.auto_run = st.toggle( "auto run event", help="automatic run background event", value=True, ) - show_video = st.toggle("show video") - show_image_file = st.toggle("upload image") + route.camera_input = st.toggle( + "camera_input", + value=route.camera_input, + key=route.generate_key(st.session_state, "camera_input"), + ) + route.image_input = st.toggle( + "image input", + value=route.image_input, + key=route.generate_key(st.session_state, "image input"), + ) + route.bind(st.session_state) - if show_video: - pic = st.camera_input("Task a picture") - if show_image_file: - image = st.file_uploader("Upload image", type=["png", "jpg", "jpeg"]) render_empty() # header @@ -105,14 +114,8 @@ def main_chat(): # inputs if show_chatting: st.subheader("Chat") - inputs = [] - if chat_input := st.chat_input("message"): - inputs = route.get_route_bound([], "inputs") - inputs.append(Role.USER.new(chat_input)) - route.bind_to_route([], "inputs") - route.input_type = "" - chatting(route, conversation, inputs, auto_run) + chatting(route, conversation) def get_conversation(route: GhostChatRoute) -> Conversation: @@ -138,23 +141,53 @@ def main_task(): render_task_info_settings(task, thread) -def chatting(route: GhostChatRoute, conversation: Conversation, inputs: List[Message], rotate: bool): +def chatting(route: GhostChatRoute, conversation: Conversation): + chat_input = st.chat_input("message") + thread = conversation.thread() render_thread_messages(thread, max_turn=20) debug = get_app_conf().BoolOpts.DEBUG_MODE.get() + pics: List[UploadedFile] = [] + if route.camera_input: + if pic := st.camera_input("Task picture"): + pics.append(pic) + else: + st.empty() + + inputs = st.session_state["ghostos_inputs"] if "ghostos_inputs" in st.session_state else [] + st.session_state["ghostos_inputs"] = [] + if chat_input: + if pics: + saved_images = [] + for p in pics: + image_info = save_uploaded_image(p) + saved_images.append(image_info) + + message = ImageAssetMessage.from_image_asset( + name="", + content=chat_input, + images=saved_images, + ) + inputs.append(message) + st.session_state["ghostos_inputs"] = inputs + route.new_render_turn(st.session_state) + st.rerun() + else: + inputs.append(Role.USER.new(chat_input)) + if inputs: event, receiver = conversation.respond(inputs) render_event(event, debug) render_receiver(receiver, debug) - while not route.input_type and rotate and conversation.available(): - if event := conversation.pop_event(): - render_event(event, debug) - receiver = conversation.respond_event(event) - render_receiver(receiver, debug) - else: - time.sleep(1) + # while not route.media_input() and route.auto_run and conversation.available(): + # if event := conversation.pop_event(): + # render_event(event, debug) + # receiver = conversation.respond_event(event) + # render_receiver(receiver, debug) + # else: + # time.sleep(1) @st.dialog("Textarea") diff --git a/ghostos/prototypes/streamlitapp/pages/router.py b/ghostos/prototypes/streamlitapp/pages/router.py index ffad77e5..292ae683 100644 --- a/ghostos/prototypes/streamlitapp/pages/router.py +++ b/ghostos/prototypes/streamlitapp/pages/router.py @@ -45,10 +45,32 @@ class GhostChatRoute(Route): ghost_meta: Optional[EntityMeta] = Field(default=None, description="ghost meta") context_meta: Optional[EntityMeta] = Field(default=None, description="context meta") filename: Optional[str] = Field(default=None, description="filename to lunch the ghost") - input_type: str = Field(default="", description="input type") + camera_input: bool = Field(default=False, description="camera input") + image_input: bool = Field(default=False, description="image input") + auto_run: bool = Field(default=True, description="auto run") __ghost__ = None + def generate_key(self, session_state, key: str) -> str: + turn = self.get_render_turn(session_state) + return generate_import_path(self.__class__) + "-turn-" + str(turn) + "-key-" + key + + def media_input(self) -> bool: + return self.camera_input or self.image_input + + def get_render_turn(self, session_state) -> int: + key = generate_import_path(self.__class__) + ":turn" + if key not in session_state: + session_state[key] = 0 + return session_state[key] + + def new_render_turn(self, session_state) -> int: + key = generate_import_path(self.__class__) + ":turn" + if key not in session_state: + session_state[key] = 0 + session_state[key] += 1 + return session_state[key] + def get_ghost(self) -> Ghost: if self.__ghost__ is None: self.__ghost__ = get_entity(self.ghost_meta, Ghost) diff --git a/ghostos/prototypes/streamlitapp/resources.py b/ghostos/prototypes/streamlitapp/resources.py index 6cc416ca..c1304b1d 100644 --- a/ghostos/prototypes/streamlitapp/resources.py +++ b/ghostos/prototypes/streamlitapp/resources.py @@ -1,4 +1,4 @@ -from typing import Optional, Dict +from typing import Optional, Dict, Tuple, List from enum import Enum from pydantic import Field @@ -6,8 +6,11 @@ from ghostos.container import Container from ghostos.prototypes.streamlitapp.utils.session import Singleton from ghostos.contracts.configs import YamlConfig, Configs +from ghostos.contracts.assets import ImagesAsset, ImageInfo from ghostos.contracts.documents import DocumentRegistry, Documents +from ghostos.core.messages.message_classes import ImageAssetMessage from ghostos.helpers import GHOSTOS_DOMAIN +from streamlit.runtime.uploaded_file_manager import DeletedFile, UploadedFile @st.cache_resource @@ -75,3 +78,33 @@ def get_app_docs() -> Documents: conf = get_app_conf() registry = get_container().force_fetch(DocumentRegistry) return registry.get_domain(conf.domain, conf.lang) + + +@st.cache_resource +def get_images_asset() -> ImagesAsset: + container = get_container() + return container.force_fetch(ImagesAsset) + + +def save_uploaded_image(file: UploadedFile) -> ImageInfo: + assets = get_images_asset() + image_info = ImageInfo( + image_id=file.file_id, + filename=file.name, + description="streamlit camera input", + filetype=file.type, + ) + binary = file.getvalue() + assets.save(image_info, binary) + return image_info + + +def get_asset_images(image_ids: List[str]) -> Dict[str, Tuple[ImageInfo, Optional[bytes]]]: + result = {} + assets = get_images_asset() + for image_id in image_ids: + data = assets.get_binary_by_id(image_id) + if data is None: + continue + result[image_id] = data + return result diff --git a/ghostos/prototypes/streamlitapp/tests/image_test.py b/ghostos/prototypes/streamlitapp/tests/image_test.py new file mode 100644 index 00000000..826d72b9 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/image_test.py @@ -0,0 +1,8 @@ +import streamlit as st + +if pic := st.camera_input("You photo"): + st.write(pic) + data = pic.getvalue() + with open("pic.jpg", "wb") as f: + f.write(data) + st.write("write to file") diff --git a/ghostos/prototypes/streamlitapp/utils/route.py b/ghostos/prototypes/streamlitapp/utils/route.py index 2c14df22..92e75f22 100644 --- a/ghostos/prototypes/streamlitapp/utils/route.py +++ b/ghostos/prototypes/streamlitapp/utils/route.py @@ -174,13 +174,6 @@ def get_route_bound(cls, value: T, key: str = "") -> T: st.session_state[session_key] = value return value - @classmethod - def bind_to_route(cls, value, key: str = ""): - if not key: - key = generate_import_path(type(value)) - session_key = cls.session_state_key() + ":" + key - st.session_state[session_key] = value - class Router: diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py index 558b3dd0..3bbbde4e 100644 --- a/ghostos/prototypes/streamlitapp/widgets/messages.py +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -2,7 +2,10 @@ import streamlit as st from typing import Iterable, List, NamedTuple -from ghostos.core.messages import Message, Role, MessageType, Caller +from ghostos.core.messages import ( + Message, Role, MessageType, Caller, + ImageAssetMessage, +) from ghostos.framework.messages import CompletionUsagePayload, TaskPayload, PromptPayload from ghostos.helpers import gettext as _ @@ -102,6 +105,9 @@ def render_message_in_content(message: Message, debug: bool, in_expander: bool, elif MessageType.FUNCTION_OUTPUT.match(message): render_message_caller_output(message, debug, in_expander) # todo: more types + elif MessageType.IMAGE.match(message): + # render image type message + render_image_message(message) else: st.write(message.model_dump(exclude_defaults=True)) if message.callers: @@ -110,6 +116,23 @@ def render_message_in_content(message: Message, debug: bool, in_expander: bool, st.empty() +def render_image_message(message: Message): + from ghostos.prototypes.streamlitapp.resources import get_asset_images + if message.type != MessageType.IMAGE.value: + return + image_msg = ImageAssetMessage.from_message(message) + content = image_msg.content + # render content first + st.markdown(content) + image_ids = [image_id.image_id for image_id in image_msg.attrs.images] + got = get_asset_images(image_ids) + for image_info, binary in got.values(): + if binary: + st.image(binary, use_column_width=True) + elif image_info.url: + st.image(image_info.url, use_column_width=True) + + def render_message_caller_output(message: Message, debug: bool, in_expander: bool): if not in_expander: with st.expander("Caller Output", expanded=debug): From 57c4d4812cb41e94628a225cd4d61d5e4406a51a Mon Sep 17 00:00:00 2001 From: zhuming Date: Wed, 27 Nov 2024 21:43:52 +0800 Subject: [PATCH 109/148] dev: add image upload and paste --- ghostos/core/messages/openai.py | 2 ++ .../prototypes/streamlitapp/pages/ghosts.py | 36 ++++++++++++++----- ghostos/prototypes/streamlitapp/resources.py | 25 +++++++++++-- .../streamlitapp/widgets/messages.py | 4 +-- pyproject.toml | 1 + 5 files changed, 56 insertions(+), 12 deletions(-) diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index feb9155f..dc2ea4b8 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -253,6 +253,8 @@ def from_chat_completion(self, message: ChatCompletionMessage) -> Message: def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) -> Iterable[Message]: # 创建首包, 并发送. + if messages is None: + return [] buffer = None for item in messages: chunk = None diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index b3e30e11..ee2c5cbc 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -1,6 +1,7 @@ import streamlit as st -import time import streamlit_react_jsonschema as srj +import streamlit_paste_button as spb +from PIL.Image import Image from typing import Iterable, List from ghostos.prototypes.streamlitapp.pages.router import ( GhostChatRoute, GhostTaskRoute, @@ -13,12 +14,15 @@ render_object, render_event, render_turn, render_empty, ) -from ghostos.prototypes.streamlitapp.resources import get_app_conf, save_uploaded_image +from ghostos.prototypes.streamlitapp.resources import ( + get_app_conf, save_uploaded_image, save_pil_image, +) from ghostos.core.runtime import GoThreadInfo, Event, GoTaskStruct from ghostos.core.messages import ( Receiver, Role, ReceiverBuffer, MessageType, Message, ImageAssetMessage, ) +from ghostos.contracts.assets import ImageInfo from streamlit.logger import get_logger from ghostos.abcd import Shell, Conversation, Context from ghostos.identifier import get_identifier @@ -56,17 +60,19 @@ def main_chat(): st.subheader("chat options") with st.container(border=True): route.auto_run = st.toggle( - "auto run event", - help="automatic run background event", + _("auto run event"), + help=_("automatic run background event"), value=True, ) route.camera_input = st.toggle( - "camera_input", + _("camera_input"), + help=_("take picture from camera, the model shall support image type"), value=route.camera_input, key=route.generate_key(st.session_state, "camera_input"), ) route.image_input = st.toggle( "image input", + help=_("upload picture, the model shall support image type"), value=route.image_input, key=route.generate_key(st.session_state, "image input"), ) @@ -150,8 +156,18 @@ def chatting(route: GhostChatRoute, conversation: Conversation): pics: List[UploadedFile] = [] if route.camera_input: - if pic := st.camera_input("Task picture"): + if pic := st.camera_input(_("Task picture")): + pics.append(pic) + else: + st.empty() + + if route.image_input: + if pic := st.file_uploader(_("Choose a picture"), type=["png", "jpg", "jpeg"]): pics.append(pic) + paste = spb.paste_image_button(_("Paste a picture")) + if paste.image_data is not None: + st.image(paste.image_data, width=300) + pics.append(paste.image_data) else: st.empty() @@ -161,8 +177,12 @@ def chatting(route: GhostChatRoute, conversation: Conversation): if pics: saved_images = [] for p in pics: - image_info = save_uploaded_image(p) - saved_images.append(image_info) + if isinstance(p, UploadedFile): + image_info = save_uploaded_image(p) + saved_images.append(image_info) + elif isinstance(p, Image): + image_info = save_pil_image(p, "user paste image") + saved_images.append(image_info) message = ImageAssetMessage.from_image_asset( name="", diff --git a/ghostos/prototypes/streamlitapp/resources.py b/ghostos/prototypes/streamlitapp/resources.py index c1304b1d..1d9191d5 100644 --- a/ghostos/prototypes/streamlitapp/resources.py +++ b/ghostos/prototypes/streamlitapp/resources.py @@ -8,8 +8,9 @@ from ghostos.contracts.configs import YamlConfig, Configs from ghostos.contracts.assets import ImagesAsset, ImageInfo from ghostos.contracts.documents import DocumentRegistry, Documents +from PIL.Image import Image as ImageType from ghostos.core.messages.message_classes import ImageAssetMessage -from ghostos.helpers import GHOSTOS_DOMAIN +from ghostos.helpers import GHOSTOS_DOMAIN, uuid from streamlit.runtime.uploaded_file_manager import DeletedFile, UploadedFile @@ -87,7 +88,6 @@ def get_images_asset() -> ImagesAsset: def save_uploaded_image(file: UploadedFile) -> ImageInfo: - assets = get_images_asset() image_info = ImageInfo( image_id=file.file_id, filename=file.name, @@ -95,7 +95,28 @@ def save_uploaded_image(file: UploadedFile) -> ImageInfo: filetype=file.type, ) binary = file.getvalue() + save_image_info(image_info, binary) + return image_info + + +def save_image_info(image_info: ImageInfo, binary: bytes) -> None: + assets = get_images_asset() assets.save(image_info, binary) + + +def save_pil_image(image: ImageType, desc: str) -> ImageInfo: + from io import BytesIO + file_id = uuid() + img_bytes = BytesIO() + image.save(img_bytes, format='PNG') + binary = img_bytes.getvalue() + image_info = ImageInfo( + image_id=file_id, + filename=file_id + ".png", + filetype="image/png", + description=desc + ) + save_image_info(image_info, binary) return image_info diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py index 3bbbde4e..cfb1c8f5 100644 --- a/ghostos/prototypes/streamlitapp/widgets/messages.py +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -128,9 +128,9 @@ def render_image_message(message: Message): got = get_asset_images(image_ids) for image_info, binary in got.values(): if binary: - st.image(binary, use_column_width=True) + st.image(binary) elif image_info.url: - st.image(image_info.url, use_column_width=True) + st.image(image_info.url) def render_message_caller_output(message: Message, debug: bool, in_expander: bool): diff --git a/pyproject.toml b/pyproject.toml index 6c4e8378..9c8574ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ babel = "^2.16.0" websockets = "^13.1" pysocks = "^1.7.1" requests = { extras = ["socks"], version = "^2.32.3" } +streamlit-paste-button = "^0.1.2" [tool.poetry.scripts] init = "ghostos.scripts.init:main" From 91449d49e2f9a827c12ced42d6f6d99d4eb50a00 Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 28 Nov 2024 15:21:41 +0800 Subject: [PATCH 110/148] dev: shell add thread mutex when checking closed conversation --- .ghostos/configs/streamlit_app.yml | 2 +- ghostos/abcd/concepts.py | 4 ++++ ghostos/framework/ghostos/session_impl.py | 9 +++++++++ ghostos/framework/ghostos/shell_impl.py | 17 +++++++++++------ ghostos/ghosts/chatbot/simplest.py | 3 +++ ghostos/ghosts/moss_agent/agent.py | 9 ++++++++- ghostos/ghosts/moss_agent/for_developer.py | 4 ++++ ghostos/prototypes/streamlitapp/main.py | 2 +- 8 files changed, 41 insertions(+), 9 deletions(-) diff --git a/.ghostos/configs/streamlit_app.yml b/.ghostos/configs/streamlit_app.yml index d026c718..ba7b6af7 100644 --- a/.ghostos/configs/streamlit_app.yml +++ b/.ghostos/configs/streamlit_app.yml @@ -1,6 +1,6 @@ # from class: ghostos.prototypes.streamlitapp.resources:AppConf bool_options: - DEBUG_MODE: true + DEBUG_MODE: false HELP_MODE: false domain: ghostos lang: zh diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index e6cc115b..57b027e3 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -140,6 +140,10 @@ def parse_event( ) -> Union[Event, None]: pass + @abstractmethod + def on_creating(self, session: Session) -> None: + pass + @abstractmethod def on_event(self, session: Session, event: Event) -> Union[Operator, None]: """ diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index afdde8c6..a71597a6 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -134,6 +134,11 @@ def to_messages(self, values: Iterable[Union[MessageKind, Any]]) -> List[Message def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator]]: self._validate_alive() driver = get_ghost_driver(self.ghost) + if self.task.state == TaskState.NEW.value: + driver.on_creating(self) + self.task.state = TaskState.RUNNING.value + return event, None + # always let ghost driver decide event handling logic first. event = driver.parse_event(self, event) if event is None: @@ -146,6 +151,7 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] if EventTypes.INPUT.value == event.type: # only input event can reset errors. self.task.errors = 0 + self.task.state = TaskState.RUNNING.value if event.context is not None: self.task.context = event.context @@ -344,6 +350,9 @@ def _update_state_changes(self) -> None: task.thread_id = thread.id task.state_values = state_values + if task.state == TaskState.RUNNING.value: + task.state = TaskState.WAITING.value + tasks = self.container.force_fetch(GoTasks) threads = self.container.force_fetch(GoThreads) self.logger.debug("task info %s", task.model_dump()) diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index 7a15ec72..dc2e8b48 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -1,5 +1,5 @@ import time -from typing import Union, Optional, Iterable, List, Tuple, TypeVar, Callable, Dict +from typing import Union, Optional, Iterable, List, Tuple, TypeVar, Callable from ghostos.contracts.logger import LoggerItf, get_ghostos_logger from ghostos.contracts.pool import Pool, DefaultPool from ghostos.container import Container, Provider @@ -14,6 +14,7 @@ from ghostos.helpers import uuid, Timeleft, import_from_path from ghostos.identifier import get_identifier from ghostos.entity import to_entity_meta +from threading import Lock from pydantic import BaseModel, Field from .conversation_impl import ConversationImpl, ConversationConf @@ -47,6 +48,7 @@ def __init__( process: GoProcess, providers: List[Provider], ): + self._conversation_mutex = Lock() self._conf = config self._container = Container(parent=container, name="shell") # prepare container @@ -77,7 +79,6 @@ def __init__( self._container.set(Shell, self) self._container.set(ShellImpl, self) self._container.set(ShellConf, config) - self._container.bootstrap() self._conversations: List[Conversation] = [] @@ -144,6 +145,14 @@ def sync_task( username=username, user_role=user_role, ) + self._join_conversation(conversation) + return conversation + elif throw: + raise RuntimeError(f'create conversation failed, Task {task.task_id} already locked') + return None + + def _join_conversation(self, conversation: Conversation): + with self._conversation_mutex: exists = self._conversations running = [] # remove closed ones @@ -153,10 +162,6 @@ def sync_task( running.append(c) running.append(conversation) self._conversations = running - return conversation - elif throw: - raise RuntimeError(f'create conversation failed, Task {task.task_id} already locked') - return None def call( self, diff --git a/ghostos/ghosts/chatbot/simplest.py b/ghostos/ghosts/chatbot/simplest.py index bfbe4a49..458826ea 100644 --- a/ghostos/ghosts/chatbot/simplest.py +++ b/ghostos/ghosts/chatbot/simplest.py @@ -61,6 +61,9 @@ def on_event(self, session: Session, event: Event) -> Union[Operator, None]: return method(session, event) return self.default_handle_event(session, event) + def on_creating(self, session: Session) -> None: + return + def thought(self, session: Session) -> Thought: thought = LLMThought(llm_api=self.ghost.llm_api) return thought diff --git a/ghostos/ghosts/moss_agent/agent.py b/ghostos/ghosts/moss_agent/agent.py index dcea050c..6838d21f 100644 --- a/ghostos/ghosts/moss_agent/agent.py +++ b/ghostos/ghosts/moss_agent/agent.py @@ -132,8 +132,15 @@ def get_actions(self, session: Session, runtime: MossRuntime) -> Iterable[Action moss_action = MossAction(runtime) yield moss_action - def on_event(self, session: Session, event: Event) -> Union[Operator, None]: + def on_creating(self, session: Session) -> None: + from ghostos.ghosts.moss_agent.for_developer import __moss_agent_creating__ as fn + m = self.get_module() + if fn.__name__ in m.__dict__: + fn = m.__dict__[fn.__name__] + fn(self.ghost, session) + return + def on_event(self, session: Session, event: Event) -> Union[Operator, None]: compiler = self._get_moss_compiler(session) with compiler: rtm = compiler.compile(self.ghost.compile_module) diff --git a/ghostos/ghosts/moss_agent/for_developer.py b/ghostos/ghosts/moss_agent/for_developer.py index 587477f1..c34d7f2e 100644 --- a/ghostos/ghosts/moss_agent/for_developer.py +++ b/ghostos/ghosts/moss_agent/for_developer.py @@ -22,6 +22,10 @@ def __moss_agent_providers__(agent: A) -> Iterable[Provider]: return [] +def __moss_agent_creating__(agent: A, session: Session) -> None: + pass + + def __moss_agent_truncate__(agent: MossAgent, session: Session) -> GoThreadInfo: """ default truncate logic of the agent diff --git a/ghostos/prototypes/streamlitapp/main.py b/ghostos/prototypes/streamlitapp/main.py index bb74c664..237f0110 100644 --- a/ghostos/prototypes/streamlitapp/main.py +++ b/ghostos/prototypes/streamlitapp/main.py @@ -82,7 +82,7 @@ def main_run(bootstrap: BOOTSTRAP) -> None: # icon=":material/menu:", # use_container_width=True, # ) - with st.expander(label="Options", expanded=False, icon=":material/settings:"): + with st.expander(label="Options", expanded=True, icon=":material/settings:"): AppConf.BoolOpts.HELP_MODE.render_toggle( label=_("Help Mode"), tips=_("switch help mode at every page"), From 861366d4d8d04869841e09edda2117ca5018021d Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 28 Nov 2024 16:29:35 +0800 Subject: [PATCH 111/148] dev: optimize close logic a little. I'm still not familiar to python gc best practice --- ghostos/container.py | 20 +-- ghostos/core/aifunc/driver.py | 2 +- ghostos/core/moss/abcd.py | 10 +- ghostos/core/moss/impl.py | 123 ++++++++++++------ .../framework/ghostos/conversation_impl.py | 8 +- ghostos/framework/ghostos/session_impl.py | 2 +- ghostos/framework/ghostos/shell_impl.py | 7 +- ghostos/prototypes/ghostfunc/driver.py | 2 +- tests/core/moss/examples/test_baseline.py | 23 +++- tests/test_container.py | 2 +- 10 files changed, 127 insertions(+), 72 deletions(-) diff --git a/ghostos/container.py b/ghostos/container.py index 93f53de5..95917d29 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -124,7 +124,7 @@ def providers(self, recursively: bool = True) -> Iterable[Provider]: pass @abstractmethod - def destroy(self) -> None: + def shutdown(self) -> None: """ Manually delete the container to prevent memory leaks. """ @@ -169,17 +169,13 @@ def __init__(self, parent: Optional[Container] = None, *, name: str = "", inheri self._bootstrapper: List["Bootstrapper"] = [] self._bootstrapped: bool = False self._aliases: Dict[Any, Any] = {} - self._destroyed: bool = False + self._is_shutdown: bool = False self._shutdown: List[Callable[[], None]] = [] if inherit and parent is not None: self._inherit(parent) Container.instance_count += 1 - def __del__(self): - self.destroy() - Container.instance_count -= 1 - def _inherit(self, parent: Container): """ inherit none singleton provider from parent @@ -408,18 +404,21 @@ def providers(self, recursively: bool = True) -> Iterable[Provider]: yield provider def _check_destroyed(self) -> None: - if self._destroyed: + if self._is_shutdown: raise RuntimeError(f"container {self.bloodline} is called after destroyed") - def destroy(self) -> None: + def shutdown(self) -> None: """ Manually delete the container to prevent memory leaks. """ - if self._destroyed: + if self._is_shutdown: return - self._destroyed = True + self._is_shutdown = True for shutdown in self._shutdown: shutdown() + + def __del__(self): + self.shutdown() del self._shutdown del self._instances del self.parent @@ -428,6 +427,7 @@ def destroy(self) -> None: del self._bootstrapper del self._bootstrapped del self._aliases + Container.instance_count -= 1 Factory = Callable[[Container], Any] diff --git a/ghostos/core/aifunc/driver.py b/ghostos/core/aifunc/driver.py index 385c6628..e70da8ab 100644 --- a/ghostos/core/aifunc/driver.py +++ b/ghostos/core/aifunc/driver.py @@ -280,7 +280,7 @@ def think( else: finish = False finally: - runtime.destroy() + runtime.close() return thread, result, finish def parse_moss_code_in_message(self, message: Message) -> str: diff --git a/ghostos/core/moss/abcd.py b/ghostos/core/moss/abcd.py index ef56fb72..bf4de24a 100644 --- a/ghostos/core/moss/abcd.py +++ b/ghostos/core/moss/abcd.py @@ -220,7 +220,7 @@ def compile( self.__compiling__ = False self.__compiled__ = True # 手动管理一下, 避免外部解决内存泄漏的心智成本. - self.destroy() + self.close() @abstractmethod def _compile(self, modulename: Optional[str] = None) -> ModuleType: @@ -239,7 +239,7 @@ def _new_runtime(self, module: ModuleType) -> "MossRuntime": pass @abstractmethod - def destroy(self) -> None: + def close(self) -> None: """ 主动做垃圾回收的准备, 避免 python 内存泄漏. """ @@ -249,7 +249,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - self.destroy() + self.close() class MossPrompter(ABC): @@ -490,7 +490,7 @@ def execute( finally: self.__executing__ = False - def destroy(self) -> None: + def close(self) -> None: """ 方便垃圾回收. """ @@ -500,7 +500,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - self.destroy() + self.close() class Execution(NamedTuple): diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index cb2fe246..b4cecf7d 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -1,6 +1,7 @@ +import importlib import inspect from types import ModuleType -from typing import Optional, Any, Dict, get_type_hints, Type, List, Callable +from typing import Optional, Any, Dict, get_type_hints, Type, List, Callable, ClassVar import io from ghostos.container import Container, Provider @@ -18,6 +19,23 @@ IMPORT_FUTURE = "from __future__ import annotations" +__all__ = [ + 'MossStub', + 'MossCompilerImpl', + 'MossRuntimeImpl', + 'DefaultMOSSProvider', + 'MossTempModuleType', +] + + +class MossTempModuleType(ModuleType): + __instance_count__: ClassVar[int] = 0 + + def __del__(self): + if MOSS_VALUE_NAME in self.__dict__: + del self.__dict__[MOSS_VALUE_NAME] + MossTempModuleType.__instance_count__ -= 1 + class MossCompilerImpl(MossCompiler): def __init__(self, *, container: Container, pycontext: Optional[PyContext] = None): @@ -31,10 +49,10 @@ def __init__(self, *, container: Container, pycontext: Optional[PyContext] = Non self._injections: Dict[str, Any] = {} self._attr_prompts: List = [] self._compiled = False - self._destroyed = False + self._closed = False def __del__(self): - self.destroy() + self.close() def container(self) -> Container: return self._container @@ -60,19 +78,24 @@ def injects(self, **attrs: Any) -> "MossCompiler": return self def _compile(self, modulename: Optional[str] = None) -> ModuleType: + origin: Optional[ModuleType] = None + filename = "" + origin_modulename = self._pycontext.module + if origin_modulename: + origin = importlib.import_module(origin_modulename) + filename = origin.__file__ + if modulename is None: - modulename = self._pycontext.module - if not modulename: - modulename = "__main__" + modulename = origin_modulename if origin_modulename else "__moss__" code = self.pycontext_code() # 创建临时模块. - module = ModuleType(modulename) + module = MossTempModuleType(modulename) + MossTempModuleType.__instance_count__ += 1 module.__dict__.update(self._predefined_locals) - module.__dict__['__file__'] = "" + module.__file__ = filename compiled = compile(code, modulename, "exec") exec(compiled, module.__dict__) - if self._pycontext.module: - origin = self._modules.import_module(self._pycontext.module) + if origin is not None: updating = self._filter_origin(origin) module.__dict__.update(updating) return module @@ -115,38 +138,58 @@ def pycontext_code(self) -> str: code = IMPORT_FUTURE + "\n\n" + code.lstrip("\n") return code if code else "" - def destroy(self) -> None: - if self._destroyed: + def close(self) -> None: + if self._closed: return - self._destroyed = True + self._closed = True # container 先不 destroy. if not self._compiled: - self._container.destroy() + self._container.shutdown() del self._container del self._pycontext del self._predefined_locals del self._injections -def new_moss_stub(cls: Type[Moss], container: Container, pycontext: PyContext, pprint: Callable) -> Moss: - # cls 必须不包含参数. - class MossType(cls): - __pycontext__ = pycontext - __container__ = container - __output__ = "" +class MossStub(Moss): + instance_count: ClassVar[int] = 0 + __pycontext__: PyContext + __container__: Container + __output__: str + __printer__: Callable - def fetch(self, abstract: Type[cls.T]) -> Optional[cls.T]: - return self.__container__.fetch(abstract) + def __init__(self, pycontext: PyContext, container: Container, printer: Callable): + MossStub.instance_count += 1 + self.__dict__['__pycontext__'] = pycontext + self.__dict__['__container__'] = container + self.__dict__['__output__'] = "" + self.__dict__['__printer__'] = printer - def pprint(self, *args, **kwargs) -> None: - pprint(*args, **kwargs) + def fetch(self, abstract: Moss.T) -> Optional[Moss.T]: + return self.__container__.fetch(abstract) - def __setattr__(self, _name, _value): - if self.__pycontext__.allow_prop(_value): - self.__pycontext__.set_prop(_name, _value) - self.__dict__[_name] = _value + def pprint(self, *args, **kwargs) -> None: + return self.__printer__(*args, **kwargs) + + def __setattr__(self, _name, _value): + if self.__pycontext__.allow_prop(_value): + self.__pycontext__.set_prop(_name, _value) + self.__dict__[_name] = _value + + def __del__(self): + MossStub.instance_count -= 1 + + +def new_moss_stub(cls: Type[Moss], container: Container, pycontext: PyContext, pprint: Callable) -> Moss: + # cls 必须不包含参数. + + stub = MossStub(pycontext, container, pprint) + # assert stub.instance_count > 0 + for attr_name in dir(cls): + if not attr_name.startswith("_") and not hasattr(stub, attr_name): + attr_value = getattr(cls, attr_name) + setattr(stub, attr_name, attr_value) - stub = MossType() # 反向注入. for name, value in cls.__dict__.items(): if name in pycontext.properties or name.startswith("_"): @@ -179,15 +222,11 @@ def __init__( self._built: bool = False self._moss_prompt: Optional[str] = None self._attr_prompts: Dict[str, str] = attr_prompts - self._destroyed: bool = False + self._closed: bool = False self._injected = set() self._moss: Moss = self._compile_moss() MossRuntime.instance_count += 1 - def __del__(self): - MossRuntime.instance_count -= 1 - self.destroy() - def _compile_moss(self) -> Moss: from .lifecycle import __moss_compiled__ moss_type = self.moss_type() @@ -233,7 +272,7 @@ def inject(attr_name: str, injected: Any) -> Any: fn = __moss_compiled__ if __moss_compiled__.__name__ in self._compiled.__dict__: fn = self._compiled.__dict__[__moss_compiled__.__name__] - fn(moss) + fn(moss) return moss def container(self) -> Container: @@ -331,16 +370,20 @@ def _parse_pycontext_code(code: str, exclude_hide_code: bool = True) -> str: return "\n".join(results) - def destroy(self) -> None: - if self._destroyed: + def close(self) -> None: + if self._closed: return - self._destroyed = True + self._closed = True data = self._moss.__dict__ for val in data.values(): if isinstance(val, Injection): val.on_destroy() - self._moss.__dict__ = {} - self._container.destroy() + self._container.shutdown() + + def __del__(self): + if not self._closed: + self.close() + MossRuntime.instance_count -= 1 del self._container del self._injections del self._compiled diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index 08d1b4f5..73c963c0 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -268,10 +268,6 @@ def fail(self, error: Exception) -> bool: def __del__(self): self.close() - - def _destroy(self): - self.logger.info("conversation %s is destroying", self.task_id) - self._container.destroy() del self._container del self._tasks del self._threads @@ -287,7 +283,9 @@ def close(self): if self._submit_session_thread: self._submit_session_thread = None self._locker.release() - self._destroy() + self.logger.info("conversation %s is destroying", self.task_id) + self._container.shutdown() + self._container = None def closed(self) -> bool: return self._closed diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index a71597a6..5646486f 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -416,7 +416,7 @@ def destroy(self) -> None: return self._destroyed = True del self._task_locker - self.container.destroy() + self.container.shutdown() del self.container del self._firing_events del self.task diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index dc2e8b48..c677d2aa 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -332,13 +332,16 @@ def close(self): continue self.logger.info("closing shell conversation %s", conversation.task_id) conversation.close() - del self._conversations self.logger.info("shell conversations are closed") - self._container.destroy() + self._container.shutdown() self.logger.info("shell container destroyed") self.logger.info("shutting down shell pool") self._pool.shutdown(cancel_futures=True) self.logger.info("shell pool is shut") + + def __del__(self): + self.close() + del self._conversations del self._container del self._eventbus del self._tasks diff --git a/ghostos/prototypes/ghostfunc/driver.py b/ghostos/prototypes/ghostfunc/driver.py index 48375b1c..98cf063a 100644 --- a/ghostos/prototypes/ghostfunc/driver.py +++ b/ghostos/prototypes/ghostfunc/driver.py @@ -255,7 +255,7 @@ def _run_code( ) return None, False finally: - runtime.destroy() + runtime.close() result, ok = executed.returns if not ok: diff --git a/tests/core/moss/examples/test_baseline.py b/tests/core/moss/examples/test_baseline.py index 05f2885c..b7d288dd 100644 --- a/tests/core/moss/examples/test_baseline.py +++ b/tests/core/moss/examples/test_baseline.py @@ -1,3 +1,5 @@ +import time + from ghostos.core.moss import moss_container from ghostos.core.moss.abcd import MossCompiler, Moss, MOSS_TYPE_NAME, MossRuntime from ghostos.core.moss.pycontext import PyContext @@ -42,7 +44,7 @@ def test_baseline_exec(): moss = runtime.moss() assert isinstance(moss, Moss) - assert isinstance(moss, moss_type) + # assert isinstance(moss, moss_type) prompter = runtime.prompter() assert prompter is not None @@ -94,8 +96,8 @@ def test_baseline_exec(): assert foo.foo() == "hello" # 最后成功销毁. - runtime.destroy() - container.destroy() + runtime.close() + container.shutdown() def test_baseline_in_test_mode(): @@ -122,7 +124,7 @@ def test_baseline_in_test_mode(): result = runtime.execute(target="test_main", local_args=["moss"]) assert result.returns == 3 assert result.pycontext.get_prop("hello") == "world" - container.destroy() + container.shutdown() def test_baseline_with_pycontext_code(): @@ -133,20 +135,26 @@ def test_baseline_with_pycontext_code(): line = "print('hello')" compiler.join_context(PyContext(module=baseline.__name__, code=line)) assert line in compiler.pycontext_code() - container.destroy() + container.shutdown() def test_moss_gc(): from threading import Thread from gc import collect + from ghostos.core.moss.impl import MossStub, MossTempModuleType container = moss_container() assert Container.instance_count < 10 + moss_stub_count = MossStub.instance_count + assert moss_stub_count < 10 def run(c: Container): - compiler = container.force_fetch(MossCompiler) + compiler = c.force_fetch(MossCompiler) compiler = compiler.join_context(PyContext(module=baseline.__name__)) runtime = compiler.compile("__test__") + assert runtime.moss() is not None assert MossRuntime.instance_count > 0 + assert MossStub.instance_count > 0 + assert MossTempModuleType.__instance_count__ > 0 with runtime: runtime.execute(target="test_main", local_args=["moss"]) @@ -160,5 +168,8 @@ def run(c: Container): # assert gc success collect() + time.sleep(0.05) + assert MossTempModuleType.__instance_count__ < 10 assert MossRuntime.instance_count == 0 + assert MossStub.instance_count <= moss_stub_count assert Container.instance_count < 10 diff --git a/tests/test_container.py b/tests/test_container.py index 7433aba8..2e6592c6 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -186,5 +186,5 @@ def shutdown(self): container.set(Foo, f) container.add_shutdown(f.shutdown) assert Foo.instance_count == 1 - container.destroy() + container.shutdown() assert Foo.instance_count == 0 From 0cf1215913c6b9bfc01e63bc452734da274cdd6b Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 28 Nov 2024 23:55:31 +0800 Subject: [PATCH 112/148] fix: fix missing charactor --- ghostos/ghosts/moss_agent/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostos/ghosts/moss_agent/__init__.py b/ghostos/ghosts/moss_agent/__init__.py index 8787f0c7..50efa1e8 100644 --- a/ghostos/ghosts/moss_agent/__init__.py +++ b/ghostos/ghosts/moss_agent/__init__.py @@ -29,7 +29,7 @@ def new_moss_agent( return MossAgent( moss_module=modulename, persona=persona, - instruction=instruction, + instructions=instruction, name=name, description=description, llm_api=llm_api, From 640a9763c7702e15c8307d67ebc8f632488dd9bb Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 28 Nov 2024 23:58:42 +0800 Subject: [PATCH 113/148] dev: move sphero dependencies to sphero group --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9c8574ec..7778d366 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ readme = "README.md" [tool.poetry.dependencies] python = ">=3.10,<3.14" pydantic = "^2.7.0" -pytest = "^8.1.1" openai = "^1.19.0" pyyaml = "^6.0.1" rich = "^13.7.1" @@ -48,6 +47,9 @@ console = "ghostos.scripts.cli.run_console:main" ghost = "ghostos.scripts.cli.run_streamlit_ghost:main" [tool.poetry.group.dev.dependencies] +pytest = "^8.1.1" + +[tool.poetry.group.sphero.dependencies] spherov2 = "^0.12.1" bleak = "^0.22.3" From d502c0bcbca9ff26de8374c71c34555893661b31 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 30 Nov 2024 23:22:40 +0800 Subject: [PATCH 114/148] dev: add audio messages --- .ghostos/assets/audios/.gitignore | 2 + .ghostos/streamlit_main.py | 18 ---- README.md | 2 +- ghostos/bootstrap.py | 12 ++- ghostos/contracts/assets.py | 102 +++++++++++------- ghostos/contracts/storage.py | 2 +- ghostos/core/llms/__init__.py | 2 +- ghostos/core/llms/{llm.py => abcd.py} | 0 ghostos/core/llms/configs.py | 1 + ghostos/core/messages/message.py | 13 ++- ghostos/core/messages/message_classes.py | 57 ++++++++-- ghostos/core/messages/openai.py | 24 +++-- ghostos/core/models/__init__.py | 0 ghostos/core/models/audio_generation.py | 59 ++++++++++ ghostos/core/models/embedding.py | 18 ++++ ghostos/core/models/speech_to_text.py | 14 +++ ghostos/core/models/text_to_speech.py | 0 ghostos/framework/assets/__init__.py | 5 +- .../assets/workspace_audio_provider.py | 15 +++ .../assets/workspace_image_provider.py | 15 +++ ...sset_provider.py => workspace_provider.py} | 17 ++- ghostos/framework/llms/openai_driver.py | 33 +++--- ghostos/framework/llms/providers.py | 5 +- ghostos/prototypes/streamlitapp/resources.py | 20 ++-- pyproject.toml | 4 + 25 files changed, 319 insertions(+), 121 deletions(-) create mode 100644 .ghostos/assets/audios/.gitignore delete mode 100644 .ghostos/streamlit_main.py rename ghostos/core/llms/{llm.py => abcd.py} (100%) create mode 100644 ghostos/core/models/__init__.py create mode 100644 ghostos/core/models/audio_generation.py create mode 100644 ghostos/core/models/embedding.py create mode 100644 ghostos/core/models/speech_to_text.py create mode 100644 ghostos/core/models/text_to_speech.py create mode 100644 ghostos/framework/assets/workspace_audio_provider.py create mode 100644 ghostos/framework/assets/workspace_image_provider.py rename ghostos/framework/assets/{image_asset_provider.py => workspace_provider.py} (54%) diff --git a/.ghostos/assets/audios/.gitignore b/.ghostos/assets/audios/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/.ghostos/assets/audios/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/.ghostos/streamlit_main.py b/.ghostos/streamlit_main.py deleted file mode 100644 index 0b944324..00000000 --- a/.ghostos/streamlit_main.py +++ /dev/null @@ -1,18 +0,0 @@ -from ghostos.prototypes.streamlitapp.main import main_run, SINGLETONS -from ghostos.prototypes.streamlitapp.utils.session import Singleton - - -def bootstrap() -> SINGLETONS: - from os.path import dirname - from ghostos.bootstrap import make_app_container - from ghostos.prototypes.streamlitapp.pages.router import default_router - - app_dir = dirname(__file__) - app_container = make_app_container(app_dir) - - # bind container before everything - yield Singleton(app_container) - yield Singleton(default_router()) - - -main_run(bootstrap) diff --git a/README.md b/README.md index c93bb932..905b6225 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ export OPENAI_PROXY="xxxx" # OPENAI proxy if you need ### Config LLMs API -`GhostOS` use yaml file to configure the [LLMs](ghostos/core/llms/llm.py) library. +`GhostOS` use yaml file to configure the [LLMs](ghostos/core/llms/abcd.py) library. You can edit [ghostos/demo/configs/llms_conf.yml](ghostos/demo/configs/llms_conf.yml) as you want, the yaml structure follows [LLMConfig](ghostos/core/llms/configs.py) diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 3d1b84c6..99c96b6a 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -119,7 +119,7 @@ def default_application_contracts() -> Contracts: from ghostos.framework.logger import LoggerItf from ghostos.framework.documents import DocumentRegistry from ghostos.framework.ghostos import GhostOS - from ghostos.framework.assets import ImagesAsset + from ghostos.framework.assets import ImageAssets, AudioAssets from ghostos.core.aifunc import AIFuncExecutor, AIFuncRepository return Contracts([ @@ -127,7 +127,9 @@ def default_application_contracts() -> Contracts: Workspace, # application workspace implementation Configs, # application configs repository Variables, - ImagesAsset, + + ImageAssets, + AudioAssets, # system contracts Shutdown, # graceful shutdown register @@ -178,7 +180,7 @@ def default_application_providers( from ghostos.core.messages.openai import DefaultOpenAIParserProvider from ghostos.framework.workspaces import BasicWorkspaceProvider from ghostos.framework.configs import WorkspaceConfigsProvider - from ghostos.framework.assets import WorkspaceImagesAssetProvider + from ghostos.framework.assets import WorkspaceImageAssetsProvider, WorkspaceAudioAssetsProvider from ghostos.framework.processes import WorkspaceProcessesProvider from ghostos.framework.threads import MsgThreadsRepoByWorkSpaceProvider from ghostos.framework.tasks import WorkspaceTasksProvider @@ -204,8 +206,10 @@ def default_application_providers( WorkspaceProcessesProvider(runtime_processes_dir), WorkspaceTasksProvider(runtime_tasks_dir), ConfiguredDocumentRegistryProvider("documents_registry.yml"), + WorkspaceVariablesProvider(), - WorkspaceImagesAssetProvider(), + WorkspaceImageAssetsProvider(), + WorkspaceAudioAssetsProvider(), # --- messages --- # DefaultOpenAIParserProvider(), diff --git a/ghostos/contracts/assets.py b/ghostos/contracts/assets.py index 9de4921d..d225aa62 100644 --- a/ghostos/contracts/assets.py +++ b/ghostos/contracts/assets.py @@ -3,87 +3,115 @@ from pydantic import BaseModel, Field from ghostos.contracts.storage import Storage from ghostos.helpers import uuid, yaml_pretty_dump +from io import BytesIO import yaml -class ImageInfo(BaseModel): - image_id: str = Field(default_factory=uuid, description="ID of the image.") - filename: str = Field(description="The file name of the image.") - description: str = Field(default="", description="The description of the image.") - filetype: str = Field(default="", description="The file type of the image.") - url: Optional[str] = Field(default=None, description="The URL of the image.") +class FileInfo(BaseModel): + fileid: str = Field(default_factory=uuid, description="ID of the file.") + filename: str = Field(description="The file name of the file.") + description: str = Field(default="", description="The description of the file.") + filetype: str = Field(default="", description="The file type of the file.") + summary: str = Field(default="", description="The text summary of the file.") + url: Optional[str] = Field(default=None, description="The URL of the file.") + def get_format(self) -> str: + return self.filetype.split("/")[-1] -class ImagesAsset(ABC): + +class FileAssets(ABC): + + @abstractmethod + def save(self, file: FileInfo, binary: Optional[bytes]) -> str: + """ + save file info and binary data to storage + :param file: the file info + :param binary: binary data of the file. if None, the url must be provided + :return: relative file path of the saved file + """ + pass @abstractmethod - def save(self, image: ImageInfo, binary: Optional[bytes]) -> str: + def save_stream(self, file: FileInfo, stream: BytesIO) -> None: """ - save image info and binary data to storage - :param image: the image info - :param binary: binary data of the image. if None, the url must be provided - :return: relative file path of the saved image + save file info from stream + """ + pass + + @abstractmethod + def get_stream(self, file: FileInfo) -> Optional[BytesIO]: + """ + get file stream from file info """ pass @abstractmethod def get_binary(self, filename: str) -> Optional[bytes]: """ - get binary data of the image - :param filename: the relative filename of the image - :return: binary data of the image, None if binary data is not available + get binary data of the file + :param filename: the relative filename of the file + :return: binary data of the file, None if binary data is not available """ pass @abstractmethod - def get_image_info(self, image_id: str) -> Optional[ImageInfo]: + def get_fileinfo(self, fileid: str) -> Optional[FileInfo]: """ - get image info from storage - :param image_id: the image id - :return: None if no image info is available + get file info from storage + :param fileid: the file id + :return: None if no file info is available """ pass - def get_binary_by_id(self, image_id: str) -> Optional[Tuple[ImageInfo, Union[bytes, None]]]: + def get_binary_by_id(self, fileid: str) -> Optional[Tuple[FileInfo, Union[bytes, None]]]: """ - get binary data by image id - :param image_id: the image info id. - :return: image info and binary data, if binary data is None, means the image has url. + get binary data by file id + :param fileid: the file info id. + :return: file info and binary data, if binary data is None, means the file has url. """ - image_info = self.get_image_info(image_id) - if image_info is None: + file_info = self.get_fileinfo(fileid) + if file_info is None: return None - return image_info, self.get_binary(image_info.filename) + return file_info, self.get_binary(file_info.filename) + + +class ImageAssets(FileAssets, ABC): + pass + + +class AudioAssets(FileAssets, ABC): + pass -class StorageImagesAsset(ImagesAsset): +class StorageFileAssets(FileAssets): def __init__(self, storage: Storage): self._storage = storage @staticmethod - def _get_image_info_filename(image_id: str) -> str: - return f"{image_id}.yml" + def _get_fileinfo_filename(fileid: str) -> str: + return f"{fileid}.yml" - def save(self, image: ImageInfo, binary: Optional[bytes]) -> str: - if binary is None and image.url is None: + def save(self, file: FileInfo, binary: Optional[bytes]) -> str: + if binary is None and file.url is None: raise AttributeError("failed to save image: binary is None and image info is not from url.") - image_info_filename = self._get_image_info_filename(image.image_id) - data = image.model_dump(exclude_none=True) + image_info_filename = self._get_fileinfo_filename(file.fileid) + data = file.model_dump(exclude_none=True) content = yaml_pretty_dump(data) self._storage.put(image_info_filename, content.encode()) if binary: - self._storage.put(image.filename, binary) + self._storage.put(file.filename, binary) + return file.filename def get_binary(self, filename: str) -> Optional[bytes]: if self._storage.exists(filename): return self._storage.get(filename) return None - def get_image_info(self, image_id: str) -> Optional[ImageInfo]: - image_info_filename = self._get_image_info_filename(image_id) + def get_fileinfo(self, fileid: str) -> Optional[FileInfo]: + image_info_filename = self._get_fileinfo_filename(fileid) if not self._storage.exists(image_info_filename): return None content = self._storage.get(image_info_filename) data = yaml.safe_load(content) - return ImageInfo(**data) + return FileInfo(**data) diff --git a/ghostos/contracts/storage.py b/ghostos/contracts/storage.py index 541a89fd..064223aa 100644 --- a/ghostos/contracts/storage.py +++ b/ghostos/contracts/storage.py @@ -1,4 +1,4 @@ -from typing import Optional, AnyStr, Iterable +from typing import Optional, Iterable from abc import ABC, abstractmethod __all__ = ['Storage', 'FileStorage'] diff --git a/ghostos/core/llms/__init__.py b/ghostos/core/llms/__init__.py index 537737ac..6cdb3af8 100644 --- a/ghostos/core/llms/__init__.py +++ b/ghostos/core/llms/__init__.py @@ -2,7 +2,7 @@ ModelConf, ServiceConf, LLMsConfig, OPENAI_DRIVER_NAME, LITELLM_DRIVER_NAME, ) -from ghostos.core.llms.llm import LLMs, LLMDriver, LLMApi +from ghostos.core.llms.abcd import LLMs, LLMDriver, LLMApi from ghostos.core.llms.prompt import ( Prompt, PromptPipe, run_prompt_pipeline, PromptStorage, PromptPayload, ) diff --git a/ghostos/core/llms/llm.py b/ghostos/core/llms/abcd.py similarity index 100% rename from ghostos/core/llms/llm.py rename to ghostos/core/llms/abcd.py diff --git a/ghostos/core/llms/configs.py b/ghostos/core/llms/configs.py index d75745c2..2a2e484a 100644 --- a/ghostos/core/llms/configs.py +++ b/ghostos/core/llms/configs.py @@ -33,6 +33,7 @@ class ModelConf(Payload): request_timeout: float = Field(default=40, description="request timeout") kwargs: Dict[str, Any] = Field(default_factory=dict, description="kwargs") use_tools: bool = Field(default=True, description="use tools") + message_types: Optional[List[str]] = Field(None, description="model allow message types") class ServiceConf(BaseModel): diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 73226ca1..f0d07f78 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -457,7 +457,7 @@ def from_message(cls, message: Message) -> Optional[Self]: pass @abstractmethod - def to_openai_param(self, container: Optional[Container]) -> List[Dict]: + def to_openai_param(self, container: Optional[Container], compatible: bool = False) -> List[Dict]: pass @@ -518,7 +518,7 @@ def from_message(cls, message: Message) -> Optional[Self]: content=message.content, ) - def to_openai_param(self, container: Optional[Container]) -> List[Dict]: + def to_openai_param(self, container: Optional[Container], compatible: bool = False) -> List[Dict]: from openai.types.chat.chat_completion_tool_message_param import ChatCompletionToolMessageParam from openai.types.chat.chat_completion_function_message_param import ChatCompletionFunctionMessageParam if self.call_id: @@ -551,11 +551,16 @@ def parse(self, message: Message) -> Optional[MessageClass]: item = cls.from_message(message) return item - def to_openai_params(self, message: Message, container: Optional[Container]) -> Optional[List[Dict]]: + def to_openai_params( + self, + message: Message, + container: Optional[Container], + compatible: bool = False, + ) -> Optional[List[Dict]]: parsed = self.parse(message) if parsed is None: return None - return parsed.to_openai_param(container) + return parsed.to_openai_param(container, compatible) MessageKind = Union[Message, MessageClass, str, EntityType] diff --git a/ghostos/core/messages/message_classes.py b/ghostos/core/messages/message_classes.py index 4932963e..e09efe09 100644 --- a/ghostos/core/messages/message_classes.py +++ b/ghostos/core/messages/message_classes.py @@ -3,7 +3,7 @@ from typing_extensions import Self from ghostos.contracts.variables import Variables -from ghostos.contracts.assets import ImageInfo +from ghostos.contracts.assets import FileInfo from ghostos.container import Container from ghostos.prompter import get_defined_prompt from .message import Message, MessageClass, MessageType, CallerOutput, MessageKind, Role @@ -62,13 +62,13 @@ def from_message(cls, message: Message) -> Optional[Self]: ) return obj - def to_openai_param(self, container: Optional[Container]) -> List[Dict]: + def to_openai_param(self, container: Optional[Container], compatible: bool = False) -> List[Dict]: content = f"""variable message: vid: {self.attrs.vid} type: {self.attrs.type} desc: {self.attrs.desc} """ - if container and container.bound(Variables): + if container and container.bound(Variables) and compatible: variables = container.force_fetch(Variables) v = variables.load(self.attrs.vid) prompt = get_defined_prompt(v) @@ -122,11 +122,11 @@ def from_image_asset( cls, name: str, content: str, - images: List[ImageInfo], + images: List[FileInfo], role: str = Role.USER.value, ) -> Self: attrs = ImageAttrs(images=[ - ImageId(image_id=image_info.image_id) + ImageId(image_id=image_info.fileid) for image_info in images ]) return cls( @@ -147,7 +147,7 @@ def from_message(cls, message: Message) -> Optional[Self]: payloads=message.payloads, ) - def to_openai_param(self, container: Optional[Container]) -> List[Dict]: + def to_openai_param(self, container: Optional[Container], compatible: bool = False) -> List[Dict]: from openai.types.chat.chat_completion_content_part_text_param import ChatCompletionContentPartTextParam from openai.types.chat.chat_completion_content_part_image_param import ( ChatCompletionContentPartImageParam, ImageURL, @@ -158,12 +158,12 @@ def to_openai_param(self, container: Optional[Container]) -> List[Dict]: from openai.types.chat.chat_completion_assistant_message_param import ( ChatCompletionAssistantMessageParam, ) - from ghostos.contracts.assets import ImagesAsset + from ghostos.contracts.assets import ImageAssets content = self.content image_id_and_desc = [] content_parts = [] - if self.attrs is not None and self.attrs.images and container: - images = container.force_fetch(ImagesAsset) + if not compatible and self.attrs is not None and self.attrs.images and container: + images = container.force_fetch(ImageAssets) for image_id_info in self.attrs.images: got = images.get_binary_by_id(image_id_info.image_id) if got is None: @@ -180,7 +180,7 @@ def to_openai_param(self, container: Optional[Container]) -> List[Dict]: type="image_url", image_url=ImageURL( url=url, - detail=image_id_info.detail, + detail="auto", ), )) image_id_and_desc.append((image_id_info.image_id, image_info.description)) @@ -213,6 +213,43 @@ def to_openai_param(self, container: Optional[Container]) -> List[Dict]: return [item] +class AudioMessage(MessageClass, BaseModel): + msg_id: str = Field(default_factory=uuid, description="message id") + payloads: Dict[str, Dict] = Field( + default_factory=dict, + description="payload type key to payload item. payload shall be a strong-typed dict" + ) + role: str = Field(default="", description="who send the message") + name: Optional[str] = Field(None, description="who send the message") + content: str = Field("", description="transcription of the audio message") + + __message_type__ = MessageType.AUDIO.value + + def to_message(self) -> Message: + message = Message.new_tail( + role=self.role, + name=self.name, + content=self.content, + type_=self.__message_type__, + msg_id=self.msg_id, + ) + message.payloads = self.payloads + return message + + @classmethod + def from_message(cls, message: Message) -> Optional[Self]: + return cls( + msg_id=message.msg_id, + role=message.role, + name=message.name, + content=message.content, + payloads=message.payloads, + ) + + def to_openai_param(self, container: Optional[Container], compatible: bool = False) -> List[Dict]: + raise NotImplementedError("todo") + + class MessageKindParser: """ middleware that parse weak MessageKind into Message chunks diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index dc2ea4b8..6d91355d 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -35,6 +35,7 @@ class OpenAIMessageParser(ABC): def parse_message( self, message: Message, + types: Optional[List[str]] = None, ) -> Iterable[ChatCompletionMessageParam]: """ parse a Message to OpenAI chat completion message form. @@ -43,12 +44,16 @@ def parse_message( """ pass - def parse_message_list(self, messages: Iterable[Message]) -> Iterable[ChatCompletionMessageParam]: + def parse_message_list( + self, + messages: Iterable[Message], + types: Optional[List[str]] = None, + ) -> Iterable[ChatCompletionMessageParam]: """ syntax suger """ for message in messages: - items = self.parse_message(message) + items = self.parse_message(message, types) for item in items: yield item @@ -115,11 +120,20 @@ def __init__( if not self.logger: self.logger = FakeLogger() - def parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: + def parse_message( + self, + message: Message, + types: Optional[List[str]] = None, + ) -> Iterable[ChatCompletionMessageParam]: if not message.is_complete(): return [] + compatible = False + if types is not None: + types_set = set(types) + if message.type not in types_set: + compatible = True - wrapped = self.class_parser.to_openai_params(message, self.container) + wrapped = self.class_parser.to_openai_params(message, self.container, compatible) if wrapped is not None: yield from wrapped else: @@ -168,8 +182,6 @@ def _parse_message(self, message: Message) -> Iterable[ChatCompletionMessagePara role="function", ) ] - elif not MessageType.is_text(message): - return [] if message.role == Role.ASSISTANT: return self._parse_assistant_chat_completion(message) diff --git a/ghostos/core/models/__init__.py b/ghostos/core/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/core/models/audio_generation.py b/ghostos/core/models/audio_generation.py new file mode 100644 index 00000000..1ddf3bea --- /dev/null +++ b/ghostos/core/models/audio_generation.py @@ -0,0 +1,59 @@ +from abc import ABC, abstractmethod +from typing import Optional, Dict, Generic, TypeVar +from ghostos.core.llms import Prompt, ServiceConf +from ghostos.core.messages import Message +from ghostos.entity import EntityMeta, get_entity, to_entity_meta +from pydantic import BaseModel, Field + + +class AudioGenerationModel(BaseModel, ABC): + name: str = Field(description="Name of the audio generator") + driver: str = Field(description="Name of the audio generator driver") + + +M = TypeVar("M", bound=AudioGenerationModel) + + +class AudioGeneratorsConfig(BaseModel): + default: str = Field(description="Default audio generator model") + models: Dict[str, EntityMeta] = Field( + default_factory=dict, + ) + + def add_model(self, model: AudioGenerationModel): + self.models[model.name] = to_entity_meta(model) + + def get_model(self, name: str) -> Optional[AudioGenerationModel]: + if name in self.models: + meta = self.models[name] + return get_entity(meta, AudioGenerationModel) + return None + + +class AudioGenerationDriver(Generic[M], ABC): + @abstractmethod + def driver_name(self) -> str: + pass + + @abstractmethod + def generate(self, prompt: Prompt, conf: M) -> Message: + pass + + +class AudioGenerators(ABC): + + @abstractmethod + def register(self, generator: AudioGenerationDriver): + pass + + @abstractmethod + def get(self, model_name: str) -> Optional[AudioGenerationDriver]: + pass + + @abstractmethod + def get_model_conf(self, model_name: str) -> AudioGenerationModel: + pass + + @abstractmethod + def generate(self, prompt: Prompt, model: str = "") -> Message: + pass diff --git a/ghostos/core/models/embedding.py b/ghostos/core/models/embedding.py new file mode 100644 index 00000000..c145c149 --- /dev/null +++ b/ghostos/core/models/embedding.py @@ -0,0 +1,18 @@ +from typing import Tuple, List +from numpy import ndarray +from abc import ABC, abstractmethod + + +class Embeddings(ABC): + + @abstractmethod + def get_embedding(self, lang: str, model: str = "") -> ndarray[float]: + pass + + @abstractmethod + def similarity(self, lang: str, compare: str, model: str = "") -> float: + pass + + @abstractmethod + def search(self, query: str, selections: List[str], top_k: int, threshold: float, model: str = "") -> float: + pass diff --git a/ghostos/core/models/speech_to_text.py b/ghostos/core/models/speech_to_text.py new file mode 100644 index 00000000..67819f30 --- /dev/null +++ b/ghostos/core/models/speech_to_text.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod +from ghostos.contracts.assets import FileInfo +from ghostos.core.messages import Message + + +class SpeechToTextDriver(ABC): + pass + + +class SpeechToText(ABC): + + @abstractmethod + def transcript(self, file: FileInfo, model: str = "") -> Message: + pass diff --git a/ghostos/core/models/text_to_speech.py b/ghostos/core/models/text_to_speech.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/framework/assets/__init__.py b/ghostos/framework/assets/__init__.py index 0700d166..ee9c4f11 100644 --- a/ghostos/framework/assets/__init__.py +++ b/ghostos/framework/assets/__init__.py @@ -1,2 +1,3 @@ -from ghostos.contracts.assets import ImagesAsset -from ghostos.framework.assets.image_asset_provider import WorkspaceImagesAssetProvider +from ghostos.contracts.assets import ImageAssets, AudioAssets +from ghostos.framework.assets.workspace_image_provider import WorkspaceImageAssetsProvider +from ghostos.framework.assets.workspace_audio_provider import WorkspaceAudioAssetsProvider diff --git a/ghostos/framework/assets/workspace_audio_provider.py b/ghostos/framework/assets/workspace_audio_provider.py new file mode 100644 index 00000000..9db7716c --- /dev/null +++ b/ghostos/framework/assets/workspace_audio_provider.py @@ -0,0 +1,15 @@ +from .workspace_provider import WorkspaceFileAssetsProvider +from typing import Type + +from ghostos.contracts.assets import ( + AudioAssets, +) + + +class WorkspaceAudioAssetsProvider(WorkspaceFileAssetsProvider): + + def __init__(self, dirname: str = "audios"): + super().__init__(dirname) + + def contract(self) -> Type[AudioAssets]: + return AudioAssets diff --git a/ghostos/framework/assets/workspace_image_provider.py b/ghostos/framework/assets/workspace_image_provider.py new file mode 100644 index 00000000..188f0575 --- /dev/null +++ b/ghostos/framework/assets/workspace_image_provider.py @@ -0,0 +1,15 @@ +from .workspace_provider import WorkspaceFileAssetsProvider +from typing import Type + +from ghostos.contracts.assets import ( + ImageAssets, +) + + +class WorkspaceImageAssetsProvider(WorkspaceFileAssetsProvider): + + def __init__(self, dirname: str = "images"): + super().__init__(dirname) + + def contract(self) -> Type[ImageAssets]: + return ImageAssets diff --git a/ghostos/framework/assets/image_asset_provider.py b/ghostos/framework/assets/workspace_provider.py similarity index 54% rename from ghostos/framework/assets/image_asset_provider.py rename to ghostos/framework/assets/workspace_provider.py index fae98943..e1e0c918 100644 --- a/ghostos/framework/assets/image_asset_provider.py +++ b/ghostos/framework/assets/workspace_provider.py @@ -1,22 +1,29 @@ -from typing import Optional +from typing import Optional, Type +from abc import ABC, abstractmethod -from ghostos.contracts.assets import ImagesAsset, StorageImagesAsset +from ghostos.contracts.assets import ( + StorageFileAssets, FileAssets, +) from ghostos.contracts.workspace import Workspace from ghostos.container import Container, Provider, INSTANCE -class WorkspaceImagesAssetProvider(Provider[ImagesAsset]): +class WorkspaceFileAssetsProvider(Provider, ABC): """ workspace based image asset provider. """ - def __init__(self, dirname: str = "images"): + def __init__(self, dirname: str): self._dirname = dirname def singleton(self) -> bool: return True + @abstractmethod + def contract(self) -> Type[FileAssets]: + pass + def factory(self, con: Container) -> Optional[INSTANCE]: ws = con.force_fetch(Workspace) storage = ws.assets().sub_storage(self._dirname) - return StorageImagesAsset(storage) + return StorageFileAssets(storage) diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index a083feae..a7a7f6a4 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -21,7 +21,7 @@ from ghostos.container import Bootstrapper, Container __all__ = [ - 'OpenAIDriver', 'OpenAIAdapter', 'OpenAIDriverBootstrapper', + 'OpenAIDriver', 'OpenAIAdapter', 'LitellmAdapter', 'LiteLLMDriver', ] @@ -75,12 +75,14 @@ def __init__( model_conf: ModelConf, parser: OpenAIMessageParser, storage: PromptStorage, + logger: LoggerItf, # deprecated: functional_token_prompt: Optional[str] = None, ): self._service = service_conf self._model = model_conf self._storage: PromptStorage = storage + self._logger = logger http_client = None if service_conf.proxy: transport = SyncProxyTransport.from_url(service_conf.proxy) @@ -107,10 +109,11 @@ def text_completion(self, prompt: str) -> str: raise NotImplemented("text_completion is deprecated, implement it later") def parse_message_params(self, messages: List[Message]) -> List[ChatCompletionMessageParam]: - return list(self._parser.parse_message_list(messages)) + return list(self._parser.parse_message_list(messages, self._model.message_types)) def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion, Iterable[ChatCompletionChunk]]: prompt = self.parse_prompt(prompt) + self._logger.debug(f"start chat completion for prompt %s", prompt.id) include_usage = ChatCompletionStreamOptionsParam(include_usage=True) if stream else NOT_GIVEN messages = prompt.get_messages() messages = self.parse_message_params(messages) @@ -118,8 +121,7 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion raise AttributeError("empty chat!!") try: prompt.run_start = timestamp() - get_ghostos_logger().debug(f"start chat completion for prompt %s", prompt.id) - get_ghostos_logger().info(f"start chat completion messages %s", messages) + self._logger.debug(f"start chat completion messages %s", messages) functions = prompt.get_openai_functions() tools = prompt.get_openai_tools() if self._model.use_tools: @@ -141,9 +143,10 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion **self._model.kwargs, ) except Exception as e: - get_ghostos_logger().error(f"error chat completion for prompt {prompt.id}: {e}") + self._logger.error(f"error chat completion for prompt {prompt.id}: {e}") + raise finally: - get_ghostos_logger().debug(f"end chat completion for prompt {prompt.id}") + self._logger.debug(f"end chat completion for prompt {prompt.id}") prompt.run_end = timestamp() def chat_completion(self, prompt: Prompt) -> Message: @@ -195,9 +198,10 @@ class OpenAIDriver(LLMDriver): adapter """ - def __init__(self, storage: PromptStorage, parser: Optional[OpenAIMessageParser] = None): + def __init__(self, storage: PromptStorage, logger: LoggerItf, parser: Optional[OpenAIMessageParser] = None): if parser is None: parser = DefaultOpenAIMessageParser(None, None) + self._logger = logger self._parser = parser self._storage = storage @@ -206,7 +210,7 @@ def driver_name(self) -> str: def new(self, service: ServiceConf, model: ModelConf) -> LLMApi: get_ghostos_logger().debug(f"new llm api %s at service %s", model.model, service.name) - return OpenAIAdapter(service, model, self._parser, self._storage) + return OpenAIAdapter(service, model, self._parser, self._storage, self._logger) class LitellmAdapter(OpenAIAdapter): @@ -250,15 +254,4 @@ def driver_name(self) -> str: return LITELLM_DRIVER_NAME def new(self, service: ServiceConf, model: ModelConf) -> LLMApi: - return LitellmAdapter(service, model, self._parser, self._storage) - - -class OpenAIDriverBootstrapper(Bootstrapper): - - def bootstrap(self, container: Container) -> None: - llms = container.force_fetch(LLMs) - storage = container.force_fetch(PromptStorage) - openai_driver = OpenAIDriver(storage) - lite_llm_driver = LiteLLMDriver(storage) - llms.register_driver(openai_driver) - llms.register_driver(lite_llm_driver) + return LitellmAdapter(service, model, self._parser, self._storage, self._logger) diff --git a/ghostos/framework/llms/providers.py b/ghostos/framework/llms/providers.py index 730c2c3f..186b91d7 100644 --- a/ghostos/framework/llms/providers.py +++ b/ghostos/framework/llms/providers.py @@ -36,10 +36,11 @@ def factory(self, con: Container) -> Optional[LLMs]: configs = con.force_fetch(Configs) storage = con.force_fetch(PromptStorage) parser = con.get(OpenAIMessageParser) + logger = con.force_fetch(LoggerItf) conf = configs.get(LLMsYamlConfig) - openai_driver = OpenAIDriver(storage, parser) - lite_llm_driver = LiteLLMDriver(storage, parser) + openai_driver = OpenAIDriver(storage, logger, parser) + lite_llm_driver = LiteLLMDriver(storage, logger, parser) # register default drivers. llms = LLMsImpl(conf=conf, default_driver=openai_driver) diff --git a/ghostos/prototypes/streamlitapp/resources.py b/ghostos/prototypes/streamlitapp/resources.py index 1d9191d5..28b91acf 100644 --- a/ghostos/prototypes/streamlitapp/resources.py +++ b/ghostos/prototypes/streamlitapp/resources.py @@ -6,7 +6,7 @@ from ghostos.container import Container from ghostos.prototypes.streamlitapp.utils.session import Singleton from ghostos.contracts.configs import YamlConfig, Configs -from ghostos.contracts.assets import ImagesAsset, ImageInfo +from ghostos.contracts.assets import ImageAssets, FileInfo from ghostos.contracts.documents import DocumentRegistry, Documents from PIL.Image import Image as ImageType from ghostos.core.messages.message_classes import ImageAssetMessage @@ -82,14 +82,14 @@ def get_app_docs() -> Documents: @st.cache_resource -def get_images_asset() -> ImagesAsset: +def get_images_asset() -> ImageAssets: container = get_container() - return container.force_fetch(ImagesAsset) + return container.force_fetch(ImageAssets) -def save_uploaded_image(file: UploadedFile) -> ImageInfo: - image_info = ImageInfo( - image_id=file.file_id, +def save_uploaded_image(file: UploadedFile) -> FileInfo: + image_info = FileInfo( + fileid=file.file_id, filename=file.name, description="streamlit camera input", filetype=file.type, @@ -99,18 +99,18 @@ def save_uploaded_image(file: UploadedFile) -> ImageInfo: return image_info -def save_image_info(image_info: ImageInfo, binary: bytes) -> None: +def save_image_info(image_info: FileInfo, binary: bytes) -> None: assets = get_images_asset() assets.save(image_info, binary) -def save_pil_image(image: ImageType, desc: str) -> ImageInfo: +def save_pil_image(image: ImageType, desc: str) -> FileInfo: from io import BytesIO file_id = uuid() img_bytes = BytesIO() image.save(img_bytes, format='PNG') binary = img_bytes.getvalue() - image_info = ImageInfo( + image_info = FileInfo( image_id=file_id, filename=file_id + ".png", filetype="image/png", @@ -120,7 +120,7 @@ def save_pil_image(image: ImageType, desc: str) -> ImageInfo: return image_info -def get_asset_images(image_ids: List[str]) -> Dict[str, Tuple[ImageInfo, Optional[bytes]]]: +def get_asset_images(image_ids: List[str]) -> Dict[str, Tuple[FileInfo, Optional[bytes]]]: result = {} assets = get_images_asset() for image_id in image_ids: diff --git a/pyproject.toml b/pyproject.toml index 7778d366..0ff5c2dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,10 @@ pytest = "^8.1.1" spherov2 = "^0.12.1" bleak = "^0.22.3" + +[tool.poetry.group.audio.dependencies] +pyaudio = "^0.2.14" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" From e819917eb10ab301fb6978ac03d82d032a786da9 Mon Sep 17 00:00:00 2001 From: zhuming Date: Sat, 30 Nov 2024 23:24:37 +0800 Subject: [PATCH 115/148] fix: fix methods forgot to delete --- ghostos/contracts/assets.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/ghostos/contracts/assets.py b/ghostos/contracts/assets.py index d225aa62..0a2833c9 100644 --- a/ghostos/contracts/assets.py +++ b/ghostos/contracts/assets.py @@ -3,7 +3,6 @@ from pydantic import BaseModel, Field from ghostos.contracts.storage import Storage from ghostos.helpers import uuid, yaml_pretty_dump -from io import BytesIO import yaml @@ -31,20 +30,6 @@ def save(self, file: FileInfo, binary: Optional[bytes]) -> str: """ pass - @abstractmethod - def save_stream(self, file: FileInfo, stream: BytesIO) -> None: - """ - save file info from stream - """ - pass - - @abstractmethod - def get_stream(self, file: FileInfo) -> Optional[BytesIO]: - """ - get file stream from file info - """ - pass - @abstractmethod def get_binary(self, filename: str) -> Optional[bytes]: """ From b1a37801c9d749fc754d59fc9832df3e4f497bb5 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 2 Dec 2024 17:58:19 +0800 Subject: [PATCH 116/148] dev: save realtime developing code --- ghostos/abcd/__init__.py | 5 + ghostos/abcd/concepts.py | 8 +- ghostos/abcd/utils.py | 6 +- ghostos/core/messages/transport.py | 4 +- ghostos/core/runtime/threads.py | 6 +- .../framework/ghostos/conversation_impl.py | 9 +- ghostos/framework/ghostos/session_impl.py | 31 +- ghostos/framework/llms/openai_driver.py | 4 +- ghostos/prototypes/realtime/abcd.py | 288 ++++++++-------- .../openai/{states.py => __states.py} | 49 +-- ghostos/prototypes/realtime/openai/agent.py | 158 --------- ghostos/prototypes/realtime/openai/app.py | 281 ++++++++++++++++ ghostos/prototypes/realtime/openai/configs.py | 6 +- ghostos/prototypes/realtime/openai/context.py | 30 ++ .../realtime/openai/conversation.py | 40 --- .../realtime/openai/event_data_objects.py | 112 +++++++ .../realtime/openai/event_from_client.py | 118 +++++++ .../realtime/openai/event_from_server.py | 313 ++++++++++++++++++ .../prototypes/realtime/openai/protocols.py | 178 ---------- .../realtime/openai/state_of_client.py | 226 +++++++++++++ .../realtime/openai/state_of_server.py | 129 ++++++++ ghostos/prototypes/realtime/openai/utils.py | 2 +- ghostos/prototypes/realtime/openai/ws.py | 1 - .../realtime/pyaudio_io/__init__.py | 0 .../realtime/pyaudio_io/listener.py | 44 +++ .../prototypes/realtime/pyaudio_io/speaker.py | 44 +++ ghostos/prototypes/realtime/shells.py | 58 ---- .../prototypes/streamlitapp/pages/ghosts.py | 125 ++++--- .../prototypes/streamlitapp/pages/router.py | 1 + .../streamlitapp/tests/audio_output_test.py | 51 +++ .../streamlitapp/tests/audio_test.py | 60 ++++ .../streamlitapp/widgets/messages.py | 27 +- tests/python/test_pydantic.py | 8 + 33 files changed, 1718 insertions(+), 704 deletions(-) rename ghostos/prototypes/realtime/openai/{states.py => __states.py} (88%) delete mode 100644 ghostos/prototypes/realtime/openai/agent.py create mode 100644 ghostos/prototypes/realtime/openai/app.py create mode 100644 ghostos/prototypes/realtime/openai/context.py delete mode 100644 ghostos/prototypes/realtime/openai/conversation.py create mode 100644 ghostos/prototypes/realtime/openai/event_data_objects.py create mode 100644 ghostos/prototypes/realtime/openai/event_from_client.py create mode 100644 ghostos/prototypes/realtime/openai/event_from_server.py delete mode 100644 ghostos/prototypes/realtime/openai/protocols.py create mode 100644 ghostos/prototypes/realtime/openai/state_of_client.py create mode 100644 ghostos/prototypes/realtime/openai/state_of_server.py create mode 100644 ghostos/prototypes/realtime/pyaudio_io/__init__.py create mode 100644 ghostos/prototypes/realtime/pyaudio_io/listener.py create mode 100644 ghostos/prototypes/realtime/pyaudio_io/speaker.py delete mode 100644 ghostos/prototypes/realtime/shells.py create mode 100644 ghostos/prototypes/streamlitapp/tests/audio_output_test.py create mode 100644 ghostos/prototypes/streamlitapp/tests/audio_test.py diff --git a/ghostos/abcd/__init__.py b/ghostos/abcd/__init__.py index 48395b24..dafa122b 100644 --- a/ghostos/abcd/__init__.py +++ b/ghostos/abcd/__init__.py @@ -12,3 +12,8 @@ run_session_event, fire_session_event, ) from ghostos.abcd.thoughts import Thought, LLMThought, ChainOfThoughts +from ghostos.core.runtime import ( + GoThreadInfo, GoThreads, + GoProcess, GoProcesses, + GoTasks, GoTaskStruct, +) diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index 57b027e3..79c6ef65 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -344,6 +344,8 @@ class Conversation(Protocol[G]): """ task_id: str + logger: LoggerItf + @abstractmethod def container(self) -> Container: """ @@ -356,7 +358,7 @@ def task(self) -> GoTaskStruct: pass @abstractmethod - def thread(self) -> GoThreadInfo: + def thread(self, truncated: bool = False) -> GoThreadInfo: pass @abstractmethod @@ -560,6 +562,10 @@ def alive(self) -> bool: """ pass + @abstractmethod + def get_truncated_thread(self) -> GoThreadInfo: + pass + @abstractmethod def to_messages(self, values: Iterable[Union[MessageKind, Any]]) -> List[Message]: pass diff --git a/ghostos/abcd/utils.py b/ghostos/abcd/utils.py index a9e70500..4cf99a89 100644 --- a/ghostos/abcd/utils.py +++ b/ghostos/abcd/utils.py @@ -53,11 +53,9 @@ def fire_session_event(session: Session, event: Event) -> Optional[Operator]: # if event is intercepted, stop the run. return None driver = get_ghost_driver(session.ghost) - session.task.state = TaskState.RUNNING.value - session.thread = driver.truncate(session) + session.thread = session.get_truncated_thread() op = driver.on_event(session, event) - if op is None: - session.task.state = TaskState.WAITING.value + # only session and driver can change event. return op diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index 8922e397..f248b293 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -317,9 +317,9 @@ def __init__(self, head: Message, receiver: Iterator[Message]): self._next: Optional[Self] = None @classmethod - def new(cls, receiver: Iterable[Message]) -> Optional[Self]: + def new(cls, receiving: Iterable[Message]) -> Optional[Self]: try: - iterator = iter(receiver) + iterator = iter(receiving) head = next(iterator) except StopIteration: return None diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index 637b317d..d608b535 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -186,14 +186,14 @@ def get_history_turns(self, truncate: bool = True) -> List[Turn]: turns.append(turn) return turns - def get_history_messages(self, truncate: bool) -> Iterable[Message]: + def get_history_messages(self, truncated: bool) -> Iterable[Message]: """ 返回所有的历史消息. """ yield from self.on_created.messages(False) - turns = self.get_history_turns(truncate) + turns = self.get_history_turns(truncated) for turn in turns: - yield from turn.messages(truncate) + yield from turn.messages(truncated) def get_pycontext(self) -> PyContext: """ diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index 73c963c0..7392db34 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -103,11 +103,14 @@ def task(self) -> GoTaskStruct: self._validate_closed() return self._tasks.get_task(self._scope.task_id) - def thread(self) -> GoThreadInfo: + def thread(self, truncated: bool = False) -> GoThreadInfo: self._validate_closed() task = self.task() - thread_id = task.thread_id - return self._threads.get_thread(thread_id, create=True) + if not truncated: + thread_id = task.thread_id + return self._threads.get_thread(thread_id, create=True) + session = self._create_session(task, self._locker, None) + return session.get_truncated_thread() def update_thread(self, thread: GoThreadInfo) -> None: self._validate_closed() diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 5646486f..b3fa0744 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -110,6 +110,11 @@ def _bootstrap(self): self.container.register(provide(Subtasks, False)(lambda c: self.subtasks())) self.container.register(provide(Messenger, False)(lambda c: self.messenger())) self.container.bootstrap() + # truncate thread. + + def get_truncated_thread(self) -> GoThreadInfo: + thread = self.ghost_driver.truncate(self) + return thread @staticmethod def unmarshal_state(task: GoTaskStruct) -> Dict[str, EntityType]: @@ -134,6 +139,7 @@ def to_messages(self, values: Iterable[Union[MessageKind, Any]]) -> List[Message def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator]]: self._validate_alive() driver = get_ghost_driver(self.ghost) + # if the task is new, initialize the task. if self.task.state == TaskState.NEW.value: driver.on_creating(self) self.task.state = TaskState.RUNNING.value @@ -143,6 +149,7 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] event = driver.parse_event(self, event) if event is None: return None, None + # notification do not trigger the handling if EventTypes.NOTIFY.value == event.type: self.thread.new_turn(event) @@ -175,16 +182,18 @@ def parse_event(self, event: Event) -> Tuple[Optional[Event], Optional[Operator] self.task.errors = 0 self.thread.new_turn(event) self.task.state = TaskState.CANCELLED.value - for child_id in self.task.children: - event = EventTypes.CANCEL.new( - task_id=child_id, - messages=[], - from_task_id=self.task.task_id, - from_task_name=self.task.name, - reason="parent task is canceled", - instruction="cancel what you are doing", - ) - self.fire_events(event) + # cancel children. + if self.task.children: + for child_id in self.task.children: + event = EventTypes.CANCEL.new( + task_id=child_id, + messages=[], + from_task_id=self.task.task_id, + from_task_name=self.task.name, + reason="parent task is canceled", + instruction="cancel what you are doing", + ) + self.fire_events(event) return None, EmptyOperator() event.context = None @@ -329,7 +338,7 @@ def _update_subtasks(self): return tasks = self.get_task_briefs(*children) for tid, tb in tasks: - if TaskState.is_dead(tb.state): + if TaskState.is_dead(tb.status): continue children.append(tid) self.task.children = children diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index a7a7f6a4..cde1f427 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -113,7 +113,7 @@ def parse_message_params(self, messages: List[Message]) -> List[ChatCompletionMe def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion, Iterable[ChatCompletionChunk]]: prompt = self.parse_prompt(prompt) - self._logger.debug(f"start chat completion for prompt %s", prompt.id) + self._logger.info(f"start chat completion for prompt %s", prompt.id) include_usage = ChatCompletionStreamOptionsParam(include_usage=True) if stream else NOT_GIVEN messages = prompt.get_messages() messages = self.parse_message_params(messages) @@ -121,7 +121,7 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion raise AttributeError("empty chat!!") try: prompt.run_start = timestamp() - self._logger.debug(f"start chat completion messages %s", messages) + self._logger.info(f"start chat completion messages %s", messages) functions = prompt.get_openai_functions() tools = prompt.get_openai_tools() if self._model.use_tools: diff --git a/ghostos/prototypes/realtime/abcd.py b/ghostos/prototypes/realtime/abcd.py index 11f5dd5c..aad45008 100644 --- a/ghostos/prototypes/realtime/abcd.py +++ b/ghostos/prototypes/realtime/abcd.py @@ -1,73 +1,159 @@ from __future__ import annotations from abc import ABC, abstractmethod from typing import ( + Generic, Protocol, Literal, List, ClassVar, Iterable, Tuple, TypeVar, Optional, Dict, Callable, Type, Self, Union, ) -from ghostos.core.messages import Message +from ghostos.abcd import Conversation +from ghostos.core.messages import Message, ReceiverBuffer import time from enum import Enum -from pydantic import BaseModel +from pydantic import BaseModel, Field from queue import Queue from contextlib import contextmanager +# class State(ABC): +# state_name: ClassVar[str] +# +# @abstractmethod +# def conversation(self) -> Conversation: +# pass +# +# @abstractmethod +# def operate(self, op: Operator) -> Tuple[str, str | None]: +# """ +# :param op: +# :return: accept level | error message +# """ +# pass +# +# @abstractmethod +# def run_operator(self) -> Union["State", None]: +# """ +# :return: None means no operation, go on handle event +# """ +# pass +# +# @abstractmethod +# def run_server_event(self) -> Union[State, None]: +# """ +# :return: a new state, or continue +# """ +# pass +# +# def tick(self) -> Union[State, None]: +# """ +# :return: if not none, means a new state is returned. and: +# 1. replace current state with new state +# 2. put the current state to a recycling queue, join it without blocking. +# """ +# new_state = self.run_operator() +# if new_state: +# return new_state +# new_state = self.run_server_event() +# if new_state: +# return new_state +# return None +# +# @abstractmethod +# def join(self): +# """ +# """ +# pass +# +# +# S = TypeVar("S", bound=State) + + +class Realtime(ABC): + @abstractmethod + def create(self, conversation: Conversation, app_name: str = "") -> RealtimeApp: + pass -class State(ABC): - state_name: ClassVar[str] + @abstractmethod + def register(self, driver: RealtimeDriver): + pass + + +class RealtimeAppConfig(BaseModel): @abstractmethod - def conversation(self) -> ConversationProtocol: + def driver_name(self) -> str: pass + +class RealtimeConfig(BaseModel): + apps: Dict[str, RealtimeAppConfig] = Field( + default_factory=dict, + ) + + +C = TypeVar("C", bound=RealtimeAppConfig) + + +class RealtimeDriver(Generic[C], ABC): + @abstractmethod - def operate(self, op: Operator) -> Tuple[OperationType, str | None]: - """ - :param op: - :return: accept level | error message - """ + def driver_name(self) -> str: pass @abstractmethod - def run_operator(self) -> Union["State", None]: - """ - :return: None means no operation, go on handle event - """ + def create( + self, + config: C, + conversation: Conversation, + listener: Optional[Listener] = None, + speaker: Optional[Speaker] = None, + ) -> RealtimeApp: pass + +class Listener(ABC): + @abstractmethod - def run_server_event(self) -> Union[State, None]: - """ - :return: a new state, or continue - """ + def hearing(self) -> Optional[bytes]: pass - def tick(self) -> Union[State, None]: - """ - :return: if not none, means a new state is returned. and: - 1. replace current state with new state - 2. put the current state to a recycling queue, join it without blocking. - """ - new_state = self.run_operator() - if new_state: - return new_state - new_state = self.run_server_event() - if new_state: - return new_state - return None + @abstractmethod + def flush(self) -> bytes: + pass @abstractmethod - def join(self): - """ - """ + def __enter__(self): pass + @abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb): + pass -S = TypeVar("S", bound=State) +class Speaker(ABC): + @abstractmethod + def __enter__(self): + pass -class RealtimeAgent(Protocol[S]): + @abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + @abstractmethod + def speak(self, data: bytes): + pass + + @abstractmethod + def flush(self) -> bytes: + pass + + +class Operator(Protocol): + name: str + description: str + + +class RealtimeApp(ABC): """ realtime agent in multi-threading programming pattern. it will develop several threads during runtime to exchange parallel events and actions. @@ -107,136 +193,64 @@ class RealtimeAgent(Protocol[S]): """ @abstractmethod - def run_util_stop( - self, - *shells: Shell, - ) -> None: - pass - - -class ConversationProtocol(Protocol): - - @abstractmethod - def id(self) -> str: - pass - - @abstractmethod - def messages(self) -> List[Message]: - pass - - @abstractmethod - def add(self, message: Message) -> None: - pass - - -class Function(BaseModel): - name: str - description: str - parameters: Dict - - def with_name(self, name: str) -> Self: - return self.model_copy(update={"name": name}, deep=True) - - -class Ghost(Protocol[S]): - - @abstractmethod - def operate(self, op: Operator) -> Tuple[OperationType, str | None]: + def start(self): """ - :param op: - :return: accept level | error message + start realtime session """ pass @abstractmethod - def state(self) -> S: - pass - - @abstractmethod - def messages(self) -> Iterable[Message]: - pass - - -class Shell(Protocol): - - @abstractmethod - def name(self) -> str: + def close(self): + """ + close realtime session and release resources + """ pass @abstractmethod - def functions(self) -> List[Function]: + def is_closed(self) -> bool: pass @abstractmethod - def subscribing(self) -> List[str]: + def messages(self) -> List[Message]: pass @abstractmethod - def on_sync(self, ghost: Ghost) -> ChanIn[Union[dict, None]]: + def state(self) -> Tuple[List[Operator], bool]: """ - sync the ghost with shell, - and return a channel that the agent publish the subscribed event to the shell. - :param ghost: - :return: Queue[event: dict, None]. None means the agent is stopped. + :return: (operators, is_outputting) """ pass - -class FunctionCall(Protocol): - id: Optional[str] - name: str - arguments: str - - -# --- channels --- # - -E = TypeVar("E") - - -class ChanIn(Protocol[E]): - """ - the receiver create the put chan - compatible to queue.Queue - but not necessary? - """ - @abstractmethod - def put(self, item: E, block=True, timeout=None) -> None: + def operate(self, operator: Operator) -> Tuple[bool, Optional[str]]: """ - :param item: dict | None - :param block: boolean - :param timeout: float | None + :param operator: + :return: (allowed, [error message]) """ pass @abstractmethod - def task_done(self) -> None: + def fail(self, error: Exception) -> bool: """ - notify task is done. + receive an exception + :return: if the error is able to intercept """ pass - -class ChanOut(Protocol[E]): - - @abstractmethod - def get(self, block=True, timeout=None) -> E: - pass - @abstractmethod - def get_nowait(self): + def output(self) -> Optional[ReceiverBuffer]: + """ + output messages. if None, check status again. + """ pass + def __enter__(self): + self.start() + return self -# --- basic operators --- # - -class Operator(BaseModel, ABC): - """ - to operate the agent's ghost state machine - is a protocol defined by the agent. - """ - type: str - shell: str = "" - - -OperationType = Literal["", "queued", "blocked", "illegal"] + def __exit__(self, exc_type, exc_val, exc_tb): + intercepted = None + if exc_val is not None: + intercepted = self.fail(exc_val) + self.close() + return intercepted diff --git a/ghostos/prototypes/realtime/openai/states.py b/ghostos/prototypes/realtime/openai/__states.py similarity index 88% rename from ghostos/prototypes/realtime/openai/states.py rename to ghostos/prototypes/realtime/openai/__states.py index 199b5216..17e99141 100644 --- a/ghostos/prototypes/realtime/openai/states.py +++ b/ghostos/prototypes/realtime/openai/__states.py @@ -8,18 +8,18 @@ from ghostos.prototypes.realtime.abcd import ( ConversationProtocol, State, - OperationType, Operator, + OperationType, RealtimeOperator, ) from ghostos.helpers import uuid +from ghostos.abcd import Conversation from ghostos.contracts.logger import LoggerItf, get_logger from ghostos.core.messages import Message, MessageType from ghostos.container import Container from pydantic import BaseModel, Field from collections import deque -from .protocols import * -from .conversation import AbsConversation +from .event_from_server import * from .ws import OpenAIWebsocketsConf, OpenAIWSConnection -from .configs import AgentConf +from .configs import OpenAIRealtimeConf from .broadcast import SimpleBroadcaster, Broadcaster from .utils import parse_message_to_client_event, parse_server_event_to_message @@ -28,44 +28,17 @@ class StateCtx: def __init__( self, - conf: AgentConf, + conf: OpenAIRealtimeConf, container: Container, - funcs: Dict[str, List[Function]], - conversation: ConversationProtocol, - broadcaster: Broadcaster, + conversation: Conversation, logger: LoggerItf, - session: Optional[OpenAISessionObj], connection: Optional[OpenAIWSConnection], - connect_sock: Optional[Callable], ): - self.conf: AgentConf = conf + self.conf: OpenAIRealtimeConf = conf self.container = container self.logger: LoggerItf = logger - self.funcs: Dict[str, List[Function]] = funcs - self.conversation: ConversationProtocol = conversation - self.broadcaster: Broadcaster = broadcaster - self.session: Optional[OpenAISessionObj] = session + self.conversation: Conversation = conversation self.connection: Optional[OpenAIWSConnection] = connection - self.connect_sock: Optional[Callable] = connect_sock - - def get_session_obj(self) -> Optional[OpenAISessionObj]: - """ - if the created session exists, return it - otherwise try to create a new session from conf. - """ - if self.session is not None: - return self.session - if self.conf.session: - tools = [] - for shell_name, funcs in self.funcs.items(): - for fn in funcs: - name = f"{shell_name}.{fn.name}" - target = fn.with_name(name) - tools.append(target) - obj = self.conf.session.model_copy(deep=True) - obj.tools = tools - return obj - return None def recv_from_server_nowait(self) -> Union[dict, None]: if self.connection is None: @@ -141,7 +114,7 @@ def run_operator(self) -> Union[State, None]: op = self._op_queue.popleft() return self._run_operator(op) - def run_server_event(self) -> Union[State, None]: + def run_frame(self) -> Union[State, None]: e = self._ctx.recv_from_server_nowait() if e is None: return None @@ -405,7 +378,7 @@ class OperatorType(str, Enum): update_session = "update_session" -class AbsOperator(Operator): +class AbsOperator(RealtimeOperator): def on_accept(self, ctx: StateCtx): return @@ -447,7 +420,7 @@ def run(self, ctx: StateCtx) -> Union[State, None]: pass -class UpdateSession(Operator): +class UpdateSession(RealtimeOperator): type = OperatorType.update_session instruction: Optional[str] = Field( diff --git a/ghostos/prototypes/realtime/openai/agent.py b/ghostos/prototypes/realtime/openai/agent.py deleted file mode 100644 index a0d3ed3a..00000000 --- a/ghostos/prototypes/realtime/openai/agent.py +++ /dev/null @@ -1,158 +0,0 @@ -from __future__ import annotations -import time -from typing import List, Optional, Dict, Iterable, Tuple, Callable, Union -from threading import Thread -from ghostos.prototypes.realtime.abcd import ( - Function, RealtimeAgent, - Shell, - Ghost, Message, - Operator, OperationType, ChanIn, - ConversationProtocol, -) -from ghostos.container import Container -from ghostos.contracts.logger import LoggerItf, get_logger -from concurrent.futures import ThreadPoolExecutor -from queue import Queue -from .protocols import StateName, ServerEventType -from .configs import AgentConf -from .states import AbsState, ConnectingState, StateCtx -from .broadcast import SimpleBroadcaster, Broadcaster - - -class Agent(RealtimeAgent): - - def __init__( - self, - conf: AgentConf, - container: Container, - conversation: ConversationProtocol, - proxy: Optional[Callable] = None, - ): - self._container = Container(parent=container) - self._conf = conf - self._conversation = conversation - self._state: AbsState | None = None - self._container.set(RealtimeAgent, self) - self._container.set(ConversationProtocol, self._conversation) - self._proxy = proxy - self._logger = container.get(LoggerItf) - self._pool = ThreadPoolExecutor(max_workers=2) - if self._logger is None: - self._logger = get_logger() - - self._closed: bool = False - self._started = False - - def run_util_stop(self, *shells: Shell) -> None: - if self._started: - raise RuntimeError("agent already started") - - _funcs: Dict[str, List[Function]] = {} - _broadcast: Broadcaster = SimpleBroadcaster() - # bind shells. - for shell in shells: - self._add_shell(shell, _broadcast, _funcs) - - _ctx = StateCtx( - conf=self._conf, - container=self._container, - funcs=_funcs, - conversation=self._conversation, - broadcaster=_broadcast, - session=None, - connection=None, - connect_sock=self._proxy, - logger=self._logger, - ) - - self._state = ConnectingState(_ctx) - while not self._closed: - state = self._state - new_state = state.tick() - if new_state is None: - time.sleep(0.05) - else: - # destroy - self._pool.submit(state.join) - # renew the state - self._state = new_state - if new_state.state_name == StateName.stopped: - # stop the world - break - # recycle - _broadcast.close() - if self._state is not None: - self._state.join() - self._pool.shutdown() - - def _add_shell(self, shell: Shell, _broadcast: Broadcaster, _funcs: Dict[str, List[Function]]) -> None: - """ - initialize shell data - """ - name = shell.name() - if name in _funcs: - raise KeyError(f"Shell `{name}` already exists") - _funcs[name] = shell.functions() - event_types = shell.subscribing() - ghost = self.GhostAdapter(self, name) - chan_in = shell.on_sync(ghost) - _broadcast.subscribe(name, chan_in, event_types) - - class GhostAdapter(Ghost[AbsState]): - """ - Adapter to wrap the agent to the ghost - """ - - def __init__(self, agent: Agent, shell_name: str): - self._agent = agent - self._shell_name = shell_name - - def operate(self, op: Operator) -> Tuple[OperationType, str | None]: - if self._agent._state is None: - return "illegal", "agent is not ready" - op.shell = self._shell_name - return self._agent._state.operate(op) - - def state(self) -> AbsState: - return self._agent._state - - def messages(self) -> Iterable[Message]: - return self.state().conversation().messages() - -# class ConversationShell(Shell): -# """ -# non-block conversation item updater -# """ -# -# def __init__(self, conversation: Conversation): -# self._conversation = conversation -# self._recv_queue = Queue() -# self._closed = False -# self._main_thread = Thread(target=self._main) -# -# def name(self) -> str: -# return "__conversation__" -# -# def functions(self) -> List[Function]: -# return [] -# -# def subscribing(self) -> List[str]: -# return ServerEventType.conversation_item_events() -# -# def on_sync(self, ghost: Ghost) -> ChanIn[Union[dict, None]]: -# self._main_thread.start() -# return self._recv_queue -# -# def _main(self): -# while not self._closed: -# e = self._recv_queue.get(block=True) -# if e is None: -# self._closed = True -# break -# self._add_conversation(e) -# -# def _add_conversation(self, e: dict) -> None: -# raise NotImplementedError("todo") -# -# def destroy(self): -# self._main_thread.join() diff --git a/ghostos/prototypes/realtime/openai/app.py b/ghostos/prototypes/realtime/openai/app.py new file mode 100644 index 00000000..2ecca103 --- /dev/null +++ b/ghostos/prototypes/realtime/openai/app.py @@ -0,0 +1,281 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from queue import Queue +from collections import deque +import time +from typing import List, Optional, Dict, Iterable, Tuple, Callable, Union +from threading import Thread +from ghostos.abcd import Conversation +from ghostos.core.messages import ReceiverBuffer +from ghostos.prototypes.realtime.abcd import ( + RealtimeApp, + Listener, Speaker, + RealtimeOperator, +) +from collections import deque +from ghostos.container import Container +from ghostos.core.messages import ( + Message, +) +from ghostos.contracts.logger import LoggerItf, get_logger +from ghostos.contracts.pool import DefaultPool +# from concurrent.futures import ThreadPoolExecutor +# from queue import Queue +# from .protocols import StateName, ServerEventType +from .configs import OpenAIRealtimeConf +from .state_of_client import OpenAIRealtimeContext +from .ws import OpenAIWSConnection + + +# from .broadcast import SimpleBroadcaster, Broadcaster + + + +class OpenAIRealtimeApp(RealtimeApp): + + def __init__( + self, + conf: OpenAIRealtimeConf, + conversation: Conversation, + listener: Listener, + speaker: Speaker, + proxy: Optional[Callable] = None, + ): + self._conf = conf + self._state: State = State() + self._conversation = conversation + self._proxy = proxy + self._logger = conversation.logger + self._pool = DefaultPool(10) + self._ctx: OpenAIRealtimeContext = OpenAIRealtimeContext( + conversation=conversation, + connection=None, + logger=self._logger, + ) + self._operators: deque[RealtimeOperator] = deque() + self._listener: Listener = listener + self._speaker: Speaker = speaker + # status. + self._started = False + + def start(self): + if self._started: + return + if self._ctx.is_closed(): + raise RuntimeError("App is already closed") + self._started = True + self._pool.submit(self._listening_thread) + self._pool.submit(self._speaking_thread) + self._pool.submit(self._main_state_loop) + + def close(self): + if self._ctx.is_closed() or not self._started: + return + self._ctx.closed = True + self._pool.shutdown() + # todo: 1. update conversation. 2. destroy dependencies. + + def is_closed(self) -> bool: + return self._ctx.is_closed() + + def send_message(self, messages: List[Message]): + raise NotImplementedError("Not implemented yet") + + def fail(self, error: Exception) -> bool: + self._logger.exception(error) + return False + + def get_state_desc(self) -> Tuple[str, bool]: + return self._state.name(), self._state.is_outputting() + + def operate(self, op: RealtimeOperator) -> Tuple[bool, Optional[str]]: + ok, error = self._state.allow(op) + if ok: + self._operators.append(op) + return ok, error + + def output(self) -> Optional[ReceiverBuffer]: + outputting_id = self._ctx.outputting_id + if outputting_id is None: + return None + iterator = self._output_messages(outputting_id) + if iterator is None: + return None + return ReceiverBuffer.new(iterator) + + def messages(self) -> List[Message]: + messages = [] + thread = self._ctx.conversation.thread(truncated=True) + for message in thread.get_history_messages(truncated=True): + messages.append(message) + for message in self._ctx.buffer: + messages.append(message) + return messages + + def _output_messages(self, outputting_id: int) -> Optional[Iterable[Message]]: + if outputting_id not in self._ctx.outputting_chunks: + return None + sending = 0 + while not self._ctx.is_closed(): + chunks = self._ctx.outputting_chunks[outputting_id] + chunks_length = len(chunks) + if chunks_length > sending: + yield chunks[sending] + sending += 1 + continue + elif chunks_length == sending: + if outputting_id in self._ctx.outputting_completed: + yield self._ctx.outputting_completed[outputting_id] + break + + time.sleep(0.1) + continue + + @staticmethod + def _destroy_state(state: State) -> None: + state.destroy() + + def _main_state_loop(self): + while not self._ctx.is_closed(): + state = self._state + while len(self._operators) > 0: + op = self._operators.popleft() + next_state = state.handle(op) + if next_state is not None: + self._pool.submit(state.destroy) + state = next_state + # clear all + self._operators.clear() + continue + + next_state = state.run_frame() + if next_state is not None: + self._pool.submit(state.destroy) + self._state = next_state + + def _listening_thread(self): + while not self._closed: + if not self._ctx.listening: + time.sleep(0.5) + continue + self._try_listening() + + def _speaking_thread(self): + while not self._closed: + speaking_id = self._ctx.speaking_id + if speaking_id is None: + time.sleep(0.5) + continue + self._try_speaking(speaking_id, self._ctx.speaking_queue) + + def _try_speaking(self, speaking_id: str, queue: Queue): + with self._speaker: + while self._ctx.speaking_id and speaking_id == self._ctx.speaking_id: + data = queue.get(block=True) + if data is None: + break + self._speaker.speak(data) + + def _try_listening(self): + if not self._ctx.listening: + return + with self._listener: + while self._ctx.listening: + data = self._listener.hearing() + if data is None: + return + self._send_audio_buffer(data) + buffer = self._listener.flush() + + def _send_audio_buffer(self, data: bytes) -> None: + pass + + # def start(self) -> None: + # if self._started: + # raise RuntimeError("agent already started") + # + # _funcs: Dict[str, List[Function]] = {} + + # def _main(self): + # # bind shells. + # _ctx = StateCtx( + # conf=self._conf, + # container=self._container, + # conversation=self._conversation, + # broadcaster=_broadcast, + # session=None, + # connection=None, + # connect_sock=self._proxy, + # logger=self._logger, + # ) + # self._state = ConnectingState(_ctx) + # + # while not self._closed: + # state = self._state + # new_state = state.tick() + # if new_state is None: + # time.sleep(0.05) + # else: + # # destroy + # self._pool.submit(state.join) + # # renew the state + # self._state = new_state + # if new_state.state_name == StateName.stopped: + # # stop the world + # break + # # recycle + # _broadcast.close() + # if self._state is not None: + # self._state.join() + # + # def close(self) -> None: + # if self._closed: + # return + # self._closed = True + # self._pool.shutdown() + # + # def fail(self, error: Exception) -> bool: + # self._logger.exception(error) + # self.close() + # return False + # + # def output(self) -> Optional[ReceiverBuffer]: + # pass + +# class ConversationShell(Shell): +# """ +# non-block conversation item updater +# """ +# +# def __init__(self, conversation: Conversation): +# self._conversation = conversation +# self._recv_queue = Queue() +# self._closed = False +# self._main_thread = Thread(target=self._main) +# +# def name(self) -> str: +# return "__conversation__" +# +# def functions(self) -> List[Function]: +# return [] +# +# def subscribing(self) -> List[str]: +# return ServerEventType.conversation_item_events() +# +# def on_sync(self, ghost: Ghost) -> ChanIn[Union[dict, None]]: +# self._main_thread.start() +# return self._recv_queue +# +# def _main(self): +# while not self._closed: +# e = self._recv_queue.get(block=True) +# if e is None: +# self._closed = True +# break +# self._add_conversation(e) +# +# def _add_conversation(self, e: dict) -> None: +# raise NotImplementedError("todo") +# +# def destroy(self): +# self._main_thread.join() diff --git a/ghostos/prototypes/realtime/openai/configs.py b/ghostos/prototypes/realtime/openai/configs.py index a1369051..38890a1a 100644 --- a/ghostos/prototypes/realtime/openai/configs.py +++ b/ghostos/prototypes/realtime/openai/configs.py @@ -1,10 +1,10 @@ from typing import Optional from pydantic import BaseModel, Field from .ws import OpenAIWebsocketsConf -from .protocols import OpenAISessionObj +from .event_data_objects import SessionObject -class AgentConf(BaseModel): +class OpenAIRealtimeConf(BaseModel): name: str = Field( description="Name of the agent", ) @@ -15,7 +15,7 @@ class AgentConf(BaseModel): default_factory=OpenAIWebsocketsConf, description="OpenAI Websockets configuration", ) - session: Optional[OpenAISessionObj] = Field( + session: Optional[SessionObject] = Field( default=None, description="basic session settings, if None, use openai default session", ) diff --git a/ghostos/prototypes/realtime/openai/context.py b/ghostos/prototypes/realtime/openai/context.py new file mode 100644 index 00000000..05118967 --- /dev/null +++ b/ghostos/prototypes/realtime/openai/context.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from .event_from_server import * +from .ws import OpenAIWSConnection +from .configs import SessionObject, OpenAIRealtimeConf +from ghostos.core.messages import Message +from ghostos.abcd import Conversation, GoThreadInfo +from queue import Queue + + +class Context(Protocol): + conf: OpenAIRealtimeConf + session_obj: SessionObject + connection: OpenAIWSConnection + conversation: Conversation + thread: GoThreadInfo + messages: List[Message] + + receiving_server_event: bool = False + is_outputting: bool = False + + listening: bool = False + """if the client side shall listen """ + + # when realtime server is speaking, the audio bytes shall send through the speaking_queue + speaking: bool = False + speaking_queue: Optional[Queue] = None + + outputting_id: Optional[str] = None + outputting_chunks: Dict[str, List[Message]] + outputting_completes: Dict[str, List[Message]] diff --git a/ghostos/prototypes/realtime/openai/conversation.py b/ghostos/prototypes/realtime/openai/conversation.py deleted file mode 100644 index 8e7ef044..00000000 --- a/ghostos/prototypes/realtime/openai/conversation.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import Iterable, List, Dict -from abc import ABC, abstractmethod - -from ghostos.prototypes.realtime.abcd import ConversationProtocol -from ghostos.core.messages import Message - - -class AbsConversation(ConversationProtocol, ABC): - message_index: Dict[int, str] - message_map: Dict[str, Message] - - def __init__( - self, - message_index: Dict[int, str], - message_map: Dict[str, Message], - ): - self.message_index = message_index - self.message_map = message_map - - def messages(self) -> List[Message]: - keys = self.message_index.keys() - sorted_keys = sorted(keys) - messages = [] - for index in sorted_keys: - msg_id = self.message_index[index] - message = self.message_map.get(msg_id) - messages.append(message) - return messages - - def add(self, message: Message) -> None: - msg_id = message.msg_id - index = message.index - if index is not None: - self.message_index[index] = msg_id - self.message_map[msg_id] = message - self.save() - - @abstractmethod - def save(self): - pass diff --git a/ghostos/prototypes/realtime/openai/event_data_objects.py b/ghostos/prototypes/realtime/openai/event_data_objects.py new file mode 100644 index 00000000..b0920486 --- /dev/null +++ b/ghostos/prototypes/realtime/openai/event_data_objects.py @@ -0,0 +1,112 @@ +from pydantic import BaseModel, Field +from typing import Optional, Literal, List, Dict, Union + + +class RateLimit(BaseModel): + name: str = Field() + limit: int = Field() + remaining: int = Field() + reset_seconds: float = Field() + + +class TokensDetails(BaseModel): + cached_tokens: int = Field(default=0) + text_tokens: int = Field(default=0) + audio_tokens: int = Field(default=0) + + +class Usage(BaseModel): + total_tokens: int = Field() + input_tokens: int = Field() + output_tokens: int = Field() + input_token_details: Optional[TokensDetails] = Field(None) + output_token_details: Optional[TokensDetails] = Field(None) + + +class Error(BaseModel): + type: str = Field("") + code: str = Field("") + message: str = Field("") + param: str = Field("") + + +class ResponseStatusDetails(BaseModel): + type: str = Field("") + reason: str = Field("") + error: Optional[Error] = Field(None) + + +class Response(BaseModel): + id: str = Field() + object: str = Field("realtime.response") + status: Literal["completed", "cancelled", "failed", "incomplete"] = Field() + status_details: Optional[ResponseStatusDetails] = Field(None) + output: List[Dict] = Field(default_factory=list) + usage: Optional[Usage] = Field(None) + + +class Content(BaseModel): + """ + The content of the message, applicable for message items. + Message items with a role of system support only input_text content, + message items of role user support input_text and input_audio content, + and message items of role assistant support text content. + """ + type: Literal["input_text", "input_audio", "text"] = Field() + text: str = Field("") + audio: str = Field("") + transcript: str = Field("") + + +class MessageItem(BaseModel): + """ + The item to add to the conversation. + """ + id: str = Field() + type: Literal["message", "function_call", "function_call_output"] = Field("") + status: Optional[str] = Field(None, enum={"completed", "incomplete"}) + role: Optional[str] = Field(None, enum={"assistant", "user", "system"}) + call_id: Optional[str] = Field(None) + name: Optional[str] = Field(None, description="The name of the function being called (for function_call items).") + arguments: Optional[str] = Field(None, description="The arguments of the function call (for function_call items).") + output: Optional[str] = Field(None, description="The output of the function call (for function_call_output items).") + + +class DeltaIndex(BaseModel): + response_id: str = Field("") + item_id: str = Field("") + output_index: int = Field(0) + content_index: int = Field(0) + + +class ConversationObject(BaseModel): + id: str = Field("") + object: str = Field("realtime.conversation") + + +class SessionObjectBase(BaseModel): + """ + immutable configuration for the openai session object + """ + model: str = Field("gpt-4o-realtime-preview-2024-10-01") + modalities: List[str] = Field(default_factory=list, enum={"text", "audio"}) + voice: str = Field(default="alloy", enum={"alloy", "echo", "shimmer"}) + input_audio_format: str = Field(default="pcm16", enum={"pcm16", "g711_ulaw", "g711_alaw"}) + output_audio_format: str = Field(default="pcm16", enum={"pcm16", "g711_ulaw", "g711_alaw"}) + turn_detection: Union[Dict, None] = Field(None) + + +class SessionObject(SessionObjectBase): + """ + full data model for openai realtime-api session object + """ + id: str = Field(default="", description="id of the session") + object: Literal["realtime.session"] = "realtime.session" + tools: List[dict] = Field(default_factory=list) + tool_choice: str = Field(default="auto") + temperature: float = Field(default=0.8) + max_response_output_tokens: Union[int, Literal['inf']] = Field(default='inf') + + def get_update_event(self) -> dict: + data = self.model_dump(exclude={'id'}) + return data diff --git a/ghostos/prototypes/realtime/openai/event_from_client.py b/ghostos/prototypes/realtime/openai/event_from_client.py new file mode 100644 index 00000000..90c6094b --- /dev/null +++ b/ghostos/prototypes/realtime/openai/event_from_client.py @@ -0,0 +1,118 @@ +from typing import Optional, ClassVar, Self +from abc import ABC, abstractmethod +from enum import Enum +from pydantic import BaseModel, Field +from .event_data_objects import SessionObject, MessageItem + + +class ClientEventType(str, Enum): + session_update = "session.updated" + input_audio_buffer_append = "input_audio_buffer.append" + input_audio_buffer_commit = "input_audio_buffer.commit" + """ + 1. This event will produce an error if the input audio buffer is empty. + 2. When in Server VAD mode, the client does not need to send this event. + 3. Committing the input audio buffer will trigger input audio transcription. + (if enabled in session configuration) + 4. it will not create a response from the model. + 5. The server will respond with an input_audio_buffer.committed event. + """ + + input_audio_buffer_clear = "input_audio_buffer.clear" + + conversation_item_create = "conversation.item.create" + conversation_item_truncate = "conversation.item.truncate" + conversation_item_delete = "conversation.item.delete" + + response_create = "response.create" + """ + 1. When in Server VAD mode, the server will create Responses automatically. + 2. A Response will include at least one Item, and may have two, in which case the second will be a function call. + 3. These Items will be appended to the conversation history. + 4. The server will respond with: + 1) a response.created event, + 2) events for Items and content created, + 3) and finally a response.done event to indicate the Response is complete. + 5. The response.create event includes inference configuration like instructions, and temperature. + 6. These fields will override the Session's configuration for **this Response only**. + """ + + response_cancel = "response.cancel" + """ + 1. The server will respond with a response.cancelled event + 2. or an error if there is no response to cancel. + """ + + +# ---- client side events ---- # + + +class ClientEvent(BaseModel, ABC): + type: ClassVar[str] + event_id: Optional[str] = Field( + default=None, + description="Optional client-generated ID used to identify this event.", + ) + + +class ClientSessionUpdate(ClientEvent): + type: ClassVar[str] = ClientEventType.session_update.value + session: SessionObject + + +class ClientInputAudioBufferAppend(ClientEvent): + type: ClassVar[str] = ClientEventType.input_audio_buffer_append.value + audio: str = Field() + + @classmethod + def new(cls, audio: bytes) -> Self: + raise NotImplementedError("todo") + + +class ClientInputAudioBufferCommit(ClientEvent): + """ + Send this event to commit the user input audio buffer, + which will create a new user message item in the conversation. + This event will produce an error if the input audio buffer is empty. + When in Server VAD mode, the client does not need to send this event, + the server will commit the audio buffer automatically. + Committing the input audio buffer will trigger input audio transcription (if enabled in session configuration), + but it will not create a response from the model. + The server will respond with an input_audio_buffer.committed event. + """ + type: ClassVar[str] = ClientEventType.input_audio_buffer_commit.value + + +class ClientInputAudioBufferClear(ClientEvent): + """ + Send this event to clear the audio bytes in the buffer. + The server will respond with an input_audio_buffer.cleared event. + """ + type: ClassVar[str] = ClientEventType.input_audio_buffer_clear.value + + +class ConversationItemCreate(ClientEvent): + type: ClassVar[str] = ClientEventType.conversation_item_create.value + previous_item_id: str = Field("") + item: MessageItem = Field() + + +class ConversationItemTruncate(ClientEvent): + type: ClassVar[str] = ClientEventType.conversation_item_truncate.value + item_id: str = Field() + content_index: int = Field(0) + audio_end_ms: int = Field() + + +class ConversationItemDelete(ClientEvent): + type: ClassVar[str] = ClientEventType.conversation_item_delete.value + item_id: str = Field() + + +class ResponseCreate(ClientEvent): + type: ClassVar[str] = ClientEventType.response_create.value + response: Optional[SessionObject] = Field(None) + + +class ResponseCancel(ClientEvent): + type: ClassVar[str] = ClientEventType.response_cancel.value diff --git a/ghostos/prototypes/realtime/openai/event_from_server.py b/ghostos/prototypes/realtime/openai/event_from_server.py new file mode 100644 index 00000000..eb3af1a9 --- /dev/null +++ b/ghostos/prototypes/realtime/openai/event_from_server.py @@ -0,0 +1,313 @@ +from typing import Self, Literal, List, Optional, Union, Dict, Protocol, ClassVar, Set +from abc import ABC, abstractmethod +from enum import Enum +from pydantic import BaseModel, Field +from .event_data_objects import ( + RateLimit, Response, MessageItem, Content, DeltaIndex, ConversationObject, Error, SessionObject, +) + + +# class StateName(str, Enum): +# # --- can not change +# stopped = "stopped" +# +# # --- blocking +# connecting = "connecting" +# session_updating = "session_updating" +# +# # --- interruptible +# responding = "responding" +# input_audio = "audio_input" +# listening = "listening" # vad +# +# idle = "idle" +# +# # --- special operations allowed +# failed = "failed" # failed but reconnect-able +# +# # I think this state is parallel, need test +# # creating_conversation_item = "creating_conversation_item" +# +# +# class OperatorName(str, Enum): +# # --- idempotent or immediately +# stop = "stop" # highest priority +# reconnect = "reconnect" # second to stop +# +# # --- parallel actions +# text_input = "text_input" +# function_output = "function_output" +# +# # --- blocking +# session_update = "session_updating" +# +# # --- idempotent or illegal +# create_response = "create_response" +# input_audio = "input_audio" # push-to-talk mode +# start_listening = "start_listening" # start vad listening +# +# # --- immediately or illegal +# truncate_listening = "truncate_listening" # vad only +# response_cancel = "response_cancel" +# + +class ServerEventType(str, Enum): + # recover-able error + error = "error" + + # non-block inform + session_created = "session.created" + session_updated = "session.updated" + conversation_created = "conversation.created" + + # streaming items + + # complete message item alignments + conversation_item_created = "conversation.item.created" + + conversation_item_input_audio_transcription_completed = "conversation.item.input_audio_transcription.completed" + conversation_item_input_audio_transcription_failed = "conversation.item.input_audio_transcription.failed" + + conversation_item_truncated = "conversation.item.truncated" + conversation_item_deleted = "conversation.item.deleted" + + input_audio_buffer_committed = "input_audio_buffer.committed" + input_audio_buffer_cleared = "input_audio_buffer.cleared" + + input_audio_buffer_speech_started = "input_audio_buffer.speech_started" + input_audio_buffer_speech_stopped = "input_audio_buffer.speech_stopped" + + response_created = "response.created" + response_done = "response.done" + + response_output_item_added = "response.output_item.added" + response_output_item_done = "response.output_item.done" + response_content_part_added = "response.content_part.added" + response_content_part_done = "response.content_part.done" + response_text_delta = "response.text.delta" + response_text_done = "response.text.done" + response_audio_transcript_delta = "response.audio_transcript.delta" + response_audio_transcript_done = "response.audio_transcript.done" + response_audio_delta = "response.audio.delta" + response_audio_done = "response.audio.done" + response_function_call_arguments_delta = "response.function_call_arguments.delta" + response_function_call_arguments_done = "response.function_call_arguments.done" + + # system + rate_limits_updated = "rate_limits.updated" + + @classmethod + def get_type(cls, event: dict) -> Self: + return cls(event.get("type", "")) + + @classmethod + def is_session_event(cls, event: dict, e_type: Optional[str] = None) -> bool: + if e_type is None: + e_type = event.get("type", "") + return e_type.startswith("session.") + + @classmethod + def is_input_audio_event(cls, event: dict, e_type: Optional[str] = None) -> bool: + if e_type is None: + e_type = event.get("type", "") + return e_type.startswith("input_audio_buffer.") + + @classmethod + def is_respond_event(cls, event: dict, e_type: Optional[str] = None) -> bool: + if e_type is None: + e_type = event.get("type", "") + return e_type.startswith("conversation.") + + @classmethod + def is_conversation_event(cls, event: dict, e_type: Optional[str] = None) -> bool: + if e_type is None: + e_type = event.get("type", "") + return e_type.startswith("conversation.") + + @classmethod + def is_conversation_item_event(cls, event: dict, e_type: Optional[str] = None) -> bool: + if e_type is None: + e_type = event.get("type", "") + return e_type.startswith("conversation.item") + + @classmethod + def get_event_id(cls, event: dict) -> str: + return event.get("event_id", "") + + @classmethod + def get_response_id(cls, event: dict) -> Union[str, None]: + if "response" in event: + return event["response"].get("id", None) + if "response_id" in event: + return event["response_id"] + return None + + def match(self, event: dict) -> bool: + return "type" in event and event["type"] == self.value + + +# ---- server side events ---- # + +class ServerEvent(BaseModel, ABC): + type: ClassVar[str] + event_id: str = Field(description="Optional client-generated ID used to identify this event.") + + +class ServerSessionCreated(ServerEvent): + type: ClassVar[str] = ServerEventType.session_created.value + session: SessionObject + + +class ServerSessionUpdated(ServerEvent): + type: ClassVar[str] = ServerEventType.session_updated.value + session: SessionObject + + +class ConversationCreated(ServerEvent): + type: ClassVar[str] = ServerEventType.conversation_created.value + conversation: ConversationObject + + +class ConversationItemCreated(ServerEvent): + type: ClassVar[str] = ServerEventType.conversation_item_created.value + previous_item_id: str = Field("") + item: MessageItem = Field() + + +class ConversationItemDeleted(ServerEvent): + type: ClassVar[str] = ServerEventType.conversation_item_deleted.value + item_id: str = Field("") + + +class ConversationInputAudioTranscriptionCompleted(ServerEvent): + type: ClassVar[str] = ServerEventType.conversation_item_input_audio_transcription_completed.value + item_id: str = Field("") + content_index: int = Field(default=0) + transcript: str = Field(default="") + + +class ConversationInputAudioTranscriptionFailed(ServerEvent): + type: ClassVar[str] = ServerEventType.conversation_item_input_audio_transcription_failed.value + item_id: str = Field("") + content_index: int = Field(default=0) + error: Error = Field() + + +class ConversationItemTruncated(ServerEvent): + type: ClassVar[str] = ServerEventType.conversation_item_truncated.value + item_id: str = Field("") + content_index: int = Field(default=0) + audio_end_ms: int = Field(default=0) + + +class InputAudioBufferCommitted(ServerEvent): + type: ClassVar[str] = ServerEventType.input_audio_buffer_committed.value + previous_item_id: str = Field("") + item_id: str = Field("") + + +class InputAudioBufferSpeechStarted(ServerEvent): + type: ClassVar[str] = ServerEventType.input_audio_buffer_speech_started.value + audio_start_ms: int = Field(default=0) + item_id: str = Field("") + + +class InputAudioBufferSpeechStopped(ServerEvent): + type: ClassVar[str] = ServerEventType.input_audio_buffer_speech_stopped.value + audio_end_ms: int = Field(default=0) + item_id: str = Field("") + + +class InputAudioBufferCleared(ServerEvent): + type: ClassVar[str] = ServerEventType.input_audio_buffer_cleared.value + + +class ResponseCreated(ServerEvent): + type: ClassVar[str] = ServerEventType.response_created.value + response: Response = Field() + + +class ResponseDone(ServerEvent): + type: ClassVar[str] = ServerEventType.response_done.value + response: Response = Field() + + +class ResponseOutputItemAdded(ServerEvent): + type: ClassVar[str] = ServerEventType.response_output_item_added.value + response_id: str = Field("") + output_index: int = Field(0) + item: MessageItem = Field() + + +class ResponseOutputItemDone(ServerEvent): + type: ClassVar[str] = ServerEventType.response_output_item_done.value + response_id: str = Field("") + output_index: int = Field(0) + item: MessageItem = Field() + + +class ResponseContentPartAdded(ServerEvent): + type: ClassVar[str] = ServerEventType.response_content_part_added.value + response_id: str = Field("") + output_index: int = Field(0) + item_id: str = Field("") + part: Content = Field() + + +class ResponseContentPartDone(ServerEvent): + type: ClassVar[str] = ServerEventType.response_content_part_done.value + response_id: str = Field("") + output_index: int = Field(0) + item_id: str = Field("") + part: Content = Field() + + +class ResponseTextDelta(DeltaIndex, ServerEvent): + type: ClassVar[str] = ServerEventType.response_text_delta.value + delta: str = Field("") + + +class ResponseTextDone(DeltaIndex, ServerEvent): + type: ClassVar[str] = ServerEventType.response_text_done.value + text: str = Field("") + + +class ResponseAudioTranscriptDelta(DeltaIndex, ServerEvent): + type: ClassVar[str] = ServerEventType.response_audio_transcript_delta.value + delta: str = Field("") + + +class ResponseAudioTranscriptDone(DeltaIndex, ServerEvent): + type: ClassVar[str] = ServerEventType.response_audio_transcript_done.value + transcript: str = Field("") + + +class ResponseAudioDelta(DeltaIndex, ServerEvent): + type: ClassVar[str] = ServerEventType.response_audio_delta.value + delta: str = Field("") + + +class ResponseAudioDone(DeltaIndex, ServerEvent): + type: ClassVar[str] = ServerEventType.response_audio_transcript_done.value + + +class ResponseFunctionCallArgumentsDelta(DeltaIndex, ServerEvent): + type: ClassVar[str] = ServerEventType.response_function_call_arguments_delta.value + delta: str = Field("") + + +class ResponseFunctionCallArgumentsDone(DeltaIndex, ServerEvent): + type: ClassVar[str] = ServerEventType.response_function_call_arguments_done.value + call_id: str = Field("") + arguments: str = Field("") + + +class RateLimitsUpdated(ServerEvent): + """ + Emitted at the beginning of a Response to indicate the updated rate limits. + When a Response is created some tokens will be "reserved" for the output tokens, + the rate limits shown here reflect that reservation, + which is then adjusted accordingly once the Response is completed. + """ + type: ClassVar[str] = ServerEventType.rate_limits_updated.value + rate_limits: List[RateLimit] = Field(default_factory=list) diff --git a/ghostos/prototypes/realtime/openai/protocols.py b/ghostos/prototypes/realtime/openai/protocols.py deleted file mode 100644 index 80cf9e70..00000000 --- a/ghostos/prototypes/realtime/openai/protocols.py +++ /dev/null @@ -1,178 +0,0 @@ -from typing import Self, Literal, List, Optional, Union, Dict, Protocol, ClassVar, Set -from abc import ABC, abstractmethod -from enum import Enum -from ghostos.prototypes.realtime.abcd import Function -from pydantic import BaseModel, Field -from ghostos.helpers import uuid - - -class StateName(str, Enum): - # --- can not change - stopped = "stopped" - - # --- blocking - connecting = "connecting" - session_updating = "session_updating" - - # --- interruptible - responding = "responding" - input_audio = "audio_input" - listening = "listening" # vad - - idle = "idle" - - # --- special operations allowed - failed = "failed" # failed but reconnect-able - - # I think this state is parallel, need test - # creating_conversation_item = "creating_conversation_item" - - -class OperatorName(str, Enum): - # --- idempotent or immediately - stop = "stop" # highest priority - reconnect = "reconnect" # second to stop - - # --- parallel actions - text_input = "text_input" - function_output = "function_output" - - # --- blocking - session_update = "session_updating" - - # --- idempotent or illegal - create_response = "create_response" - input_audio = "input_audio" # push-to-talk mode - start_listening = "start_listening" # start vad listening - - # --- immediately or illegal - truncate_listening = "truncate_listening" # vad only - response_cancel = "response_cancel" - - -class ServerEventType(str, Enum): - # recover-able error - error = "error" - - # non-block inform - session_created = "session.created" - session_updated = "session.updated" - conversation_created = "conversation.created" - - # streaming items - - # complete message item alignments - conversation_item_created = "conversation.item.created" - conversation_item_deleted = "conversation.item.deleted" - audio_transcript_created = "conversation.item.input_audio_transcription.completed" - audio_transcript_failed = "conversation.item.input_audio_transcription.failed" - response_output_item_done = "response.output_item.done" - - # system - rate_limits_updated = "rate_limits.updated" - - @classmethod - def conversation_item_events(cls) -> List["ServerEventType"]: - return [ - cls.conversation_item_created, - cls.conversation_item_deleted, - cls.audio_transcript_created, - cls.audio_transcript_failed, - cls.response_output_item_done, - ] - - @classmethod - def get_type(cls, event: dict) -> Self: - return cls(event["type"]) - - @classmethod - def get_event_id(cls, event: dict) -> str: - return event.get("event_id", "") - - @classmethod - def get_response_id(cls, event: dict) -> Union[str, None]: - if "response" in event: - return event["response"].get("id", None) - if "response_id" in event: - return event["response_id"] - return None - - def match(self, event: dict) -> bool: - return "type" in event and event["type"] == self.value - - -class ClientEventType(str, Enum): - session_update = "session.updated" - - -# ---- configs ---- # - -class OpenAISessionObjBase(BaseModel): - """ - immutable configuration for the openai session object - """ - model: str = Field("gpt-4o-realtime-preview-2024-10-01") - modalities: List[str] = Field(default_factory=list, enum={"text", "audio"}) - voice: str = Field(default="alloy", enum={"alloy", "echo", "shimmer"}) - input_audio_format: str = Field(default="pcm16", enum={"pcm16", "g711_ulaw", "g711_alaw"}) - output_audio_format: str = Field(default="pcm16", enum={"pcm16", "g711_ulaw", "g711_alaw"}) - turn_detection: Union[Dict, None] = Field(None) - - -class OpenAISessionObj(OpenAISessionObjBase): - """ - full data model for openai realtime-api session object - """ - id: str = Field(default="", description="id of the session") - object: Literal["realtime.session"] = "realtime.session" - tools: List[dict] = Field(default_factory=list) - tool_choice: str = Field(default="auto") - temperature: float = Field(default=0.8) - max_response_output_tokens: Union[int, Literal['inf']] = Field(default='inf') - - def get_update_event(self) -> dict: - data = self.model_dump(exclude={'id'}) - return data - - -# ---- server side events ---- # - -class ServerEvent(BaseModel, ABC): - event_id: str = Field(description="Optional client-generated ID used to identify this event.") - type: str = Field(description="server event type") - - -class ServerSessionCreated(ServerEvent): - session: OpenAISessionObj - - -# ---- client side events ---- # - - -class ClientEvent(BaseModel, ABC): - type: ClassVar[str] - event_id: Optional[str] = Field( - default=None, - description="Optional client-generated ID used to identify this event.", - ) - - def to_openai_event(self) -> dict: - event_id = self.event_id - type_ = self.type - data = {"type": type_} - if event_id: - data["event_id"] = event_id - return self._to_openai_event(data) - - @abstractmethod - def _to_openai_event(self, data: dict) -> dict: - pass - - -class ClientSessionUpdateEvent(ClientEvent): - type = ClientEventType.session_update.value - session: OpenAISessionObj - - def _to_openai_event(self, data: dict) -> dict: - data['session'] = self.session.model_dump() - return data diff --git a/ghostos/prototypes/realtime/openai/state_of_client.py b/ghostos/prototypes/realtime/openai/state_of_client.py new file mode 100644 index 00000000..35ddf86d --- /dev/null +++ b/ghostos/prototypes/realtime/openai/state_of_client.py @@ -0,0 +1,226 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from queue import Queue +from collections import deque +import time +from typing import List, Optional, Dict, Iterable, Tuple, Callable, Union +from threading import Thread +from ghostos.abcd import Conversation +from ghostos.core.messages import ReceiverBuffer +from ghostos.prototypes.realtime.abcd import ( + RealtimeApp, + Listener, Speaker, + RealtimeOperator, +) +from collections import deque +from ghostos.container import Container +from ghostos.core.messages import ( + Message, +) +from ghostos.contracts.logger import LoggerItf, get_logger +from ghostos.contracts.pool import DefaultPool +# from concurrent.futures import ThreadPoolExecutor +# from queue import Queue +# from .protocols import StateName, ServerEventType +from .configs import OpenAIRealtimeConf +from .ws import OpenAIWSConnection, OpenAIWebsocketsConf + +from abc import ABC, abstractmethod + +from .app import RealtimeApp +from .event_from_server import * +from .configs import OpenAIRealtimeConf +from ghostos.prototypes.realtime.abcd import RealtimeOperator, State + + +class OpenAIRealtimeContext: + + def __init__( + self, + conversation: Conversation, + conf: OpenAIRealtimeConf, + logger: LoggerItf, + ): + self.conversation = conversation + self.logger = logger + self.error: Optional[Exception] = None + self.conf: OpenAIRealtimeConf = conf + self.listening: bool = False + self.speaking_queue: Queue = Queue() + self.speaking_id: Optional[str] = None + self.buffer: List[Message] = [] + self.outputting_completed: Dict[int, Message] = {} + self.outputting_chunks: Dict[int, List[Message]] = {} + self.outputting_id: Optional[int] = None + self.connection: Optional[OpenAIWSConnection] = None + self.closed: bool = False + + def is_closed(self) -> bool: + return self.closed or self.conversation.closed() + + def stop_speaking(self): + self.speaking_id = None + self.speaking_queue.put(None) + self.speaking_queue = Queue() + + def start_speaking(self, speaking_id: str): + self.speaking_queue.put(None) + self.speaking_id = speaking_id + self.speaking_queue = Queue() + + def messages(self) -> Iterable[Message]: + pass + + def push_message(self, message: Message): + pass + + def add_message_to_server(self, message: Message): + pass + + def default_handle_server_event(self, event: dict): + if ServerEventType.error.match(event): + pass + elif ServerEventType.session_created.match(event): + pass + elif ServerEventType.session_updated.match(event): + pass + elif ServerEventType.conversation_created.match(event): + pass + elif ServerEventType.conversation_item_created.match(event): + pass + elif ServerEventType.conversation_item_deleted.match(event): + pass + elif ServerEventType.audio_transcript_created.match(event): + pass + elif ServerEventType.audio_transcript_failed.match(event): + pass + elif ServerEventType.response_output_item_done.match(event): + pass + + +class Connecting(State): + + def __init__(self, ctx: OpenAIRealtimeContext): + self.ctx = ctx + + def name(self) -> str: + return "Connecting" + + def is_outputting(self) -> bool: + return False + + def run_frame(self) -> Optional[State]: + ws_conf = self.ctx.conf.ws_conf + if self.ctx.connection: + self.ctx.connection.close() + self.ctx.connection = OpenAIWSConnection(ws_conf, logger=self.ctx.logger) + return Connected(self.ctx) + + def allow(self, op: RealtimeOperator) -> Tuple[bool, Optional[str]]: + return False, "not implemented" + + def handle(self, op: RealtimeOperator) -> Optional[State]: + event = self.ctx.connection.recv() + if event is None: + return None + + return self + + def destroy(self): + del self.ctx + + +class Connected(State): + + def __init__(self, ctx: OpenAIRealtimeContext): + self.ctx = ctx + + def name(self) -> str: + return "Connected" + + def allow(self, op: RealtimeOperator) -> Tuple[bool, Optional[str]]: + pass + + def is_outputting(self) -> bool: + pass + + def handle(self, op: RealtimeOperator) -> Optional[State]: + pass + + def run_frame(self) -> Optional[State]: + event = self.ctx.connection.recv() + if event is None: + return None + if ServerEventType.session_created.match(event): + return SyncConversation(self.ctx) + + def destroy(self): + pass + + +class SyncConversation(State): + def __init__(self, ctx: OpenAIRealtimeContext): + self.ctx = ctx + + def name(self) -> str: + pass + + def allow(self, op: RealtimeOperator) -> Tuple[bool, Optional[str]]: + pass + + def is_outputting(self) -> bool: + pass + + def handle(self, op: RealtimeOperator) -> Optional[State]: + pass + + def run_frame(self) -> Optional[State]: + for msg in self.ctx.messages(): + self.ctx.add_message_to_server(msg) + + def destroy(self): + pass + + +class WaitingServer(State): + def __init__(self, ctx: OpenAIRealtimeContext): + self.ctx = ctx + + def name(self) -> str: + pass + + def allow(self, op: RealtimeOperator) -> Tuple[bool, Optional[str]]: + pass + + def is_outputting(self) -> bool: + pass + + def handle(self, op: RealtimeOperator) -> Optional[State]: + pass + + def run_frame(self) -> Optional[State]: + event = self.ctx.connection.recv() + if event is None: + return None + if ServerEventType.conversation_created.match(event): + # todo + return None + + def destroy(self): + pass + + +class Listening(State): + def __init__(self, ctx: OpenAIRealtimeContext): + self.ctx = ctx + + def is_outputting(self) -> bool: + return True + + +class Responding(State): + def __init__(self, ctx: OpenAIRealtimeContext): + self.ctx = ctx + + def is_outputting(self) -> bool: + return True diff --git a/ghostos/prototypes/realtime/openai/state_of_server.py b/ghostos/prototypes/realtime/openai/state_of_server.py new file mode 100644 index 00000000..3e76b6ff --- /dev/null +++ b/ghostos/prototypes/realtime/openai/state_of_server.py @@ -0,0 +1,129 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import Protocol, Optional, Dict +from .event_from_server import * +from .ws import OpenAIWSConnection +from .configs import SessionObject, OpenAIRealtimeConf +from .context import Context +from ghostos.core.messages import Message +from ghostos.abcd import Conversation, GoThreadInfo +from queue import Queue + + +class ServerState(Protocol): + ctx: Context + + @abstractmethod + def recv(self, event: dict): + """ + recv an openai realtime server event, and handle it. + recv one server event at a time globally. + :param event: + :return: + """ + pass + + @abstractmethod + def gc(self): + pass + + def recv_invalid_event(self, event: dict): + pass + + def ack_server_event(self, event: ServerEvent): + pass + + +class SessionState(ServerState, Protocol): + """ + session is the root of server state + """ + session_obj: SessionObject + conversation: ConversationState + rate_limit: Optional[dict] + input_audio: InputAudioBuffer + status: Literal["new", "updated", "closed"] + + def recv(self, event: dict): + type_name = ServerEventType.get_type(event) + if ServerEventType.is_session_event(event, type_name): + return self._recv_session_event(event, type_name) + elif ServerEventType.rate_limits_updated: + return self._update_rate_limit(event) + elif ServerEventType.is_conversation_event(event, type_name): + return self._recv_conversation_event(event) + elif ServerEventType.is_input_audio_event(event, type_name): + return self._recv_input_audio_event(event) + elif ServerEventType.is_respond_event(event, type_name): + return self._recv_response_event(event) + else: + return self.recv_invalid_event(event) + + def gc(self): + pass + + def _recv_session_event(self, event: dict, e_type: str): + if e_type == ServerSessionCreated.type: + obj = ServerSessionCreated(**event) + elif e_type == ServerSessionUpdated.type: + obj = ServerSessionUpdated(**event) + else: + return self.recv_invalid_event(event) + if obj.session_id != self.session_obj.session_id: + return self.recv_invalid_event(event) + return self.ack_server_event(obj) + + def _recv_response_event(self, event: dict): + pass + + def _recv_conversation_event(self, event: dict): + pass + + def _recv_input_audio_event(self, event: dict): + pass + + def _update_rate_limit(self, event: dict): + pass + + +class ConversationState(Protocol): + session_id: str + conversation_id: str + items: dict[int, ConversationItemStatus] + responses: dict[int, ResponseBuffer] + status: Literal["new", "created", "closed"] + + @abstractmethod + def recv(self, event: dict): + if ServerEventType.conversation_created.match(event): + self.status = "created" + return + elif ServerEventType.conversation_item_created.match(event): + self._update_item(event) + return + + def _update_item(self, event: dict): + pass + + +class ConversationItemStatus(Protocol): + session_id: str + conversation_id: str + index: int + item_id: str + + @abstractmethod + def recv(self, event: dict): + pass + + +class InputAudioBuffer(Protocol): + pass + + +class ResponseBuffer(Protocol): + output_items: dict[int, OutputItemBuffer] + + +class OutputItemBuffer(Protocol): + pass diff --git a/ghostos/prototypes/realtime/openai/utils.py b/ghostos/prototypes/realtime/openai/utils.py index 72384142..5c735335 100644 --- a/ghostos/prototypes/realtime/openai/utils.py +++ b/ghostos/prototypes/realtime/openai/utils.py @@ -1,6 +1,6 @@ from typing import Union from ghostos.core.messages import Message -from .protocols import ClientEvent +from .event_from_server import ClientEvent __all__ = ['parse_message_to_client_event', 'parse_server_event_to_message'] diff --git a/ghostos/prototypes/realtime/openai/ws.py b/ghostos/prototypes/realtime/openai/ws.py index 0a860065..a89ffdb4 100644 --- a/ghostos/prototypes/realtime/openai/ws.py +++ b/ghostos/prototypes/realtime/openai/ws.py @@ -1,7 +1,6 @@ from __future__ import annotations import time -import requests import socks from typing import Union diff --git a/ghostos/prototypes/realtime/pyaudio_io/__init__.py b/ghostos/prototypes/realtime/pyaudio_io/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/realtime/pyaudio_io/listener.py b/ghostos/prototypes/realtime/pyaudio_io/listener.py new file mode 100644 index 00000000..2825b240 --- /dev/null +++ b/ghostos/prototypes/realtime/pyaudio_io/listener.py @@ -0,0 +1,44 @@ +try: + from pyaudio import PyAudio +except ImportError: + raise ImportError(f"Pyaudio is required, please install pyaudio or ghostos[audio] first") + +from typing import Optional +from io import BytesIO +from ghostos.prototypes.realtime.abcd import Speaker + + +class PyAudioSpeaker(Speaker): + + def __init__(self): + self.stream: Optional[PyAudio.Stream] = None + self.buffer: Optional[BytesIO] = None + + def __enter__(self): + if self.stream is not None: + raise RuntimeError("PyAudioSpeaker already initialized") + self.buffer = BytesIO() + self.stream = PyAudio().open( + + ) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.stream is not None: + self.stream.stop_stream() + self.stream.close() + self.stream = None + + def speak(self, data: bytes): + if self.stream is None: + raise RuntimeError("PyAudioSpeaker is not started in context manager") + self.stream.write(data) + self.buffer.write(data) + + def flush(self) -> bytes: + if self.buffer is None: + return bytes() + value = self.buffer.getvalue() + self.buffer.close() + self.buffer = None + return value diff --git a/ghostos/prototypes/realtime/pyaudio_io/speaker.py b/ghostos/prototypes/realtime/pyaudio_io/speaker.py new file mode 100644 index 00000000..2825b240 --- /dev/null +++ b/ghostos/prototypes/realtime/pyaudio_io/speaker.py @@ -0,0 +1,44 @@ +try: + from pyaudio import PyAudio +except ImportError: + raise ImportError(f"Pyaudio is required, please install pyaudio or ghostos[audio] first") + +from typing import Optional +from io import BytesIO +from ghostos.prototypes.realtime.abcd import Speaker + + +class PyAudioSpeaker(Speaker): + + def __init__(self): + self.stream: Optional[PyAudio.Stream] = None + self.buffer: Optional[BytesIO] = None + + def __enter__(self): + if self.stream is not None: + raise RuntimeError("PyAudioSpeaker already initialized") + self.buffer = BytesIO() + self.stream = PyAudio().open( + + ) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.stream is not None: + self.stream.stop_stream() + self.stream.close() + self.stream = None + + def speak(self, data: bytes): + if self.stream is None: + raise RuntimeError("PyAudioSpeaker is not started in context manager") + self.stream.write(data) + self.buffer.write(data) + + def flush(self) -> bytes: + if self.buffer is None: + return bytes() + value = self.buffer.getvalue() + self.buffer.close() + self.buffer = None + return value diff --git a/ghostos/prototypes/realtime/shells.py b/ghostos/prototypes/realtime/shells.py deleted file mode 100644 index f7c77ee2..00000000 --- a/ghostos/prototypes/realtime/shells.py +++ /dev/null @@ -1,58 +0,0 @@ -from abc import abstractmethod -from typing import Protocol, Iterable, Union, Literal -from .abcd import Shell, Message - - -class Chat(Shell, Protocol): - - @abstractmethod - def messages(self) -> Iterable[Message]: - pass - - @abstractmethod - def pop_message_head(self, timeout: float = 0.0) -> Union[Message, None]: - pass - - @abstractmethod - def read_message_chunks(self, msg_id: str) -> Iterable[Message]: - pass - - -class TextInput(Shell, Protocol): - - @abstractmethod - def send(self, text: str): - pass - - -class PushOnTalk(Shell, Protocol): - - @abstractmethod - def state(self) -> Literal["", "recording", "playing", "stopped"]: - pass - - @abstractmethod - def start_record(self): - pass - - @abstractmethod - def commit(self): - pass - - @abstractmethod - def clear(self): - pass - - @abstractmethod - def halt(self): - pass - - -class AudioOutput(Shell, Protocol): - @abstractmethod - def state(self) -> Literal["", "playing"]: - pass - - @abstractmethod - def cancel(self): - pass diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index ee2c5cbc..21b95c0b 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -1,6 +1,7 @@ import streamlit as st import streamlit_react_jsonschema as srj import streamlit_paste_button as spb +import time from PIL.Image import Image from typing import Iterable, List from ghostos.prototypes.streamlitapp.pages.router import ( @@ -22,7 +23,6 @@ Receiver, Role, ReceiverBuffer, MessageType, Message, ImageAssetMessage, ) -from ghostos.contracts.assets import ImageInfo from streamlit.logger import get_logger from ghostos.abcd import Shell, Conversation, Context from ghostos.identifier import get_identifier @@ -59,23 +59,33 @@ def main_chat(): st.subheader("chat options") with st.container(border=True): - route.auto_run = st.toggle( - _("auto run event"), - help=_("automatic run background event"), - value=True, - ) - route.camera_input = st.toggle( - _("camera_input"), - help=_("take picture from camera, the model shall support image type"), - value=route.camera_input, - key=route.generate_key(st.session_state, "camera_input"), - ) - route.image_input = st.toggle( - "image input", - help=_("upload picture, the model shall support image type"), - value=route.image_input, - key=route.generate_key(st.session_state, "image input"), + route.realtime = st.toggle( + _("voice chat"), + help=_("chat with agent by voice"), + value=route.realtime ) + if route.realtime: + route.auto_run = False + route.camera_input = False + route.image_input = False + else: + route.auto_run = st.toggle( + _("auto run event"), + help=_("automatic run background event"), + value=route.auto_run, + ) + route.camera_input = st.toggle( + _("camera_input"), + help=_("take picture from camera, the model shall support image type"), + value=route.camera_input, + key=route.generate_key(st.session_state, "camera_input"), + ) + route.image_input = st.toggle( + "image input", + help=_("upload picture, the model shall support image type"), + value=route.image_input, + key=route.generate_key(st.session_state, "image input"), + ) route.bind(st.session_state) render_empty() @@ -120,8 +130,16 @@ def main_chat(): # inputs if show_chatting: st.subheader("Chat") + if not route.realtime: + chatting(route, conversation) + else: + realtime(route, conversation) - chatting(route, conversation) + +def realtime(route: GhostChatRoute, conversation: Conversation): + thread = conversation.thread() + render_thread_messages(thread, max_turn=20) + debug = get_app_conf().BoolOpts.DEBUG_MODE.get() def get_conversation(route: GhostChatRoute) -> Conversation: @@ -198,16 +216,18 @@ def chatting(route: GhostChatRoute, conversation: Conversation): if inputs: event, receiver = conversation.respond(inputs) - render_event(event, debug) - render_receiver(receiver, debug) - - # while not route.media_input() and route.auto_run and conversation.available(): - # if event := conversation.pop_event(): - # render_event(event, debug) - # receiver = conversation.respond_event(event) - # render_receiver(receiver, debug) - # else: - # time.sleep(1) + with st.container(): + render_event(event, debug) + render_receiver(receiver, debug) + + while not route.media_input() and route.auto_run and conversation.available(): + if event := conversation.pop_event(): + with st.container(): + render_event(event, debug) + receiver = conversation.respond_event(event) + render_receiver(receiver, debug) + else: + time.sleep(1) @st.dialog("Textarea") @@ -221,30 +241,33 @@ def video_input_dialog(route: GhostChatRoute): def render_receiver(receiver: Receiver, debug: bool): try: with receiver: - with st.chat_message("assistant"): - with st.status("waiting..."): - buffer = ReceiverBuffer.new(receiver.recv()) - if buffer is None: - return - while buffer is not None: - st.logger.get_logger("ghostos").info("receive buffer head: %s", buffer.head()) - if MessageType.is_text(buffer.head()): - with st.empty(): + with st.container(): + with st.empty(): + with st.status("thinking"): + buffer = ReceiverBuffer.new(receiver.recv()) + st.empty() + if buffer is None: + return + with st.chat_message("assistant"): + while buffer is not None: + st.logger.get_logger("ghostos").info("receive buffer head: %s", buffer.head()) + if MessageType.is_text(buffer.head()): + with st.empty(): + contents = chunks_to_st_stream(buffer.chunks()) + st.write_stream(contents) + with st.container(): + render_message_in_content(buffer.tail(), debug, in_expander=False) + + elif MessageType.FUNCTION_CALL.match(buffer.head()): contents = chunks_to_st_stream(buffer.chunks()) - st.write_stream(contents) - with st.container(): - render_message_in_content(buffer.tail(), debug, in_expander=False) - - elif MessageType.FUNCTION_CALL.match(buffer.head()): - contents = chunks_to_st_stream(buffer.chunks()) - with st.empty(): - st.write_stream(contents) - with st.container(): - render_message_in_content(buffer.tail(), debug, in_expander=False) - else: - render_message_in_content(buffer.tail(), debug, in_expander=False) - # render next item - buffer = buffer.next() + with st.empty(): + st.write_stream(contents) + with st.container(): + render_message_in_content(buffer.tail(), debug, in_expander=False) + else: + render_message_in_content(buffer.tail(), debug, in_expander=False) + # render next item + buffer = buffer.next() except Exception as e: st.error(str(e)) st.exception(e) diff --git a/ghostos/prototypes/streamlitapp/pages/router.py b/ghostos/prototypes/streamlitapp/pages/router.py index 292ae683..ef6ffafc 100644 --- a/ghostos/prototypes/streamlitapp/pages/router.py +++ b/ghostos/prototypes/streamlitapp/pages/router.py @@ -48,6 +48,7 @@ class GhostChatRoute(Route): camera_input: bool = Field(default=False, description="camera input") image_input: bool = Field(default=False, description="image input") auto_run: bool = Field(default=True, description="auto run") + realtime: bool = Field(default=False, description="realtime") __ghost__ = None diff --git a/ghostos/prototypes/streamlitapp/tests/audio_output_test.py b/ghostos/prototypes/streamlitapp/tests/audio_output_test.py new file mode 100644 index 00000000..7c66c935 --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/audio_output_test.py @@ -0,0 +1,51 @@ +import streamlit as st +from typing import Iterable +import time + +if "run" not in st.session_state: + st.session_state["run"] = 1 +run = st.session_state["run"] +st.write(run) + + +class Output: + def __init__(self): + self.content: str = "" + + def iter_content(self) -> Iterable[str]: + passed = 0 + while True: + if passed > 500: + break + self.content += "hello" + yield "hello" + time.sleep(0.1) + passed += 1 + + +if "output" not in st.session_state: + st.session_state["output"] = Output() +output = st.session_state["output"] + +with st.chat_message("ai"): + st.write(output.content) + +recorded = 0 +if "recorded" in st.session_state: + recorded = st.session_state["recorded"] +st.write("recorded: %d" % recorded) +if recorded == 0: + if audio := st.audio_input("Audio file", key=f"{run}-audio-input"): + st.write(audio) + recorded = len(audio.getvalue()) + st.session_state["recorded"] = recorded + st.rerun() +else: + if st.button("stop"): + run += 1 + st.session_state["run"] = run + st.session_state["recorded"] = 0 + st.rerun() + with st.empty(): + st.write(output.iter_content()) + st.write("done") diff --git a/ghostos/prototypes/streamlitapp/tests/audio_test.py b/ghostos/prototypes/streamlitapp/tests/audio_test.py new file mode 100644 index 00000000..efda6faa --- /dev/null +++ b/ghostos/prototypes/streamlitapp/tests/audio_test.py @@ -0,0 +1,60 @@ +import streamlit as st +from threading import Thread +from io import BytesIO, BufferedReader +from os import path +from pyaudio import PyAudio, paInt16 + +pyaudio = PyAudio() +import time + +if "run" not in st.session_state: + st.session_state["run"] = 0 +st.session_state["run"] += 1 +st.write(st.session_state["run"]) + + +def write_audio_to_bytes(io_in: BytesIO, filename: str): + with open(filename, "wb+") as fl: + while True: + r = io_in.read(1024) + if not r: + break + fl.write(r) + time.sleep(0.01) + + +t = None +io_output = None +if audio := st.audio_input("Audio file"): + st.write(audio) + st.write(len(audio.getvalue())) + io_input = BytesIO(audio.getvalue()) + io_output = path.abspath("test.wav") + + t = Thread(target=write_audio_to_bytes, args=(io_input, io_output)) + t.start() + time.sleep(0.1) + +if io_output is not None: + st.write("output start") + now = time.time() + with open(io_output, "rb+") as f: + stream = pyaudio.open( + format=paInt16, + channels=1, + rate=44100, + output=True, + ) + while True: + got = f.read(1024) + if not got: + break + stream.write(got) + stream.stop_stream() + stream.close() + end = time.time() + st.write(round(end - now, 2)) + st.write("output end") + +if t is not None: + t.join() diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py index cfb1c8f5..631074b8 100644 --- a/ghostos/prototypes/streamlitapp/widgets/messages.py +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -37,22 +37,23 @@ def render_messages(messages: Iterable[Message], debug: bool, in_expander: bool, def render_message_group(group: MessageGroup, debug: bool, in_expander: bool, prefix: str = ""): - role = group.msg_role - name = group.msg_name - stage = group.stage - caption = f"{role}: {name}" if name else role - render_role = "user" if role == Role.USER.value else "assistant" - if stage: - with st.expander(stage, expanded=False): + with st.container(): + role = group.msg_role + name = group.msg_name + stage = group.stage + caption = f"{role}: {name}" if name else role + render_role = "user" if role == Role.USER.value else "assistant" + if stage: + with st.expander(stage, expanded=False): + with st.chat_message(render_role): + st.caption(caption) + for msg in group.messages: + render_message_in_content(msg, debug, prefix=prefix, in_expander=True) + else: with st.chat_message(render_role): st.caption(caption) for msg in group.messages: - render_message_in_content(msg, debug, prefix=prefix, in_expander=True) - else: - with st.chat_message(render_role): - st.caption(caption) - for msg in group.messages: - render_message_in_content(msg, debug, prefix=prefix, in_expander=in_expander) + render_message_in_content(msg, debug, prefix=prefix, in_expander=in_expander) def render_message_payloads(message: Message, debug: bool, prefix: str = ""): diff --git a/tests/python/test_pydantic.py b/tests/python/test_pydantic.py index b07773f2..32fc9067 100644 --- a/tests/python/test_pydantic.py +++ b/tests/python/test_pydantic.py @@ -232,3 +232,11 @@ class Foo(BaseModel): f = Foo() assert "(" not in str(f) assert "(" in repr(f) + + +def test_enum_with_none(): + class Foo(BaseModel): + foo: Optional[str] = Field(None, enum={"hello", "world"}) + + f = Foo() + assert f.foo is None From 708d029d6e12d84ceec14c91d7d2787c20131d7e Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 2 Dec 2024 20:33:53 +0800 Subject: [PATCH 117/148] fix: fix sphero gpt command lines with indent spaces error --- ghostos/prototypes/spherogpt/bolt.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ghostos/prototypes/spherogpt/bolt.py b/ghostos/prototypes/spherogpt/bolt.py index 1eaa9fc5..5cb433d7 100644 --- a/ghostos/prototypes/spherogpt/bolt.py +++ b/ghostos/prototypes/spherogpt/bolt.py @@ -55,9 +55,12 @@ def run_frame(self, api: SpheroEduAPI, passed: float, frame: int) -> None: # import types in case you need. from spherov2.types import Color, ToyType + # strip the spaces before each line. + code = "\n".join([line.strip() for line in self.code.splitlines()]) + # eval the python code defined in the command. # this is how the command work - eval(self.code) + eval(code) @classmethod def once(cls, name: str, code: str, duration: float): From 6d6737e681147a2089a7c4d7d6b1dbd5c3c99117 Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 5 Dec 2024 01:04:51 +0800 Subject: [PATCH 118/148] dev: save realtime development, test tomorrow --- examples/agents/sphero_bolt_gpt.py | 2 +- ghostos/abcd/concepts.py | 24 +- ghostos/contracts/assets.py | 41 +- ghostos/core/llms/tools.py | 3 + ghostos/core/messages/__init__.py | 4 +- ghostos/core/messages/message.py | 80 ++- ghostos/core/messages/message_classes.py | 68 ++- ghostos/core/messages/openai.py | 12 +- ghostos/core/messages/pipeline.py | 3 + ghostos/core/messages/transport.py | 16 +- ghostos/core/runtime/events.py | 2 + ghostos/core/runtime/threads.py | 28 +- .../framework/ghostos/conversation_impl.py | 70 ++- ghostos/framework/ghostos/session_impl.py | 23 +- ghostos/framework/ghostos/shell_impl.py | 6 +- ghostos/framework/messengers/defaults.py | 2 +- ghostos/ghosts/chatbot/simplest.py | 7 +- ghostos/ghosts/moss_agent/agent.py | 20 +- ghostos/prototypes/realtime/abcd.py | 63 +- ghostos/prototypes/realtime/openai/app.py | 70 +-- ghostos/prototypes/realtime/openai/client.py | 339 +++++++++++ ghostos/prototypes/realtime/openai/configs.py | 5 +- ghostos/prototypes/realtime/openai/context.py | 233 ++++++- .../realtime/openai/event_data_objects.py | 173 +++++- .../realtime/openai/event_from_client.py | 30 +- .../realtime/openai/event_from_server.py | 243 ++++++-- .../realtime/openai/state_of_client.py | 464 +++++++++----- .../realtime/openai/state_of_server.py | 574 ++++++++++++++++-- ghostos/prototypes/realtime/openai/ws.py | 1 + ghostos/prototypes/spherogpt/bolt.py | 2 +- .../prototypes/streamlitapp/pages/ghosts.py | 12 +- .../streamlitapp/tests/tools/history.py | 2 +- ghostos/scripts/cli/run_aifunc.py | 2 +- ghostos/thoughts/basic.py | 6 +- ghostos/thoughts/moss_thought.py | 4 +- ghostos/thoughts/pymodule_editor.py | 2 +- .../core/messages/test_arr_stream_receiver.py | 4 +- tests/core/messages/test_messages.py | 8 +- tests/framework/messenger/test_messenger.py | 2 +- 39 files changed, 2181 insertions(+), 469 deletions(-) create mode 100644 ghostos/prototypes/realtime/openai/client.py diff --git a/examples/agents/sphero_bolt_gpt.py b/examples/agents/sphero_bolt_gpt.py index d5f024fd..d3a36c9e 100644 --- a/examples/agents/sphero_bolt_gpt.py +++ b/examples/agents/sphero_bolt_gpt.py @@ -24,7 +24,7 @@ def example_spin_the_bolt(moss: Moss): def __moss_attr_prompts__(): yield "MossAgent", "" - yield from exports.items() + yield from exports.conversation_item_states() def __moss_agent_providers__(agent): diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index 79c6ef65..b1ac0d94 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -14,7 +14,7 @@ from ghostos.core.runtime.events import Event from ghostos.core.runtime.tasks import GoTaskStruct, TaskBrief from ghostos.core.runtime.threads import GoThreadInfo -from ghostos.core.llms import PromptPipe, Prompt +from ghostos.core.llms import PromptPipe, Prompt, LLMFunc from ghostos.core.messages import MessageKind, Message, Stream, Caller, Payload, Receiver, Role from ghostos.contracts.logger import LoggerItf from ghostos.container import Container, Provider @@ -124,6 +124,10 @@ def get_artifact(self, session: Session) -> Optional[G.ArtifactType]: def get_instructions(self, session: Session) -> str: pass + @abstractmethod + def functions(self, session: Session) -> List[LLMFunc]: + pass + @abstractmethod def providers(self) -> Iterable[Provider]: """ @@ -211,6 +215,10 @@ class Action(PromptPipe, ABC): def name(self) -> str: pass + @abstractmethod + def as_function(self) -> Optional[LLMFunc]: + pass + @abstractmethod def update_prompt(self, prompt: Prompt) -> Prompt: pass @@ -344,6 +352,8 @@ class Conversation(Protocol[G]): """ task_id: str + scope: Scope + logger: LoggerItf @abstractmethod @@ -354,11 +364,11 @@ def container(self) -> Container: pass @abstractmethod - def task(self) -> GoTaskStruct: + def get_task(self) -> GoTaskStruct: pass @abstractmethod - def thread(self, truncated: bool = False) -> GoThreadInfo: + def get_thread(self, truncated: bool = False) -> GoThreadInfo: pass @abstractmethod @@ -382,6 +392,10 @@ def get_context(self) -> G.ContextType: def get_instructions(self) -> str: pass + @abstractmethod + def get_functions(self) -> List[LLMFunc]: + pass + @abstractmethod def get_artifact(self) -> Tuple[Union[G.ArtifactType, None], TaskState]: pass @@ -445,7 +459,7 @@ def available(self) -> bool: pass @abstractmethod - def closed(self) -> bool: + def is_closed(self) -> bool: """ closed """ @@ -455,7 +469,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - if self.closed(): + if self.is_closed(): return if exc_val is not None: return self.fail(exc_val) diff --git a/ghostos/contracts/assets.py b/ghostos/contracts/assets.py index 0a2833c9..8ef688d2 100644 --- a/ghostos/contracts/assets.py +++ b/ghostos/contracts/assets.py @@ -1,5 +1,7 @@ +import os from abc import ABC, abstractmethod from typing import Optional, Tuple, Union +from mimetypes import guess_type from pydantic import BaseModel, Field from ghostos.contracts.storage import Storage from ghostos.helpers import uuid, yaml_pretty_dump @@ -20,6 +22,24 @@ def get_format(self) -> str: class FileAssets(ABC): + @classmethod + def new_fileinfo( + cls, + fileid: str, + filename: str, + description: str = "", + filetype: Optional[str] = None, + ) -> FileInfo: + if filetype is None: + filetype = guess_type(filename) + fileinfo = FileInfo( + fileid=fileid, + filename=filename, + description=description, + filetype=filetype, + ) + return fileinfo + @abstractmethod def save(self, file: FileInfo, binary: Optional[bytes]) -> str: """ @@ -48,6 +68,10 @@ def get_fileinfo(self, fileid: str) -> Optional[FileInfo]: """ pass + @abstractmethod + def has_binary(self, fileid: str) -> bool: + pass + def get_binary_by_id(self, fileid: str) -> Optional[Tuple[FileInfo, Union[bytes, None]]]: """ get binary data by file id @@ -80,10 +104,10 @@ def _get_fileinfo_filename(fileid: str) -> str: def save(self, file: FileInfo, binary: Optional[bytes]) -> str: if binary is None and file.url is None: raise AttributeError("failed to save image: binary is None and image info is not from url.") - image_info_filename = self._get_fileinfo_filename(file.fileid) + fileinfo_filename = self._get_fileinfo_filename(file.fileid) data = file.model_dump(exclude_none=True) content = yaml_pretty_dump(data) - self._storage.put(image_info_filename, content.encode()) + self._storage.put(fileinfo_filename, content.encode()) if binary: self._storage.put(file.filename, binary) return file.filename @@ -93,10 +117,17 @@ def get_binary(self, filename: str) -> Optional[bytes]: return self._storage.get(filename) return None + def has_binary(self, fileid: str) -> bool: + fileinfo = self.get_fileinfo(fileid) + if fileinfo is None: + return False + filename = fileinfo.filename + return self._storage.exists(filename) + def get_fileinfo(self, fileid: str) -> Optional[FileInfo]: - image_info_filename = self._get_fileinfo_filename(fileid) - if not self._storage.exists(image_info_filename): + fileinfo_filename = self._get_fileinfo_filename(fileid) + if not self._storage.exists(fileinfo_filename): return None - content = self._storage.get(image_info_filename) + content = self._storage.get(fileinfo_filename) data = yaml.safe_load(content) return FileInfo(**data) diff --git a/ghostos/core/llms/tools.py b/ghostos/core/llms/tools.py index 7896b6d1..5fb718e2 100644 --- a/ghostos/core/llms/tools.py +++ b/ghostos/core/llms/tools.py @@ -38,6 +38,9 @@ def new(cls, name: str, desc: Optional[str] = None, parameters: Optional[Dict] = del parameters["title"] return cls(name=name, description=desc, parameters=parameters) + def to_dict(self) -> dict: + return self.model_dump(exclude_defaults=True, exclude_none=True) + @classmethod def from_model( cls, diff --git a/ghostos/core/messages/__init__.py b/ghostos/core/messages/__init__.py index a6cacc21..96f9a20a 100644 --- a/ghostos/core/messages/__init__.py +++ b/ghostos/core/messages/__init__.py @@ -5,7 +5,9 @@ MessageClassesParser, ) from ghostos.core.messages.message_classes import ( - MessageKindParser, VariableMessage, ImageAssetMessage, + MessageKindParser, + VariableMessage, ImageAssetMessage, AudioMessage, FunctionCallMessage, FunctionCallOutputMessage, + ) from ghostos.core.messages.payload import Payload from ghostos.core.messages.openai import ( diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index f0d07f78..52ae95e6 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -78,14 +78,30 @@ def new( role: str = Role.ASSISTANT.value, memory: Optional[str] = None, name: Optional[str] = None, + msg_id: Optional[str] = None, + call_id: Optional[str] = None, ) -> "Message": - return Message(content=content, memory=memory, name=name, type=self.value, role=role).as_tail() + return Message( + msg_id=msg_id or "", + content=content, memory=memory, name=name, type=self.value, role=role, + call_id=call_id, + ).as_tail(copy=False) def new_assistant( - self, *, - content: str, memory: Optional[str] = None, name: Optional[str] = None, + self, + *, + content: str, + memory: Optional[str] = None, + name: Optional[str] = None, + msg_id: Optional[str] = None, ): - return self.new(content=content, role=Role.ASSISTANT.value, memory=memory, name=name) + return self.new( + content=content, + role=Role.ASSISTANT.value, + memory=memory, + name=name, + msg_id=msg_id or None, + ) def new_system( self, *, @@ -170,8 +186,7 @@ class Message(BaseModel): """ message protocol """ msg_id: str = Field(default="", description="unique message id. ") - ref_id: Optional[str] = Field(default=None, description="the referenced message id.") - from_id: Optional[str] = Field(default=None, description="the origin message id.") + call_id: Optional[str] = Field(default=None, description="the call id message id.") index: Optional[int] = Field(default=None, description="the index of the message.") type: str = Field(default="", description="default message type, if empty, means text") stage: str = Field(default="", description="message stage") @@ -219,7 +234,7 @@ def new_head( memory: Optional[str] = None, name: Optional[str] = None, msg_id: Optional[str] = None, - ref_id: Optional[str] = None, + call_id: Optional[str] = None, ): """ create a head chunk message @@ -229,13 +244,17 @@ def new_head( :param memory: :param name: :param msg_id: - :param ref_id: + :param call_id: # :param created: :return: """ if msg_id is None: msg_id = uuid() created = round(time.time(), 3) + if isinstance(role, Role): + role = role.value + if isinstance(typ_, MessageType): + typ_ = typ_.value return cls( role=role, name=name, @@ -243,7 +262,7 @@ def new_head( memory=memory, seq="head", type=typ_, - ref_id=ref_id, + call_id=call_id, msg_id=msg_id, created=created, ) @@ -257,7 +276,8 @@ def new_tail( memory: Optional[str] = None, name: Optional[str] = None, msg_id: Optional[str] = None, - ref_id: Optional[str] = None, + # todo: change to call id + call_id: Optional[str] = None, attrs: Optional[Dict[str, Any]] = None, ): """ @@ -268,7 +288,7 @@ def new_tail( :param memory: :param name: :param msg_id: - :param ref_id: + :param call_id: :param attrs: :return: """ @@ -279,7 +299,7 @@ def new_tail( memory=memory, typ_=type_, msg_id=msg_id, - ref_id=ref_id, + call_id=call_id, ) msg.seq = "complete" msg.attrs = attrs @@ -293,7 +313,8 @@ def new_chunk( content: Optional[str] = None, memory: Optional[str] = None, name: Optional[str] = None, - ref_id: Optional[str] = None, + call_id: Optional[str] = None, + msg_id: Optional[str] = None, ): """ create a chunk message. @@ -302,7 +323,8 @@ def new_chunk( return cls( role=role, name=name, content=content, memory=memory, type=typ_, - ref_id=ref_id, + call_id=call_id, + msg_id=msg_id or "", seq="chunk", ) @@ -323,7 +345,7 @@ def patch(self, chunk: "Message") -> Optional["Message"]: """ # if the type is not same, it can't be patched pack_type = chunk.get_type() - if pack_type and pack_type != self.get_type(): + if pack_type and self.type and pack_type != self.type: return None # the chunk message shall have the same message id or empty one if chunk.msg_id and self.msg_id and chunk.msg_id != self.msg_id: @@ -367,11 +389,12 @@ def update(self, pack: "Message") -> None: if not self.msg_id: # 当前消息的 msg id 不会变更. self.msg_id = pack.msg_id - if not self.ref_id: - self.ref_id = pack.ref_id + if not self.call_id: + self.call_id = pack.call_id if not self.type: - # type 也不会变更. + # only update when self type is empty (default) self.type = pack.type + if not self.role: self.role = pack.role if self.name is None: @@ -468,6 +491,8 @@ class Caller(BaseModel): id: Optional[str] = Field(default=None, description="caller 的 id, 用来 match openai 的 tool call 协议. ") name: str = Field(description="方法的名字.") arguments: str = Field(description="方法的参数. ") + + # deprecated functional_token: bool = Field(default=False, description="caller 是否是基于协议生成的?") def add(self, message: "Message") -> None: @@ -484,7 +509,7 @@ def new_output(self, output: str) -> CallerOutput: def from_message(cls, message: Message) -> Iterable[Caller]: if message.type == MessageType.FUNCTION_CALL.value: yield Caller( - id=message.ref_id, + id=message.call_id, name=message.name, arguments=message.content, ) @@ -492,20 +517,29 @@ def from_message(cls, message: Message) -> Iterable[Caller]: yield from message.callers +# todo: history code, optimize later class CallerOutput(BaseModel, MessageClass): __message_type__ = MessageType.FUNCTION_OUTPUT.value call_id: Optional[str] = Field(None, description="caller id") - name: str = Field(description="caller name") + name: Optional[str] = Field( + default=None, + description="caller name, caller id and caller name can not both be empty", + ) content: Optional[str] = Field(description="caller output") + msg_id: Optional[str] = Field(None) + payloads: Dict[str, Dict] = Field(default_factory=dict) + def to_message(self) -> Message: return Message( - ref_id=self.call_id, + msg_id=self.msg_id, + call_id=self.call_id, type=MessageType.FUNCTION_OUTPUT.value, name=self.name, role="", content=self.content, + payloads=self.payloads, ) @classmethod @@ -513,9 +547,11 @@ def from_message(cls, message: Message) -> Optional[Self]: if message.type != MessageType.FUNCTION_OUTPUT.value: return None return cls( - call_id=message.ref_id, + msg_id=message.msg_id, + call_id=message.call_id, name=message.name, content=message.content, + payloads=message.payloads, ) def to_openai_param(self, container: Optional[Container], compatible: bool = False) -> List[Dict]: diff --git a/ghostos/core/messages/message_classes.py b/ghostos/core/messages/message_classes.py index e09efe09..cbfbca6b 100644 --- a/ghostos/core/messages/message_classes.py +++ b/ghostos/core/messages/message_classes.py @@ -6,17 +6,75 @@ from ghostos.contracts.assets import FileInfo from ghostos.container import Container from ghostos.prompter import get_defined_prompt -from .message import Message, MessageClass, MessageType, CallerOutput, MessageKind, Role +from .message import Message, MessageClass, MessageType, CallerOutput, MessageKind, Role, Caller from ghostos.helpers import uuid from pydantic import BaseModel, Field __all__ = [ "VariableMessage", + "FunctionCallMessage", + "FunctionCallOutputMessage", "CallerOutput", "ImageAssetMessage", + "AudioMessage", "MessageKindParser", ] +FunctionCallOutputMessage = CallerOutput + + +class FunctionCallMessage(MessageClass, BaseModel): + __message_type__ = MessageType.FUNCTION_CALL.value + + msg_id: str = Field(default_factory=uuid, description="message id") + payloads: Dict[str, Dict] = Field( + default_factory=dict, + description="payload type key to payload item. payload shall be a strong-typed dict" + ) + role: str = Field(default="", description="who send the message") + caller: Caller + + def to_message(self) -> Message: + return Message.new_tail( + type_=self.__message_type__, + msg_id=self.msg_id, + role=self.role, + name=self.caller.name, + call_id=self.caller.id, + content=self.caller.arguments, + ) + + @classmethod + def from_message(cls, message: Message) -> Optional[Self]: + return cls( + msg_id=message.msg_id, + payloads=message.payloads, + role=message.role, + caller=Caller( + id=message.call_id, + name=message.name, + arguments=message.content, + ) + ) + + def to_openai_param(self, container: Optional[Container], compatible: bool = False) -> List[Dict]: + from openai.types.chat.chat_completion_assistant_message_param import ( + ChatCompletionAssistantMessageParam, FunctionCall, + ) + from openai.types.chat.chat_completion_message_tool_call_param import ChatCompletionMessageToolCallParam + + return [ChatCompletionAssistantMessageParam( + role="assistant", + tool_calls=[ChatCompletionMessageToolCallParam( + id=self.caller.id, + function=FunctionCall( + name=self.caller.name, + arguments=self.caller.arguments, + ), + type="function" + )] + )] + class VariableMessage(MessageClass, BaseModel): """ @@ -261,11 +319,11 @@ def __init__( *, name: Optional[str] = None, role: str = Role.ASSISTANT.value, - ref_id: Optional[str] = None, + call_id: Optional[str] = None, ) -> None: self.variables = variables self.role = role - self.ref_id = ref_id + self.call_id = call_id self.name = name def parse(self, messages: Iterable[Union[MessageKind, Any]]) -> Iterable[Message]: @@ -291,8 +349,8 @@ def parse(self, messages: Iterable[Union[MessageKind, Any]]) -> Iterable[Message yield vm.to_message() def _with_ref(self, item: Message) -> Message: - if self.ref_id is not None: - item.ref_id = self.ref_id + if self.call_id is not None: + item.call_id = self.call_id if not item.role and self.role: item.role = self.role if not item.name and self.name: diff --git a/ghostos/core/messages/openai.py b/ghostos/core/messages/openai.py index 6d91355d..e06b94c6 100644 --- a/ghostos/core/messages/openai.py +++ b/ghostos/core/messages/openai.py @@ -141,12 +141,12 @@ def parse_message( def _parse_message(self, message: Message) -> Iterable[ChatCompletionMessageParam]: if message.type == MessageType.FUNCTION_CALL.value: - if message.ref_id: + if message.call_id: return [ ChatCompletionAssistantMessageParam( role="assistant", tool_calls=[ChatCompletionMessageToolCallParam( - id=message.ref_id, + id=message.call_id, function=FunctionCall( name=message.name, arguments=message.content, @@ -166,10 +166,10 @@ def _parse_message(self, message: Message) -> Iterable[ChatCompletionMessagePara ) ] elif message.type == MessageType.FUNCTION_OUTPUT: - if message.ref_id: + if message.call_id: return [ ChatCompletionToolMessageParam( - tool_call_id=message.ref_id, + tool_call_id=message.call_id, content=message.content, role="tool", ) @@ -288,7 +288,7 @@ def from_chat_completion_chunks(self, messages: Iterable[ChatCompletionChunk]) - if chunk is None: continue elif item.id: - chunk.from_id = item.id + chunk.msg_id = item.id if buffer is None: buffer = chunk.as_head(copy=True) @@ -333,7 +333,7 @@ def _new_chunk_from_delta(delta: ChoiceDelta) -> Optional[Message]: for item in delta.tool_calls: pack = Message.new_chunk( typ_=MessageType.FUNCTION_CALL.value, - ref_id=item.id, + call_id=item.id, name=item.function.name, content=item.function.arguments, ) diff --git a/ghostos/core/messages/pipeline.py b/ghostos/core/messages/pipeline.py index 830b952b..cdf2885d 100644 --- a/ghostos/core/messages/pipeline.py +++ b/ghostos/core/messages/pipeline.py @@ -62,6 +62,9 @@ def across(self, messages: Iterable[Message]) -> Iterable[Message]: buffer = patched continue else: + # add msg_id to item, keep every chunk has it id + if not item.msg_id: + item.msg_id = buffer.msg_id yield item else: yield buffer.as_tail() diff --git a/ghostos/core/messages/transport.py b/ghostos/core/messages/transport.py index f248b293..12b2f3fd 100644 --- a/ghostos/core/messages/transport.py +++ b/ghostos/core/messages/transport.py @@ -309,9 +309,11 @@ def closed(self) -> bool: class ReceiverBuffer: - def __init__(self, head: Message, receiver: Iterator[Message]): + def __init__(self, head: Message, iterator: Iterator[Message]): + if head.is_chunk(): + head = head.as_head() self._head = head - self._receiver = receiver + self._iterator = iterator self._chunks = [] self._done: Optional[Message] = None self._next: Optional[Self] = None @@ -341,7 +343,7 @@ def chunks(self) -> Iterable[Message]: yield self._head head = self._head.get_copy() try: - item = next(self._receiver) + item = next(self._iterator) except StopIteration: self._done = head.as_tail() return None @@ -358,11 +360,11 @@ def chunks(self) -> Iterable[Message]: else: if self._done is None: self._done = head.as_tail() - self._next = ReceiverBuffer(item, self._receiver) - self._receiver = None + self._next = ReceiverBuffer(item, self._iterator) + self._iterator = None break try: - item = next(self._receiver) + item = next(self._iterator) except StopIteration: break if self._done is None: @@ -375,7 +377,7 @@ def tail(self) -> Message: return self._done list(self.chunks()) if self._done is None: - raise RuntimeError(f"tail failed") + self._done = self._head.as_tail() return self._done def next(self) -> Optional[Self]: diff --git a/ghostos/core/runtime/events.py b/ghostos/core/runtime/events.py index fe092226..cf520c88 100644 --- a/ghostos/core/runtime/events.py +++ b/ghostos/core/runtime/events.py @@ -148,6 +148,8 @@ class EventTypes(str, Enum): INPUT = "input" + ACTION_CALL = "action_call" + NOTIFY = "notify" CANCEL = "cancel" diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index d608b535..93fa9c41 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -78,8 +78,7 @@ def iter_event_message(event: Event, show_instruction: bool = True) -> Iterable[ def messages(self, truncate: bool) -> Iterable[Message]: if truncate and self.summary is not None: - yield Role.SYSTEM.new("summary of omitted history messages" + self.summary) - return + return [Role.SYSTEM.new("summary of omitted history messages" + self.summary)] yield from self.event_messages() if self.added: @@ -97,6 +96,18 @@ def is_from_self(self) -> bool: def is_callback(self) -> bool: return self.event is not None and self.event.callback + def update_message(self, message: Message) -> bool: + messages = [] + found = False + for exists in self.added: + if exists.msg_id == message.msg_id: + Found = True + exists = message + messages.append(exists) + if found: + self.added = messages + return found + class GoThreadInfo(BaseModel): """ @@ -195,6 +206,19 @@ def get_history_messages(self, truncated: bool) -> Iterable[Message]: for turn in turns: yield from turn.messages(truncated) + def get_messages(self, truncated: bool) -> Iterable[Message]: + yield from self.get_history_messages(truncated) + if self.current: + yield from self.current.messages(False) + + def update_message(self, message: Message) -> bool: + if not message.is_complete(): + return False + for turn in self.turns(): + if turn.update_message(message): + return True + return False + def get_pycontext(self) -> PyContext: """ 返回最后一轮的 pycontext. diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index 7392db34..c7e58af1 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -14,8 +14,9 @@ GoTaskStruct, TaskLocker, GoTasks, TaskState, GoThreadInfo, GoThreads, ) +from ghostos.core.llms import LLMFunc from ghostos.contracts.pool import Pool -from ghostos.contracts.logger import LoggerItf +from ghostos.contracts.logger import LoggerItf, wrap_logger from ghostos.entity import to_entity_meta, get_entity from pydantic import BaseModel, Field from .session_impl import SessionImpl @@ -59,7 +60,6 @@ def __init__( self._conf = conf self.task_id = task.task_id self._container = Container(parent=container, name="conversation") - self.logger = self._container.force_fetch(LoggerItf) self._username = username self._user_role = user_role variables = self._container.force_fetch(Variables) @@ -68,16 +68,22 @@ def __init__( name=self._username, role=self._user_role, ) - self._scope = Scope( + + self.scope = Scope( shell_id=task.shell_id, process_id=task.process_id, task_id=task.task_id, parent_task_id=task.parent, ) + self.logger = wrap_logger( + self._container.force_fetch(LoggerItf), + dict(scope=self.scope.model_dump(exclude_defaults=True)), + ) + self._pool = self._container.force_fetch(Pool) self._is_background = is_background self._ctx: Optional[Context] = None - self._locker = task_locker + self._task_locker = task_locker self._tasks = container.force_fetch(GoTasks) self._threads = container.force_fetch(GoThreads) self._eventbus = container.force_fetch(EventBus) @@ -99,50 +105,58 @@ def container(self) -> Container: self._validate_closed() return self._container - def task(self) -> GoTaskStruct: + def get_task(self) -> GoTaskStruct: self._validate_closed() - return self._tasks.get_task(self._scope.task_id) + return self._tasks.get_task(self.scope.task_id) - def thread(self, truncated: bool = False) -> GoThreadInfo: + def get_thread(self, truncated: bool = False) -> GoThreadInfo: self._validate_closed() - task = self.task() + task = self.get_task() if not truncated: thread_id = task.thread_id return self._threads.get_thread(thread_id, create=True) - session = self._create_session(task, self._locker, None) + session = self._create_session(task, None) return session.get_truncated_thread() def update_thread(self, thread: GoThreadInfo) -> None: self._validate_closed() - task = self.task() + task = self.get_task() thread.id = task.thread_id self._threads.save_thread(thread) def get_ghost(self) -> Ghost: self._validate_closed() - task = self.task() + task = self.get_task() return get_entity(task.meta, Ghost) def get_context(self) -> Optional[Context]: self._validate_closed() - task = self.task() + task = self.get_task() if task.context is None: return None return get_entity(task.context, Context) + def get_functions(self) -> List[LLMFunc]: + self._validate_closed() + session = self._create_session(self.get_task(), None) + return self.get_ghost_driver().functions(session) + def get_instructions(self) -> str: self._validate_closed() - session = self._create_session(self.task(), self._locker, None) + session = self._create_session(self.get_task(), None) try: instructions = session.get_instructions() return instructions finally: session.destroy() + def refresh(self) -> bool: + return self._task_locker.refresh() + def get_artifact(self) -> Tuple[Union[Ghost.ArtifactType, None], TaskState]: self._validate_closed() - task = self.task() - session = self._create_session(task, self._locker, None) + task = self.get_task() + session = self._create_session(task, None) with session: return session.get_artifact(), TaskState(session.task.state) @@ -171,7 +185,7 @@ def respond( context_meta = to_entity_meta(self._ctx) self._ctx = None event = EventTypes.INPUT.new( - task_id=self._scope.task_id, + task_id=self.scope.task_id, messages=messages, context=context_meta, ) @@ -187,7 +201,7 @@ def respond_event( raise RuntimeError("conversation is handling event") # complete task_id if not event.task_id: - event.task_id = self._scope.task_id + event.task_id = self.scope.task_id self.logger.debug("start to respond event %s", event.event_id) stream, retriever = new_basic_connection( @@ -203,6 +217,7 @@ def respond_event( return retriever def _validate_closed(self): + # todo: change error to defined error if self._closed: raise RuntimeError(f"Conversation is closed") if self._shell_closed(): @@ -215,7 +230,7 @@ def _submit_session_event(self, event: Event, stream: Stream) -> None: try: with stream: task = self._tasks.get_task(event.task_id) - session = self._create_session(task, self._locker, stream) + session = self._create_session(task, stream) self.logger.debug( f"create session from event id %s, task_id is %s", event.event_id, task.task_id, @@ -234,21 +249,25 @@ def _submit_session_event(self, event: Event, stream: Stream) -> None: def _create_session( self, task: GoTaskStruct, - locker: TaskLocker, stream: Optional[Stream], ) -> SessionImpl: return SessionImpl( container=self.container(), + logger=self.logger, + scope=self.scope, stream=stream, task=task, - locker=locker, + refresh_callback=self.refresh, + alive_check=self.is_alive, max_errors=self._conf.max_task_errors, ) def pop_event(self) -> Optional[Event]: - return self._eventbus.pop_task_event(self._scope.task_id) + self._validate_closed() + return self._eventbus.pop_task_event(self.scope.task_id) def send_event(self, event: Event) -> None: + self._validate_closed() task = self._tasks.get_task(event.task_id) notify = True if task: @@ -285,15 +304,18 @@ def close(self): self._handling_event = False if self._submit_session_thread: self._submit_session_thread = None - self._locker.release() + self._task_locker.release() self.logger.info("conversation %s is destroying", self.task_id) self._container.shutdown() self._container = None - def closed(self) -> bool: + def is_closed(self) -> bool: return self._closed + def is_alive(self) -> bool: + return not self._closed + def available(self) -> bool: - if self.closed() or self._shell_closed() or self._handling_event: + if self.is_closed() or self._shell_closed() or self._handling_event: return False return True diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index b3fa0744..305c3d1e 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Iterable, Tuple, TypeVar, Dict, Union, Any +from typing import Optional, List, Iterable, Tuple, TypeVar, Dict, Union, Any, Callable from ghostos.errors import StreamingError, SessionError from ghostos.abcd import ( Session, Ghost, GhostDriver, Shell, Scope, Taskflow, Operator, Subtasks, @@ -49,26 +49,25 @@ class SessionImpl(Session[Ghost]): def __init__( self, container: Container, + logger: LoggerItf, + scope: Scope, stream: Optional[Stream], task: GoTaskStruct, - locker: TaskLocker, + refresh_callback: Callable[[], bool], + alive_check: Callable[[], bool], max_errors: int, ): # session level container self.container = Container(parent=container, name="session") self.upstream = stream self.task = task - self.logger = self.container.force_fetch(LoggerItf) - self._task_locker = locker + self.logger = logger + self._refresh_callback = refresh_callback + self._alive_check = alive_check threads = container.force_fetch(GoThreads) thread = threads.get_thread(task.thread_id, create=True) self.thread = thread - self.scope = Scope( - shell_id=task.shell_id, - process_id=task.process_id, - task_id=task.task_id, - parent_task_id=task.parent, - ) + self.scope = scope self.ghost: G = get_entity(self.task.meta, Ghost) self.ghost_driver: GhostDriver[G] = get_ghost_driver(self.ghost) @@ -127,7 +126,7 @@ def unmarshal_state(task: GoTaskStruct) -> Dict[str, EntityType]: def alive(self) -> bool: if self._failed or self._destroyed: return False - return self._task_locker.acquired() and (self.upstream is None or self.upstream.alive()) + return self._alive_check() and self._refresh_callback() and (self.upstream is None or self.upstream.alive()) def _validate_alive(self): if not self.alive(): @@ -223,7 +222,7 @@ def get_instructions(self) -> str: def refresh(self) -> bool: if self._failed or self._destroyed or not self.alive(): return False - if self._task_locker.refresh(): + if self._refresh_callback(): self._saved = False return True return False diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index c677d2aa..1ed00022 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -157,7 +157,7 @@ def _join_conversation(self, conversation: Conversation): running = [] # remove closed ones for c in exists: - if c.closed(): + if c.is_closed(): continue running.append(c) running.append(conversation) @@ -188,7 +188,7 @@ def send_message(receiver: Receiver): send_message(r) while timeleft.alive(): - task = conversation.task() + task = conversation.get_task() if task.is_dead(): break e = conversation.pop_event() @@ -328,7 +328,7 @@ def close(self): len(self._conversations) ) for conversation in self._conversations: - if conversation.closed(): + if conversation.is_closed(): continue self.logger.info("closing shell conversation %s", conversation.task_id) conversation.close() diff --git a/ghostos/framework/messengers/defaults.py b/ghostos/framework/messengers/defaults.py index e234ad4e..6d5d2314 100644 --- a/ghostos/framework/messengers/defaults.py +++ b/ghostos/framework/messengers/defaults.py @@ -46,7 +46,7 @@ def flush(self) -> Tuple[List[Message], List[Caller]]: messages.append(message) if message.type == MessageType.FUNCTION_CALL: callers.append(Caller( - id=message.ref_id, + id=message.call_id, name=message.name, arguments=message.content, )) diff --git a/ghostos/ghosts/chatbot/simplest.py b/ghostos/ghosts/chatbot/simplest.py index 458826ea..6d48ed2f 100644 --- a/ghostos/ghosts/chatbot/simplest.py +++ b/ghostos/ghosts/chatbot/simplest.py @@ -1,11 +1,11 @@ -from typing import Union, Iterable, ClassVar +from typing import Union, Iterable, ClassVar, List from ghostos.abcd import Agent, GhostDriver, Session, Operator from ghostos.abcd.thoughts import LLMThought, Thought from ghostos.container import Provider from ghostos.core.runtime import Event, GoThreadInfo from ghostos.core.messages import Role -from ghostos.core.llms import Prompt +from ghostos.core.llms import Prompt, LLMFunc from ghostos.entity import ModelEntity from ghostos.prompter import TextPrmt, Prompter from ghostos.identifier import Identifier @@ -43,6 +43,9 @@ def get_artifact(self, session: Session) -> None: def get_instructions(self, session: Session) -> str: return self.get_system_prompter().get_prompt(session.container) + def functions(self, session: Session) -> List[LLMFunc]: + return [] + def providers(self) -> Iterable[Provider]: return [] diff --git a/ghostos/ghosts/moss_agent/agent.py b/ghostos/ghosts/moss_agent/agent.py index 6838d21f..4a484b2d 100644 --- a/ghostos/ghosts/moss_agent/agent.py +++ b/ghostos/ghosts/moss_agent/agent.py @@ -112,6 +112,17 @@ def get_instructions(self, session: Session) -> str: with rtm: return self._get_instructions(session, rtm) + def functions(self, session: Session) -> List[LLMFunc]: + compiler = self._get_moss_compiler(session) + result = [] + with compiler: + runtime = compiler.compile(self.ghost.moss_module) + actions = self.get_actions(session, runtime) + for action in actions: + if fn := action.as_function(): + result.append(fn) + return result + def thought(self, session: Session, runtime: MossRuntime) -> Thought: from .for_meta_ai import __moss_agent_thought__ as fn compiled = runtime.module() @@ -310,14 +321,19 @@ def __init__(self, runtime: MossRuntime): def name(self) -> str: return self.Argument.name - def update_prompt(self, prompt: Prompt) -> Prompt: + def as_function(self) -> Optional[LLMFunc]: parameters = self.Argument.model_json_schema() llm_func = LLMFunc( name=self.name(), description=MOSS_FUNCTION_DESC, parameters=parameters, ) - prompt.functions.append(llm_func) + return llm_func + + def update_prompt(self, prompt: Prompt) -> Prompt: + llm_func = self.as_function() + if llm_func is not None: + prompt.functions.append(llm_func) return prompt def run(self, session: Session, caller: Caller) -> Union[Operator, None]: diff --git a/ghostos/prototypes/realtime/abcd.py b/ghostos/prototypes/realtime/abcd.py index aad45008..e903c160 100644 --- a/ghostos/prototypes/realtime/abcd.py +++ b/ghostos/prototypes/realtime/abcd.py @@ -148,7 +148,7 @@ def flush(self) -> bytes: pass -class Operator(Protocol): +class RealtimeOP(Protocol): name: str description: str @@ -211,21 +211,23 @@ def is_closed(self) -> bool: pass @abstractmethod - def messages(self) -> List[Message]: + def messages(self) -> Iterable[Message]: + """ + return history messages. + """ pass @abstractmethod - def state(self) -> Tuple[List[Operator], bool]: + def state(self) -> Tuple[str, List[str]]: """ - :return: (operators, is_outputting) + :return: (operators, operators) """ pass @abstractmethod - def operate(self, operator: Operator) -> Tuple[bool, Optional[str]]: + def operate(self, operator: str) -> bool: """ - :param operator: - :return: (allowed, [error message]) + run operator. """ pass @@ -254,3 +256,50 @@ def __exit__(self, exc_type, exc_val, exc_tb): intercepted = self.fail(exc_val) self.close() return intercepted + + +def example(app: RealtimeApp): + with app: + while True: + state, ops = app.state() + print(state, ops) + outputting = app.output() + if outputting is None: + time.sleep(0.1) + continue + while outputting is not None: + print(outputting.head()) + chunks = outputting.chunks() + for c in chunks: + print(c) + print(outputting.tail()) + + +def streamlit_example(app: RealtimeApp): + import streamlit as st + with app: + for message in app.messages(): + with st.container(): + st.write(message) + + while True: + with st.empty(): + rendered = False + while not rendered: + state, operators = app.state() + with st.container(): + if operators: + for op in operators: + st.write(op) + with st.status(state): + buffer = app.output() + if buffer is None: + continue + rendered = buffer + if rendered is None: + time.sleep(0.1) + else: + break + with st.container(): + st.write(buffer.tail()) + break diff --git a/ghostos/prototypes/realtime/openai/app.py b/ghostos/prototypes/realtime/openai/app.py index 2ecca103..6e11dbcb 100644 --- a/ghostos/prototypes/realtime/openai/app.py +++ b/ghostos/prototypes/realtime/openai/app.py @@ -10,7 +10,7 @@ from ghostos.prototypes.realtime.abcd import ( RealtimeApp, Listener, Speaker, - RealtimeOperator, + RealtimeOP, ) from collections import deque from ghostos.container import Container @@ -23,14 +23,15 @@ # from queue import Queue # from .protocols import StateName, ServerEventType from .configs import OpenAIRealtimeConf -from .state_of_client import OpenAIRealtimeContext +from .context import Context +from .state_of_client import StateOfClient +from .state_of_server import StateOfServer from .ws import OpenAIWSConnection # from .broadcast import SimpleBroadcaster, Broadcaster - class OpenAIRealtimeApp(RealtimeApp): def __init__( @@ -42,17 +43,15 @@ def __init__( proxy: Optional[Callable] = None, ): self._conf = conf - self._state: State = State() + self._state: StateOfClient = StateOfClient() self._conversation = conversation self._proxy = proxy self._logger = conversation.logger self._pool = DefaultPool(10) - self._ctx: OpenAIRealtimeContext = OpenAIRealtimeContext( - conversation=conversation, - connection=None, - logger=self._logger, - ) - self._operators: deque[RealtimeOperator] = deque() + self._ctx: Context = "" + self._server_state: StateOfServer = StateOfServer() + self._client_state: StateOfClient = StateOfClient() + self._operators: deque[RealtimeOP] = deque() self._listener: Listener = listener self._speaker: Speaker = speaker # status. @@ -88,52 +87,39 @@ def fail(self, error: Exception) -> bool: def get_state_desc(self) -> Tuple[str, bool]: return self._state.name(), self._state.is_outputting() - def operate(self, op: RealtimeOperator) -> Tuple[bool, Optional[str]]: + def operate(self, op: RealtimeOP) -> Tuple[bool, Optional[str]]: ok, error = self._state.allow(op) if ok: self._operators.append(op) return ok, error def output(self) -> Optional[ReceiverBuffer]: - outputting_id = self._ctx.outputting_id - if outputting_id is None: - return None - iterator = self._output_messages(outputting_id) + iterator = self._output_messages() if iterator is None: return None return ReceiverBuffer.new(iterator) - def messages(self) -> List[Message]: - messages = [] - thread = self._ctx.conversation.thread(truncated=True) - for message in thread.get_history_messages(truncated=True): - messages.append(message) - for message in self._ctx.buffer: - messages.append(message) - return messages + def messages(self) -> Iterable[Message]: + # clear unsync messages. + self._ctx.sync_ghostos_conversation() + return self._ctx.thread.get_messages(truncated=True) - def _output_messages(self, outputting_id: int) -> Optional[Iterable[Message]]: - if outputting_id not in self._ctx.outputting_chunks: + def _output_messages(self) -> Optional[Iterable[Message]]: + response_id = self._ctx.response_id + if response_id is None: return None - sending = 0 - while not self._ctx.is_closed(): - chunks = self._ctx.outputting_chunks[outputting_id] - chunks_length = len(chunks) - if chunks_length > sending: - yield chunks[sending] - sending += 1 - continue - elif chunks_length == sending: - if outputting_id in self._ctx.outputting_completed: - yield self._ctx.outputting_completed[outputting_id] - break + msg_id = None + while response_id == self._ctx.response_id and not self.is_closed(): + if self._ctx.response_queue is None: + return None - time.sleep(0.1) - continue + if msg_id and msg_id in self._ctx.buffer_messages: + break + self._ctx. @staticmethod def _destroy_state(state: State) -> None: - state.destroy() + state._destroy() def _main_state_loop(self): while not self._ctx.is_closed(): @@ -142,7 +128,7 @@ def _main_state_loop(self): op = self._operators.popleft() next_state = state.handle(op) if next_state is not None: - self._pool.submit(state.destroy) + self._pool.submit(state._destroy) state = next_state # clear all self._operators.clear() @@ -150,7 +136,7 @@ def _main_state_loop(self): next_state = state.run_frame() if next_state is not None: - self._pool.submit(state.destroy) + self._pool.submit(state._destroy) self._state = next_state def _listening_thread(self): diff --git a/ghostos/prototypes/realtime/openai/client.py b/ghostos/prototypes/realtime/openai/client.py new file mode 100644 index 00000000..5d49f69e --- /dev/null +++ b/ghostos/prototypes/realtime/openai/client.py @@ -0,0 +1,339 @@ +from typing import Dict, Optional, List, Set, Self, Union +from queue import Queue +from ghostos.abcd import Conversation +from ghostos.contracts.logger import LoggerItf +from ghostos.contracts.assets import AudioAssets, FileInfo +from ghostos.core.messages import Message, MessageType +from ghostos.core.runtime import GoThreadInfo, Event as GhostOSEvent, EventTypes as GhostOSEventTypes +from .configs import OpenAIRealtimeConf +from .ws import OpenAIWSConnection +from .event_data_objects import MessageItem, SessionObject +from .event_from_server import ServerSessionCreated +from .event_from_client import ( + SessionUpdate, + ClientEvent, + ConversationItemCreate, + ResponseCancel, + ResponseCreate, + InputAudioBufferCommit, + InputAudioBufferClear, +) +from .state_of_server import ServerContext, SessionState +from .state_of_client import Client + + +class Context(ServerContext): + + def __init__( + self, + conversation: Conversation, + logger: Optional[LoggerItf] = None, + ): + self.conversation: Conversation = conversation + self.thread = conversation.get_thread(truncated=True) + self.history_messages: Dict[str, Message] = {} + self.history_message_order: List[str] = [] + self._reset_history_messages() + + if logger is None: + logger = conversation.logger + self.logger = logger + + self.buffer_message_ids: List[str] = [] + self.buffer_messages: Dict[str, Message] = {} + self.error_messages: List[Message] = [] + """ errors """ + + # status. + self.unsent_message_ids: List[str] = [] + self.sent_message_ids: Set[str] = set() + self.response_id: Optional[str] = None + self.response_queue: Optional[Queue] = None + self.speaking_queue: Optional[Queue] = None + self.listening: bool = False + + def _reset_history_messages(self): + self.history_messages: Dict[str, Message] = {} + self.history_message_order: List[str] = [] + for message in self.thread.get_messages(True): + self.history_messages[message.msg_id] = message + self.history_message_order.append(message.msg_id) + + def _reset_buffer_messages(self): + self.buffer_message_ids = [] + self.buffer_messages: Dict[str, Message] = {} + self.unsent_message_ids = [] + self.sent_message_ids = set() + + def update_local_conversation(self) -> None: + self.stop_response(self.response_id) + self.listening = False + + buffered = [] + function_call = False + for msg_id in self.buffer_message_ids: + message = self.buffer_messages[msg_id] + if not message.is_complete(): + continue + buffered.append(message) + if message.type == MessageType.FUNCTION_CALL: + function_call = True + self._reset_buffer_messages() + if not buffered: + return + + event_type = GhostOSEventTypes.ACTION_CALL if function_call else GhostOSEventTypes.NOTIFY + event = event_type.new( + task_id=self.conversation.task_id, + messages=buffered + ) + self.thread.new_turn(event) + self.conversation.update_thread(self.thread) + self.thread = self.conversation.get_thread(True) + self._reset_history_messages() + + def send_response_chunk(self, response_id: str, chunk: Optional[Message]) -> bool: + if chunk is None: + return False + if response_id != self.response_id: + return False + if self.response_queue is not None: + self.response_queue.put(chunk) + return True + return False + + def send_error_message(self, error: str) -> None: + message = MessageType.ERROR.new(content=error) + self.error_messages.append(message) + + def save_audio_data(self, item: MessageItem) -> None: + if not item.has_audio(): + return + audio_data = item.get_audio_bytes() + if not audio_data: + return + asserts = self.conversation.container().force_fetch(AudioAssets) + fileinfo = asserts.get_fileinfo(item.id) + if fileinfo is None: + fileinfo = asserts.new_fileinfo( + fileid=item.id, + filename=f"{item.id}.wav", + ) + asserts.save(fileinfo, audio_data) + + def update_history_message(self, message: Optional[Message]) -> None: + if message is None: + return + if not message.is_complete(): + return + + if message.msg_id in self.history_messages: + self.history_messages[message.msg_id] = message + self.thread.update_message(message) + else: + if message.msg_id not in self.buffer_messages: + self.buffer_message_ids.append(message.msg_id) + + self.buffer_messages[message.msg_id] = message + if message.msg_id not in self.sent_message_ids: + self.unsent_message_ids.append(message.msg_id) + + def add_message_item(self, item: MessageItem, previous_item_id: str) -> None: + if item is None: + return + message = item.to_complete_message() + self.update_history_message(message) + if previous_item_id: + self_item_id = message.msg_id + new_buffer_ids = [] + inserted = False + for buffer_id in self.buffer_message_ids: + if buffer_id == self_item_id: + continue + else: + new_buffer_ids.append(buffer_id) + + if buffer_id == previous_item_id: + new_buffer_ids.append(self_item_id) + inserted = True + if not inserted: + new_buffer_ids.append(self_item_id) + self.buffer_message_ids = new_buffer_ids + + def start_response(self, response_id: str) -> None: + if response_id == self.response_id: + return + if self.response_queue is not None: + queue = self.response_queue + queue.put_nowait(None) + + self.response_id = response_id + self.response_queue = Queue() + self.speaking_queue = Queue() + + def is_responding(self) -> bool: + return self.response_id is not None + + def stop_response(self, response_id: str) -> bool: + if response_id == self.response_id: + self.response_id = None + if self.response_queue is not None: + self.response_queue.put(None) + if self.speaking_queue is not None: + self.speaking_queue.put(None) + self.response_queue = None + self.speaking_queue = None + return True + return False + + def send_speaking_audio_chunk(self, response_id: str, data: bytes) -> bool: + if response_id == self.response_id and self.response_id is not None: + if self.speaking_queue is not None: + self.speaking_queue.put_nowait(data) + return True + else: + self.logger.error("speaking audio chunk but queue is not exists") + self.logger.debug( + "speaking audio chunk of response id %s is not current response %s", + response_id, self.response_id, + ) + return False + + +class AppClient(Client): + + def __init__( + self, + conf: OpenAIRealtimeConf, + conversation: Conversation, + ): + self.conf: OpenAIRealtimeConf = conf + self.conversation: Conversation = conversation + self.logger: LoggerItf = conversation.logger + self.ctx = Context(conversation=conversation) + self.connection: OpenAIWSConnection = self.connect() + self.session_state: SessionState = self._create_session_state() + self.synchronized: bool = False + + def connect(self) -> OpenAIWSConnection: + return OpenAIWSConnection( + self.conf.ws_conf, + logger=self.logger, + ) + + def reconnect(self) -> None: + if self.connection is not None: + connection = self.connection + connection.close() + if self.session_state is not None: + self.session_state.destroy() + + self.connection: OpenAIWSConnection = self.connect() + self.session_state: SessionState = self._create_session_state() + self.synchronized = False + + def _create_session_state(self) -> SessionState: + ce = SessionUpdate( + session=self.get_session_obj(), + ) + self._send_client_event(ce) + e = self.connection.recv(timeout=self.conf.session_created_timeout, timeout_error=True) + se = ServerSessionCreated(**e) + return SessionState(self.ctx, se) + + def synchronize_server_session(self): + if self.synchronized: + return + previous_item_id = "" + count = 0 + for msg_id in self.ctx.history_message_order: + message = self.ctx.history_messages[msg_id] + self._send_message_to_server(message, previous_item_id) + previous_item_id = message.msg_id + self.logger.debug("Synchronizing server session with item %s", msg_id) + count += 1 + self.logger.info("Synchronizing server session done with item %d", count) + + def update_local_conversation(self) -> None: + # todo: function calling + self.ctx.update_local_conversation() + + def cancel_responding(self) -> bool: + if self.ctx.is_responding(): + ce = ResponseCancel() + self._send_client_event(ce) + self.ctx.stop_response(self.ctx.response_id) + return True + return False + + def start_listening(self) -> bool: + if not self.ctx.listening: + self.ctx.listening = True + self.ctx.stop_response(self.ctx.response_id) + return True + return False + + def stop_listening(self) -> bool: + if self.ctx.listening: + self.ctx.listening = False + return True + return False + + def is_listening(self) -> bool: + return self.ctx.listening + + def commit_audio_input(self) -> bool: + if self.ctx.listening: + self.ctx.listening = False + ce = InputAudioBufferCommit() + self._send_client_event(ce) + return True + return False + + def clear_audio_input(self) -> bool: + if self.ctx.listening: + self.ctx.listening = False + ce = InputAudioBufferClear() + self._send_client_event(ce) + return True + return False + + def get_session_obj(self) -> SessionObject: + session_obj = self.conf.session + session_obj.instructions = self.conversation.get_instructions() + tools = [] + for fn in self.conversation.get_functions(): + tools.append(fn.to_dict()) + session_obj.tools = tools + return session_obj + + def create_response(self) -> bool: + session_obj = self.get_session_obj() + ce = ResponseCreate( + response=session_obj + ) + self._send_client_event(ce) + return True + + def is_responding(self) -> bool: + return self.ctx.is_responding() + + def receive_server_event(self) -> bool: + data = self.connection.recv(timeout=None) + if data: + self.session_state.recv(data) + return True + return False + + def _send_message_to_server(self, message: Message, previous_item_id: str = "") -> None: + ce = ConversationItemCreate( + previous_item_id=previous_item_id, + item=MessageItem.from_message(message), + ) + self._send_client_event(ce) + + def _send_client_event(self, event: ClientEvent): + self.connection.send(event.to_dict()) + + def send_error_message(self, error: str) -> None: + self.ctx.send_error_message(error) diff --git a/ghostos/prototypes/realtime/openai/configs.py b/ghostos/prototypes/realtime/openai/configs.py index 38890a1a..b56b2b29 100644 --- a/ghostos/prototypes/realtime/openai/configs.py +++ b/ghostos/prototypes/realtime/openai/configs.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Literal from pydantic import BaseModel, Field from .ws import OpenAIWebsocketsConf from .event_data_objects import SessionObject @@ -19,5 +19,6 @@ class OpenAIRealtimeConf(BaseModel): default=None, description="basic session settings, if None, use openai default session", ) + start_mode: Literal["listening", "idle"] = Field("idle") - + session_created_timeout: int = Field(10) diff --git a/ghostos/prototypes/realtime/openai/context.py b/ghostos/prototypes/realtime/openai/context.py index 05118967..4d3b65f0 100644 --- a/ghostos/prototypes/realtime/openai/context.py +++ b/ghostos/prototypes/realtime/openai/context.py @@ -1,30 +1,229 @@ from __future__ import annotations -from .event_from_server import * +from typing import Tuple, List, Dict, Optional, Iterable +from abc import ABC, abstractmethod from .ws import OpenAIWSConnection from .configs import SessionObject, OpenAIRealtimeConf -from ghostos.core.messages import Message +from .event_data_objects import MessageItem +from ghostos.core.messages import Message, MessageType, Role +from ghostos.core.runtime.events import EventTypes, Event from ghostos.abcd import Conversation, GoThreadInfo -from queue import Queue +from ghostos.contracts.logger import get_logger, LoggerItf +from ghostos.contracts.assets import AudioAssets, FileInfo +from queue import Queue, Empty +from enum import Enum +from .state_of_server import ServerContext -class Context(Protocol): +class RealtimeAppStage(str, Enum): + CREATED = "created" + RESPONDING = "responding" + + +class Context(ServerContext): + """ + realtime app context + """ + conf: OpenAIRealtimeConf - session_obj: SessionObject - connection: OpenAIWSConnection conversation: Conversation + listening: bool thread: GoThreadInfo - messages: List[Message] + logger: LoggerItf + connection: Optional[OpenAIWSConnection] + audio_format: str = "wav" + response_id: Optional[str] + + stage: str + status_desc: str + + def __init__( + self, + conf: OpenAIRealtimeConf, + conversation: Conversation, + ): + self.stage = RealtimeAppStage.CREATED + self.status_desc = "" + self._closed: bool = False + + self.conf = conf + self.conversation = conversation + # sync instructions + # todo: realtime logger + self.logger = get_logger("OpenAIRealtime", conversation.scope) + self.thread = conversation.get_thread(truncated=True) + # todo sync + self._history_message_ids: set[str] = set( + item.msg_id for item in self.thread.get_messages(True) + ) + + self.messages_order: List[str] = [] + self.buffer_messages: Dict[str, Message] = {} + + self.connection: Optional[OpenAIWSConnection] = None + self.error_messages: List[Message] = [] + + self.listening: bool = False + """if the client side shall listen """ + + # when realtime server is speaking, the audio bytes shall send through the speaking_queue + self.speaking: bool = False + self.speaking_queue: Optional[Queue] = None + + self.response_id: Optional[str] = None + self.response_queue: Optional[Queue] = None + + def get_session_obj(self) -> SessionObject: + session_obj = self.conf.session + session_obj.instructions = self.conversation.get_instructions() + tools = [] + for fn in self.conversation.get_functions(): + tools.append(fn.to_dict()) + session_obj.tools = tools + return session_obj + + def send_error_message(self, error: str) -> None: + pass + + def update_history_message(self, message: Message) -> None: + pass + + def stop_response(self, response_id: str) -> None: + pass + + def send_speaking_audio_chunk(self, data: bytes) -> None: + pass + + def change_stage(self, stage: str, desc: str): + self.stage = stage + self.status_desc = desc + + def is_closed(self) -> bool: + return self.conversation.is_closed() + + def send_err_message(self, error: str): + message = MessageType.ERROR.new(content=error, role=Role.SYSTEM.value) + self.error_messages.append(message) + + def update_complete_message_item(self, item: MessageItem, update: bool = True): + if item.status == "incomplete": + # incomplete item do not update yet. + return + # save audio file + if item.type == "message" and item.has_audio(): + audio_assets = self.conversation.container().force_fetch(AudioAssets) + if update or not audio_assets.has_binary(item.id): + audio_data = item.get_audio_bytes() + file_info = FileInfo( + fileid=item.id, + filename=f"{item.id}.{self.audio_format}", + filetype=f"audio/{self.audio_format}", + ) + audio_assets.save(file_info, audio_data) + + message = item.to_complete_message() + self.update_message(message, update) + + def update_message(self, message: Message, update: bool = True): + if message is None: + return + """ + update the ghostos message to current session. + """ + if message.msg_id in self._history_message_ids: + if update: + self.thread.update_message(message) + return + + if message.msg_id not in self.buffer_messages: + self.messages_order.append(message.msg_id) + if update or message.msg_id not in self.buffer_messages: + self.buffer_messages[message.msg_id] = message + + if message.type == MessageType.FUNCTION_CALL: + # 如果是 function call, 需要立刻运行一次同步流程. + self.sync_ghostos_conversation() + + def sync_ghostos_conversation(self): + # 1. 检查messages 是否有 function call + # 1.1 如果 function call 存在, 需要调用 conversation 运行一次 function call + # 1.2 如果 function call 不存在, 则仅仅更新 thread. + if len(self.buffer_messages) == 0: + return + has_call = False + messages = [] + for msg_id in self.messages_order: + if msg_id in self._history_messages: + # history message exists. + continue + if msg_id not in self.buffer_messages: + self.logger.error(f"Message {msg_id} not in buffered messages") + continue + message = self.buffer_messages[msg_id] + messages.append(message) + if message.type == MessageType.FUNCTION_CALL: + has_call = True + + event_type = EventTypes.ACTION_CALL if has_call else EventTypes.NOTIFY + event = event_type.new( + task_id=self.conversation.task_id, + messages=messages, + ) + if has_call: + r = self.conversation.respond_event(event) + with r: + for chunk in r.recv(): + # output the new items. + self.send_response_chunk(chunk) + self.thread = self.conversation.get_thread(truncated=True) + else: + self.thread.new_turn(event) + self.conversation.update_thread(self.thread) + self.thread = self.conversation.get_thread(truncated=True) + + self.reset_history_messages() + + def reset_history_messages(self): + # reset messages + self.messages_order = [] + self.buffer_messages = {} + for message in self.thread.get_history_messages(truncated=True): + if message.msg_id not in self._history_messages: + self._history_messages_order.append(message) + self._history_messages[message.msg_id] = message + + def start_response(self, response_id: Optional[str]): + if response_id is None: + self.response_id = None + elif response_id == self.response_id: + return None + + if self.response_queue is not None: + self.response_queue.put(None) + self.response_queue = None + if self.speaking_queue is not None: + self.speaking_queue.put(None) + self.speaking_queue = None + + # update speaking + if response_id: + self.response_queue = Queue() + self.speaking = True + self.speaking_queue = Queue() - receiving_server_event: bool = False - is_outputting: bool = False + def cancel_response(self): + self.start_response(None) - listening: bool = False - """if the client side shall listen """ + def send_response_chunk(self, chunk: Message): + if self.response_queue is None: + self.logger.error(f"no response queue for {chunk.msg_id}") + return + self.response_queue.put(chunk) - # when realtime server is speaking, the audio bytes shall send through the speaking_queue - speaking: bool = False - speaking_queue: Optional[Queue] = None + def close(self): + if self._closed: + return + self._closed = True + # todo - outputting_id: Optional[str] = None - outputting_chunks: Dict[str, List[Message]] - outputting_completes: Dict[str, List[Message]] + def __del__(self): + pass diff --git a/ghostos/prototypes/realtime/openai/event_data_objects.py b/ghostos/prototypes/realtime/openai/event_data_objects.py index b0920486..9b242835 100644 --- a/ghostos/prototypes/realtime/openai/event_data_objects.py +++ b/ghostos/prototypes/realtime/openai/event_data_objects.py @@ -1,12 +1,21 @@ +from __future__ import annotations + +import base64 + from pydantic import BaseModel, Field from typing import Optional, Literal, List, Dict, Union +from io import BytesIO +from ghostos.core.messages import ( + MessageType, Message, AudioMessage, FunctionCallMessage, FunctionCallOutputMessage, + Caller, Role, +) class RateLimit(BaseModel): - name: str = Field() - limit: int = Field() - remaining: int = Field() - reset_seconds: float = Field() + name: str = Field("", description="Name of the rate-limited event.", enum={"requests", "tokens"}) + limit: int = Field(0, description="The maximum allowed value for the rate limit.") + remaining: int = Field(0, description="The remaining value before the limit is reached.") + reset_seconds: float = Field(0.0, description="Seconds until the rate limit resets.") class TokensDetails(BaseModel): @@ -39,9 +48,9 @@ class ResponseStatusDetails(BaseModel): class Response(BaseModel): id: str = Field() object: str = Field("realtime.response") - status: Literal["completed", "cancelled", "failed", "incomplete"] = Field() + status: Literal["completed", "cancelled", "failed", "incomplete", "in_progress"] = Field() status_details: Optional[ResponseStatusDetails] = Field(None) - output: List[Dict] = Field(default_factory=list) + output: List[MessageItem] = Field(default_factory=list) usage: Optional[Usage] = Field(None) @@ -52,7 +61,7 @@ class Content(BaseModel): message items of role user support input_text and input_audio content, and message items of role assistant support text content. """ - type: Literal["input_text", "input_audio", "text"] = Field() + type: Literal["input_text", "input_audio", "text", "audio"] = Field() text: str = Field("") audio: str = Field("") transcript: str = Field("") @@ -66,11 +75,160 @@ class MessageItem(BaseModel): type: Literal["message", "function_call", "function_call_output"] = Field("") status: Optional[str] = Field(None, enum={"completed", "incomplete"}) role: Optional[str] = Field(None, enum={"assistant", "user", "system"}) + content: Optional[List[Content]] = Field(default_factory=None) call_id: Optional[str] = Field(None) name: Optional[str] = Field(None, description="The name of the function being called (for function_call items).") arguments: Optional[str] = Field(None, description="The arguments of the function call (for function_call items).") output: Optional[str] = Field(None, description="The output of the function call (for function_call_output items).") + @classmethod + def from_message(cls, message: Message) -> Optional[MessageItem]: + id_ = message.msg_id + call_id = None + output = None + arguments = None + content = None + role = message.role + if message.type == MessageType.FUNCTION_CALL.value: + type_ = "function_call" + call_id = message.call_id + arguments = message.content + elif message.type == MessageType.FUNCTION_OUTPUT.value: + type_ = "function_call_output" + call_id = message.call_id + output = message.content + else: + if not message.content: + return None + + type_ = "message" + if role == Role.ASSISTANT.value: + content_type = "text" + elif role == Role.USER.value: + content_type = "input_text" + elif role == Role.SYSTEM.value: + content_type = "input_text" + else: + content_type = "input_text" + + content = [ + Content(type=content_type, text=message.content), + ] + return cls( + id=id_, + type=type_, + content=content, + arguments=arguments, + call_id=call_id, + output=output, + ) + + @classmethod + def from_audio_message(cls, message: Message) -> MessageItem: + pass + + def has_audio(self) -> bool: + if len(self.content) > 0: + for c in self.content: + if c.type == "input_audio": + return True + return False + + def get_audio_bytes(self) -> bytes: + buffer = BytesIO() + for c in self.content: + if c.audio: + data = base64.b64decode(c.audio) + buffer.write(data) + return buffer.getvalue() + + def to_message_head(self) -> Optional[Message]: + + if self.type == "function_call_output": + return Message.new_head( + typ_=MessageType.FUNCTION_OUTPUT.value, + role=self.role, + content=self.output, + msg_id=self.id, + call_id=self.call_id, + ) + elif self.type == "function_call": + return Message.new_head( + typ_=MessageType.FUNCTION_CALL.value, + role=self.role, + name=self.name, + call_id=self.call_id, + content=self.arguments, + ) + elif self.type == "message": + content = "" + for c in self.content: + if c.text: + content += c.text + elif c.transcript: + content += c.transcript + if not content: + return None + + typ_ = MessageType.DEFAULT.value + if self.role == Role.ASSISTANT.value: + typ_ = MessageType.AUDIO.value + + return Message.new_head( + typ_=typ_, + role=self.role, + content=content, + ) + + else: + return None + + def to_complete_message(self) -> Optional[Message]: + if self.status == "incomplete": + return None + if self.type == "function_call_output": + return FunctionCallOutputMessage( + msg_id=self.id, + name=self.name, + call_id=self.call_id, + content=self.output, + ) + elif self.type == "function_call": + return FunctionCallMessage( + msg_id=self.id, + role=self.role or "", + caller=Caller( + id=self.call_id, + name=self.name, + arguments=self.arguments, + ) + ) + elif self.type == "message": + parsed_type = MessageType.TEXT + if self.role == Role.ASSISTANT.value or self.has_audio(): + parsed_type = MessageType.AUDIO + + parsed_content = "" + for c in self.content: + if c.text: + parsed_content = parsed_content + c.text + elif c.transcript: + parsed_content = parsed_content + c.transcript + + if parsed_type is MessageType.AUDIO: + return AudioMessage( + msg_id=self.id, + content=parsed_content, + ) + else: + return Message.new_tail( + msg_id=self.id, + role=self.role or "", + content=parsed_content, + ) + else: + return None + class DeltaIndex(BaseModel): response_id: str = Field("") @@ -102,6 +260,7 @@ class SessionObject(SessionObjectBase): """ id: str = Field(default="", description="id of the session") object: Literal["realtime.session"] = "realtime.session" + instructions: str = Field(default="", description="instructions of the session") tools: List[dict] = Field(default_factory=list) tool_choice: str = Field(default="auto") temperature: float = Field(default=0.8) diff --git a/ghostos/prototypes/realtime/openai/event_from_client.py b/ghostos/prototypes/realtime/openai/event_from_client.py index 90c6094b..dc78263c 100644 --- a/ghostos/prototypes/realtime/openai/event_from_client.py +++ b/ghostos/prototypes/realtime/openai/event_from_client.py @@ -4,6 +4,25 @@ from pydantic import BaseModel, Field from .event_data_objects import SessionObject, MessageItem +__all__ = [ + 'ClientEventType', + 'ClientEvent', + + # 9 client events + + 'SessionUpdate', + 'ConversationItemCreate', + 'ConversationItemDelete', + 'ConversationItemTruncate', + + 'InputAudioBufferClear', + 'InputAudioBufferAppend', + 'InputAudioBufferCommit', + + 'ResponseCreate', + 'ResponseCancel' +] + class ClientEventType(str, Enum): session_update = "session.updated" @@ -54,13 +73,16 @@ class ClientEvent(BaseModel, ABC): description="Optional client-generated ID used to identify this event.", ) + def to_dict(self) -> dict: + return self.model_dump(exclude_none=True) + -class ClientSessionUpdate(ClientEvent): +class SessionUpdate(ClientEvent): type: ClassVar[str] = ClientEventType.session_update.value session: SessionObject -class ClientInputAudioBufferAppend(ClientEvent): +class InputAudioBufferAppend(ClientEvent): type: ClassVar[str] = ClientEventType.input_audio_buffer_append.value audio: str = Field() @@ -69,7 +91,7 @@ def new(cls, audio: bytes) -> Self: raise NotImplementedError("todo") -class ClientInputAudioBufferCommit(ClientEvent): +class InputAudioBufferCommit(ClientEvent): """ Send this event to commit the user input audio buffer, which will create a new user message item in the conversation. @@ -83,7 +105,7 @@ class ClientInputAudioBufferCommit(ClientEvent): type: ClassVar[str] = ClientEventType.input_audio_buffer_commit.value -class ClientInputAudioBufferClear(ClientEvent): +class InputAudioBufferClear(ClientEvent): """ Send this event to clear the audio bytes in the buffer. The server will respond with an input_audio_buffer.cleared event. diff --git a/ghostos/prototypes/realtime/openai/event_from_server.py b/ghostos/prototypes/realtime/openai/event_from_server.py index eb3af1a9..dbb94b97 100644 --- a/ghostos/prototypes/realtime/openai/event_from_server.py +++ b/ghostos/prototypes/realtime/openai/event_from_server.py @@ -1,55 +1,68 @@ +import base64 from typing import Self, Literal, List, Optional, Union, Dict, Protocol, ClassVar, Set from abc import ABC, abstractmethod from enum import Enum from pydantic import BaseModel, Field +from ghostos.core.messages import Message as GhostOSMessage from .event_data_objects import ( - RateLimit, Response, MessageItem, Content, DeltaIndex, ConversationObject, Error, SessionObject, + RateLimit, Response, MessageItem, + DeltaIndex, ConversationObject, Error, SessionObject, + Content, ) +__all__ = [ + 'ServerEventType', + 'ServerEvent', + # 28 server events + + 'ServerError', + + # 2 session events + 'ServerSessionUpdated', + 'ServerSessionCreated', + + 'ConversationCreated', + + # rate event + 'RateLimitsUpdated', + + # 4 input audio + 'InputAudioBufferSpeechStarted', + 'InputAudioBufferCommitted', + 'InputAudioBufferCleared', + 'InputAudioBufferSpeechStopped', + + # 5 conversation item events + 'ConversationItemCreated', + 'ConversationItemTruncated', + 'ConversationInputAudioTranscriptionCompleted', + 'ConversationInputAudioTranscriptionFailed', + 'ConversationItemDeleted', + + # 14 response events + 'ResponseCreated', + 'ResponseDone', + + 'ResponseOutputItemAdded', + 'ResponseOutputItemDone', + + 'ResponseContentPartAdded', + 'ResponseContentPartDone', + # delta + 'ResponseAudioDelta', + 'ResponseAudioDone', + + 'ResponseAudioTranscriptDelta', + 'ResponseAudioTranscriptDone', + + 'ResponseTextDelta', + 'ResponseTextDone', + + 'ResponseFunctionCallArgumentsDelta', + 'ResponseFunctionCallArgumentsDone', + +] -# class StateName(str, Enum): -# # --- can not change -# stopped = "stopped" -# -# # --- blocking -# connecting = "connecting" -# session_updating = "session_updating" -# -# # --- interruptible -# responding = "responding" -# input_audio = "audio_input" -# listening = "listening" # vad -# -# idle = "idle" -# -# # --- special operations allowed -# failed = "failed" # failed but reconnect-able -# -# # I think this state is parallel, need test -# # creating_conversation_item = "creating_conversation_item" -# -# -# class OperatorName(str, Enum): -# # --- idempotent or immediately -# stop = "stop" # highest priority -# reconnect = "reconnect" # second to stop -# -# # --- parallel actions -# text_input = "text_input" -# function_output = "function_output" -# -# # --- blocking -# session_update = "session_updating" -# -# # --- idempotent or illegal -# create_response = "create_response" -# input_audio = "input_audio" # push-to-talk mode -# start_listening = "start_listening" # start vad listening -# -# # --- immediately or illegal -# truncate_listening = "truncate_listening" # vad only -# response_cancel = "response_cancel" -# class ServerEventType(str, Enum): # recover-able error @@ -58,38 +71,42 @@ class ServerEventType(str, Enum): # non-block inform session_created = "session.created" session_updated = "session.updated" + conversation_created = "conversation.created" # streaming items # complete message item alignments conversation_item_created = "conversation.item.created" - conversation_item_input_audio_transcription_completed = "conversation.item.input_audio_transcription.completed" conversation_item_input_audio_transcription_failed = "conversation.item.input_audio_transcription.failed" - conversation_item_truncated = "conversation.item.truncated" conversation_item_deleted = "conversation.item.deleted" input_audio_buffer_committed = "input_audio_buffer.committed" input_audio_buffer_cleared = "input_audio_buffer.cleared" - input_audio_buffer_speech_started = "input_audio_buffer.speech_started" input_audio_buffer_speech_stopped = "input_audio_buffer.speech_stopped" + # 14 response events response_created = "response.created" response_done = "response.done" response_output_item_added = "response.output_item.added" response_output_item_done = "response.output_item.done" + response_content_part_added = "response.content_part.added" response_content_part_done = "response.content_part.done" + response_text_delta = "response.text.delta" response_text_done = "response.text.done" + response_audio_transcript_delta = "response.audio_transcript.delta" response_audio_transcript_done = "response.audio_transcript.done" + response_audio_delta = "response.audio.delta" response_audio_done = "response.audio.done" + response_function_call_arguments_delta = "response.function_call_arguments.delta" response_function_call_arguments_done = "response.function_call_arguments.done" @@ -142,6 +159,16 @@ def get_response_id(cls, event: dict) -> Union[str, None]: return event["response_id"] return None + @classmethod + def get_item_id(cls, event: dict) -> Optional[str]: + if "item_id" in event: + return event["item_id"] + elif "item" in event: + item = event['item'] + if isinstance(item, dict) and "item_id" in item: + return item["item_id"] + return None + def match(self, event: dict) -> bool: return "type" in event and event["type"] == self.value @@ -153,6 +180,11 @@ class ServerEvent(BaseModel, ABC): event_id: str = Field(description="Optional client-generated ID used to identify this event.") +class ServerError(ServerEvent): + type: ClassVar[str] = ServerEventType.error.value + error: Error + + class ServerSessionCreated(ServerEvent): type: ClassVar[str] = ServerEventType.session_created.value session: SessionObject @@ -169,6 +201,18 @@ class ConversationCreated(ServerEvent): class ConversationItemCreated(ServerEvent): + """ + Returned when a conversation item is created. + There are several scenarios that produce this event: + + 1. The server is generating a Response, + which if successful will produce either one or two Items, + which will be of type message (role assistant) or type function_call. + 2. The input audio buffer has been committed, + either by the client or the server (in server_vad mode). + The server will take the content of the input audio buffer and add it to a new user message Item. + 3. The client has sent a conversation.item.create event to add a new Item to the Conversation. + """ type: ClassVar[str] = ServerEventType.conversation_item_created.value previous_item_id: str = Field("") item: MessageItem = Field() @@ -180,6 +224,18 @@ class ConversationItemDeleted(ServerEvent): class ConversationInputAudioTranscriptionCompleted(ServerEvent): + """ + This event is the output of audio transcription for user audio written to the user audio buffer. + Transcription begins when the input audio buffer is committed by the client or server + (in server_vad mode). + Transcription runs asynchronously with Response creation, + so this event may come before or after the Response events. + Realtime API models accept audio natively, + and thus input transcription is a separate process run on a separate ASR (Automatic Speech Recognition) model, + currently always whisper-1. + Thus the transcript may diverge somewhat from the model's interpretation, + and should be treated as a rough guide. + """ type: ClassVar[str] = ServerEventType.conversation_item_input_audio_transcription_completed.value item_id: str = Field("") content_index: int = Field(default=0) @@ -194,6 +250,13 @@ class ConversationInputAudioTranscriptionFailed(ServerEvent): class ConversationItemTruncated(ServerEvent): + """ + Returned when an earlier assistant audio message item is truncated by the client with + a conversation.item.truncate event. + This event is used to synchronize the server's understanding of the audio with the client's playback. + This action will truncate the audio and remove the server-side text transcript + to ensure there is no text in the context that hasn't been heard by the user. + """ type: ClassVar[str] = ServerEventType.conversation_item_truncated.value item_id: str = Field("") content_index: int = Field(default=0) @@ -249,9 +312,26 @@ class ResponseOutputItemDone(ServerEvent): class ResponseContentPartAdded(ServerEvent): type: ClassVar[str] = ServerEventType.response_content_part_added.value response_id: str = Field("") + content_index: int = Field(0) output_index: int = Field(0) item_id: str = Field("") - part: Content = Field() + part: Content = Field(None, description="The content part that was added. shall not be None.") + + def as_message_chunk(self) -> Optional[GhostOSMessage]: + if self.part is None: + return None + if self.part.transcript: + return GhostOSMessage.new_chunk( + msg_id=self.item_id, + content=self.part.transcript, + ) + elif self.part.text: + return GhostOSMessage.new_chunk( + msg_id=self.item_id, + content=self.part.text, + ) + else: + return None class ResponseContentPartDone(ServerEvent): @@ -261,31 +341,90 @@ class ResponseContentPartDone(ServerEvent): item_id: str = Field("") part: Content = Field() + def as_message_chunk(self) -> Optional[GhostOSMessage]: + if self.part is None: + return None + if self.part.transcript: + return GhostOSMessage.new_chunk( + msg_id=self.item_id, + content=self.part.transcript, + ) + elif self.part.text: + return GhostOSMessage.new_chunk( + msg_id=self.item_id, + content=self.part.text, + ) + else: + return None + class ResponseTextDelta(DeltaIndex, ServerEvent): type: ClassVar[str] = ServerEventType.response_text_delta.value delta: str = Field("") + def as_content(self) -> Content: + return Content( + type="text", + text=self.delta, + ) + + def as_message_chunk(self) -> Optional[GhostOSMessage]: + if self.delta: + return GhostOSMessage.new_chunk( + msg_id=self.item_id, + content=self.delta, + ) + return None + class ResponseTextDone(DeltaIndex, ServerEvent): type: ClassVar[str] = ServerEventType.response_text_done.value text: str = Field("") + def as_content(self) -> Content: + return Content( + type="text", + text=self.text, + ) + class ResponseAudioTranscriptDelta(DeltaIndex, ServerEvent): type: ClassVar[str] = ServerEventType.response_audio_transcript_delta.value delta: str = Field("") + def as_content(self) -> Content: + return Content( + type="audio", + transcript=self.delta, + ) + + def as_message_chunk(self) -> Optional[GhostOSMessage]: + if self.delta: + return GhostOSMessage.new_chunk( + msg_id=self.item_id, + content=self.delta, + ) + return None + class ResponseAudioTranscriptDone(DeltaIndex, ServerEvent): type: ClassVar[str] = ServerEventType.response_audio_transcript_done.value transcript: str = Field("") + def as_content(self) -> Content: + return Content( + type="audio", + transcript=self.delta, + ) + class ResponseAudioDelta(DeltaIndex, ServerEvent): type: ClassVar[str] = ServerEventType.response_audio_delta.value delta: str = Field("") + def get_audio_bytes(self) -> bytes: + return base64.b64decode(self.audio_bytes) + class ResponseAudioDone(DeltaIndex, ServerEvent): type: ClassVar[str] = ServerEventType.response_audio_transcript_done.value @@ -295,6 +434,14 @@ class ResponseFunctionCallArgumentsDelta(DeltaIndex, ServerEvent): type: ClassVar[str] = ServerEventType.response_function_call_arguments_delta.value delta: str = Field("") + def as_message_chunk(self) -> Optional[GhostOSMessage]: + if self.delta: + return GhostOSMessage.new_chunk( + msg_id=self.item_id, + content=self.delta, + ) + return None + class ResponseFunctionCallArgumentsDone(DeltaIndex, ServerEvent): type: ClassVar[str] = ServerEventType.response_function_call_arguments_done.value diff --git a/ghostos/prototypes/realtime/openai/state_of_client.py b/ghostos/prototypes/realtime/openai/state_of_client.py index 35ddf86d..2ff5d5b7 100644 --- a/ghostos/prototypes/realtime/openai/state_of_client.py +++ b/ghostos/prototypes/realtime/openai/state_of_client.py @@ -1,226 +1,356 @@ from __future__ import annotations +from typing import List, Optional, Tuple, Protocol, Self from abc import ABC, abstractmethod -from queue import Queue -from collections import deque -import time -from typing import List, Optional, Dict, Iterable, Tuple, Callable, Union -from threading import Thread -from ghostos.abcd import Conversation -from ghostos.core.messages import ReceiverBuffer -from ghostos.prototypes.realtime.abcd import ( - RealtimeApp, - Listener, Speaker, - RealtimeOperator, -) -from collections import deque -from ghostos.container import Container -from ghostos.core.messages import ( - Message, -) -from ghostos.contracts.logger import LoggerItf, get_logger -from ghostos.contracts.pool import DefaultPool -# from concurrent.futures import ThreadPoolExecutor -# from queue import Queue -# from .protocols import StateName, ServerEventType from .configs import OpenAIRealtimeConf -from .ws import OpenAIWSConnection, OpenAIWebsocketsConf +from ghostos.core.messages import Message +from enum import Enum -from abc import ABC, abstractmethod -from .app import RealtimeApp -from .event_from_server import * -from .configs import OpenAIRealtimeConf -from ghostos.prototypes.realtime.abcd import RealtimeOperator, State +class AppState(str, Enum): + closed = "closed" + connecting = "connecting" + """connecting to the websocket server""" -class OpenAIRealtimeContext: + synchronizing = "synchronizing" + """synchronizing conversation with the websocket server""" - def __init__( - self, - conversation: Conversation, - conf: OpenAIRealtimeConf, - logger: LoggerItf, - ): - self.conversation = conversation - self.logger = logger - self.error: Optional[Exception] = None - self.conf: OpenAIRealtimeConf = conf - self.listening: bool = False - self.speaking_queue: Queue = Queue() - self.speaking_id: Optional[str] = None - self.buffer: List[Message] = [] - self.outputting_completed: Dict[int, Message] = {} - self.outputting_chunks: Dict[int, List[Message]] = {} - self.outputting_id: Optional[int] = None - self.connection: Optional[OpenAIWSConnection] = None - self.closed: bool = False - - def is_closed(self) -> bool: - return self.closed or self.conversation.closed() - - def stop_speaking(self): - self.speaking_id = None - self.speaking_queue.put(None) - self.speaking_queue = Queue() - - def start_speaking(self, speaking_id: str): - self.speaking_queue.put(None) - self.speaking_id = speaking_id - self.speaking_queue = Queue() - - def messages(self) -> Iterable[Message]: - pass - - def push_message(self, message: Message): - pass - - def add_message_to_server(self, message: Message): - pass - - def default_handle_server_event(self, event: dict): - if ServerEventType.error.match(event): - pass - elif ServerEventType.session_created.match(event): - pass - elif ServerEventType.session_updated.match(event): - pass - elif ServerEventType.conversation_created.match(event): - pass - elif ServerEventType.conversation_item_created.match(event): - pass - elif ServerEventType.conversation_item_deleted.match(event): - pass - elif ServerEventType.audio_transcript_created.match(event): - pass - elif ServerEventType.audio_transcript_failed.match(event): - pass - elif ServerEventType.response_output_item_done.match(event): - pass - - -class Connecting(State): - - def __init__(self, ctx: OpenAIRealtimeContext): - self.ctx = ctx - - def name(self) -> str: - return "Connecting" - - def is_outputting(self) -> bool: - return False + waiting_response = "waiting response" - def run_frame(self) -> Optional[State]: - ws_conf = self.ctx.conf.ws_conf - if self.ctx.connection: - self.ctx.connection.close() - self.ctx.connection = OpenAIWSConnection(ws_conf, logger=self.ctx.logger) - return Connected(self.ctx) + updating = "updating" + """updating the local conversation from server state""" - def allow(self, op: RealtimeOperator) -> Tuple[bool, Optional[str]]: - return False, "not implemented" + listening = "listening" + """listening on the local audio inputs""" - def handle(self, op: RealtimeOperator) -> Optional[State]: - event = self.ctx.connection.recv() - if event is None: - return None + responding = "responding" + """responding from the server""" - return self + idle = "idle" - def destroy(self): - del self.ctx + function_call = "function calling" + """local conversation function call""" -class Connected(State): +class OperatorName(str, Enum): + listen = "listen" + commit = "commit" + stop_listen = "stop listen" + clear_audio = "clear" + respond = "respond" + cancel_responding = "cancel" - def __init__(self, ctx: OpenAIRealtimeContext): - self.ctx = ctx - def name(self) -> str: - return "Connected" +class Client(Protocol): + conf: OpenAIRealtimeConf - def allow(self, op: RealtimeOperator) -> Tuple[bool, Optional[str]]: + @abstractmethod + def reconnect(self) -> None: + """ + recreate the ws connection. + :return: + """ pass - def is_outputting(self) -> bool: + @abstractmethod + def synchronize_server_session(self): + """ + :return: + """ pass - def handle(self, op: RealtimeOperator) -> Optional[State]: + @abstractmethod + def update_local_conversation(self) -> None: pass - def run_frame(self) -> Optional[State]: - event = self.ctx.connection.recv() - if event is None: - return None - if ServerEventType.session_created.match(event): - return SyncConversation(self.ctx) + @abstractmethod + def cancel_responding(self) -> bool: + pass - def destroy(self): + @abstractmethod + def start_listening(self) -> bool: pass + @abstractmethod + def stop_listening(self) -> bool: + pass -class SyncConversation(State): - def __init__(self, ctx: OpenAIRealtimeContext): - self.ctx = ctx + @abstractmethod + def is_listening(self) -> bool: + pass - def name(self) -> str: + @abstractmethod + def commit_audio_input(self) -> bool: + """ + and stop listening. + :return: + """ pass - def allow(self, op: RealtimeOperator) -> Tuple[bool, Optional[str]]: + @abstractmethod + def clear_audio_input(self) -> bool: pass - def is_outputting(self) -> bool: + @abstractmethod + def create_response(self) -> bool: pass - def handle(self, op: RealtimeOperator) -> Optional[State]: + @abstractmethod + def is_responding(self) -> bool: pass - def run_frame(self) -> Optional[State]: - for msg in self.ctx.messages(): - self.ctx.add_message_to_server(msg) + @abstractmethod + def receive_server_event(self) -> bool: + pass - def destroy(self): + @abstractmethod + def send_error_message(self, error: str) -> None: pass -class WaitingServer(State): - def __init__(self, ctx: OpenAIRealtimeContext): - self.ctx = ctx +class StateOfClient(ABC): - def name(self) -> str: + def __init__( + self, + client: Client, + ): + self.client = client + + @abstractmethod + def on_init(self): + """ + 同步阻塞逻辑. + """ pass - def allow(self, op: RealtimeOperator) -> Tuple[bool, Optional[str]]: + @abstractmethod + def status(self) -> str: + """ + + :return: + """ pass - def is_outputting(self) -> bool: + def rotate(self) -> bool: + return self.client.receive_server_event() + + @abstractmethod + def operate(self, operator: str) -> Optional[Self]: pass - def handle(self, op: RealtimeOperator) -> Optional[State]: + def allow(self, operator: str) -> bool: + operators = self.operators() + return operator in operators + + @abstractmethod + def operators(self) -> List[str]: pass - def run_frame(self) -> Optional[State]: - event = self.ctx.connection.recv() - if event is None: + @abstractmethod + def tick_frame(self) -> Optional[Self]: + pass + + def destroy(self): + self.client = None + + def default_mode(self) -> Self: + if self.client.conf.start_mode == "listening": + return ListeningState(self.client) + elif self.client.conf.start_mode == "idle": + return IdleState(self.client) + elif self.client.is_responding(): + return RespondingState(self.client) + else: + return IdleState(self.client) + + +class ConnectingState(StateOfClient): + """ + connecting the websocket server + """ + + def status(self) -> str: + # when connecting nothing is able to do. + return AppState.connecting.value + + def on_init(self): + self.client.reconnect() + + def allow(self, operator: str) -> bool: + return False + + def operate(self, operator: str) -> Optional[Self]: + return None + + def rotate(self) -> bool: + return False + + def operators(self) -> List[str]: + return [] + + def tick_frame(self) -> Optional[Self]: + return SynchronizingState(self.client) + + +class SynchronizingState(StateOfClient): + """ + synchronizing conversation history to the websocket server + """ + + def status(self) -> str: + return AppState.synchronizing.value + + def allow(self, operator: str) -> bool: + return False + + def rotate(self) -> bool: + return False + + def operate(self, operator: str) -> Optional[Self]: + return None + + def operators(self) -> List[str]: + return [] + + def on_init(self): + self.client.synchronize_server_session() + + def tick_frame(self) -> Optional[Self]: + return self.default_mode() + + +class ListeningState(StateOfClient): + + def on_init(self): + self.client.start_listening() + + def status(self) -> str: + return AppState.listening.value + + def operators(self) -> List[str]: + return [ + OperatorName.respond, + OperatorName.stop_listen, + OperatorName.commit, + OperatorName.clear_audio, + ] + + def operate(self, operator: str) -> Optional[Self]: + if operator == OperatorName.respond.value: + self.client.commit_audio_input() + return CreateResponseState(self.client) + + elif operator == OperatorName.commit.value: + self.client.commit_audio_input() + self.client.stop_listening() + return IdleState(self.client) + + elif operator == OperatorName.stop_listen: + self.client.stop_listening() + self.client.clear_audio_input() + return IdleState(self.client) + + elif operator == OperatorName.clear_audio: + self.client.clear_audio_input() + # clear and go on listening + return None + + else: return None - if ServerEventType.conversation_created.match(event): - # todo + + def tick_frame(self) -> Optional[Self]: + if self.client.is_responding(): + # responding not cancel listening + return RespondingState(self.client) + return None + + +class CreateResponseState(StateOfClient): + + def on_init(self): + if self.client.is_responding(): + self.client.cancel_responding() + if self.client.is_listening(): + self.client.commit_audio_input() + self.client.create_response() + return + + def status(self) -> Tuple[str, List[str]]: + return AppState.waiting_response, self.operators() + + def operate(self, operator: str) -> Optional[Self]: + # todo: test later + return None + + def operators(self) -> List[str]: + # todo: test later + return [] + + def tick_frame(self) -> Optional[Self]: + if self.client.is_responding(): + return RespondingState(self.client) + return None + + +class RespondingState(StateOfClient): + + def on_init(self): + if not self.client.is_responding(): + self.client.send_error_message("enter responding state but server is not responding") + return + + def status(self) -> str: + return AppState.responding.value + + def operate(self, operator: str) -> Optional[Self]: + if operator == OperatorName.cancel_responding.value: + if self.client.is_responding(): + self.client.cancel_responding() + return self.default_mode() + elif operator == OperatorName.listen.value: + if self.client.is_responding(): + self.client.cancel_responding() + return ListeningState(self.client) + else: return None - def destroy(self): - pass + def operators(self) -> List[str]: + return [ + OperatorName.cancel_responding, + OperatorName.listen, + ] + + def tick_frame(self) -> Optional[Self]: + if self.client.is_responding(): + return None + else: + return self.default_mode() + +class IdleState(StateOfClient): -class Listening(State): - def __init__(self, ctx: OpenAIRealtimeContext): - self.ctx = ctx + def on_init(self): + if self.client.is_listening(): + self.client.stop_listening() + elif self.client.is_responding(): + self.client.cancel_responding() + # when idle, update local conversation. + self.client.update_local_conversation() + return - def is_outputting(self) -> bool: - return True + def status(self) -> str: + return AppState.idle.value + def operate(self, operator: str) -> Optional[Self]: + if operator == OperatorName.listen.value: + return ListeningState(self.client) + return None -class Responding(State): - def __init__(self, ctx: OpenAIRealtimeContext): - self.ctx = ctx + def operators(self) -> List[str]: + return [ + OperatorName.listen.value, + ] - def is_outputting(self) -> bool: - return True + def tick_frame(self) -> Optional[Self]: + self.client.update_local_conversation() + return None diff --git a/ghostos/prototypes/realtime/openai/state_of_server.py b/ghostos/prototypes/realtime/openai/state_of_server.py index 3e76b6ff..4186f085 100644 --- a/ghostos/prototypes/realtime/openai/state_of_server.py +++ b/ghostos/prototypes/realtime/openai/state_of_server.py @@ -1,20 +1,69 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Protocol, Optional, Dict +from typing import Protocol, Optional, Dict, Self, List, Union from .event_from_server import * -from .ws import OpenAIWSConnection -from .configs import SessionObject, OpenAIRealtimeConf -from .context import Context -from ghostos.core.messages import Message -from ghostos.abcd import Conversation, GoThreadInfo -from queue import Queue +from .configs import SessionObject +from .event_data_objects import ( + MessageItem, + RateLimit, +) +from pydantic import ValidationError +from ghostos.core.messages import Message, MessageType +from ghostos.contracts.logger import LoggerItf -class ServerState(Protocol): - ctx: Context +class ServerContext(Protocol): + logger: LoggerItf @abstractmethod - def recv(self, event: dict): + def send_response_chunk(self, response_id: str, chunk: Union[Message, None]) -> bool: + pass + + @abstractmethod + def send_error_message(self, error: str) -> None: + pass + + @abstractmethod + def update_history_message(self, message: Union[Message, None]) -> None: + pass + + @abstractmethod + def add_message_item(self, item: MessageItem, previous_item_id: str) -> None: + pass + + @abstractmethod + def start_response(self, response_id: str) -> None: + pass + + @abstractmethod + def stop_response(self, response_id: str) -> bool: + pass + + @abstractmethod + def send_speaking_audio_chunk(self, response_id: str, data: bytes) -> bool: + pass + + @abstractmethod + def save_audio_data(self, item: MessageItem) -> None: + pass + + +class StateOfServer(ABC): + + def __init__(self, ctx: ServerContext): + self.ctx = ctx + self._destroyed = False + + @classmethod + def recv_event(cls, state: Self, event: dict) -> None: + try: + state.receive(event) + except ValidationError as e: + state.ctx.logger.error("unwrap event failed: %r, origin event: %r", e, event) + raise e + + @abstractmethod + def recv(self, event: dict) -> None: """ recv an openai realtime server event, and handle it. recv one server event at a time globally. @@ -23,26 +72,53 @@ def recv(self, event: dict): """ pass + def __del__(self): + self._destroy() + + def destroy(self) -> None: + if self._destroyed: + return + self._destroyed = True + self._destroy() + @abstractmethod - def gc(self): + def _destroy(self): pass def recv_invalid_event(self, event: dict): - pass + se = ServerError(**event) + error = "Received invalid event: %r" % se + self.ctx.logger.error(error) + # send error message. + return self.ctx.send_error_message(error) def ack_server_event(self, event: ServerEvent): - pass + self.ctx.logger.info( + "handled server event type `%s` and event id `%s'", + type(event).__name__, event.id, + ) -class SessionState(ServerState, Protocol): +class SessionState(StateOfServer): """ session is the root of server state """ - session_obj: SessionObject - conversation: ConversationState - rate_limit: Optional[dict] - input_audio: InputAudioBuffer - status: Literal["new", "updated", "closed"] + + def __init__( + self, + ctx: ServerContext, + session_created: ServerSessionCreated, + ): + super().__init__(ctx) + self.conversation = ConversationState(ctx, session_id="", conversation_id="") + self.session_id = session_created.session.id + self.session_obj: SessionObject = session_created.session + self.input_audio = InputAudiState(ctx) + self.tokens_rate_limit: Optional[RateLimit] = None + self.requests_rate_limit: Optional[RateLimit] = None + + def is_responding(self) -> bool: + return self.conversation.responding_id is not None def recv(self, event: dict): type_name = ServerEventType.get_type(event) @@ -50,80 +126,468 @@ def recv(self, event: dict): return self._recv_session_event(event, type_name) elif ServerEventType.rate_limits_updated: return self._update_rate_limit(event) - elif ServerEventType.is_conversation_event(event, type_name): - return self._recv_conversation_event(event) + + # input audio event elif ServerEventType.is_input_audio_event(event, type_name): return self._recv_input_audio_event(event) + + # conversation event + elif ServerEventType.is_conversation_event(event, type_name): + return self._recv_conversation_event(event) + + # response event elif ServerEventType.is_respond_event(event, type_name): return self._recv_response_event(event) else: return self.recv_invalid_event(event) - def gc(self): - pass + def _destroy(self): + self.conversation.destroy() + self.input_audio.destroy() + del self.conversation + del self.input_audio + del self.session_obj + del self.ctx def _recv_session_event(self, event: dict, e_type: str): if e_type == ServerSessionCreated.type: obj = ServerSessionCreated(**event) + self.session_id = obj.session_id + self.session_obj = obj.session + elif e_type == ServerSessionUpdated.type: obj = ServerSessionUpdated(**event) + if self.session_id and obj.session_id != self.session_id: + # recv other session event, which is not possible. + return self.recv_invalid_event(event) + self.session_obj = obj.session else: return self.recv_invalid_event(event) - if obj.session_id != self.session_obj.session_id: - return self.recv_invalid_event(event) - return self.ack_server_event(obj) def _recv_response_event(self, event: dict): - pass + # let conversation handle response event + return self.conversation.recv(event) def _recv_conversation_event(self, event: dict): - pass + return self.conversation.recv(event) def _recv_input_audio_event(self, event: dict): - pass + return self.input_audio.recv(event) def _update_rate_limit(self, event: dict): - pass + # todo: use rate limit in future. + rlu = RateLimitsUpdated(**event) + for limit in rlu.ratelimits: + if limit.name == "requests": + self.requests_rate_limit = limit + elif limit.name == "tokens": + self.tokens_rate_limit = limit + self.ctx.logger.info(f"Rate limit updated {rlu}") + + +class ConversationState(StateOfServer): + def __init__( + self, + ctx: ServerContext, + session_id: str, + conversation_id: str, + ): + super().__init__(ctx) + self.session_id = session_id + self.conversation_id = conversation_id + # session-conversation completed item. + self.conversation_item_states: dict[str, ConversationItemState] = {} + self.responses: Dict[str, ResponseState] = {} + self.responding_id: Optional[str] = None -class ConversationState(Protocol): - session_id: str - conversation_id: str - items: dict[int, ConversationItemStatus] - responses: dict[int, ResponseBuffer] - status: Literal["new", "created", "closed"] + def _destroy(self): + for item in self.conversation_item_states.values(): + item.destroy() + self.conversation_item_states = {} + for item in self.responses.values(): + item.destroy() + self.responses = {} + self.responding_id = None + + def get_conversation_items(self) -> List[ConversationItemState]: + """ + return the conversation items in orders. + """ + items = [] + start_item_id = "" + item_ids = set() + next_item_trace = {} + for key, item in self.conversation_item_states.items(): + item_ids.add(key) + if not item.previous_item_id: + start_item_id = key + else: + next_item_trace[item.previous_item_id] = key + if not start_item_id: + for item_id in item_ids: + if item_id not in next_item_trace: + start_item_id = item_id + break + + current_item_id = start_item_id + while current_item_id in self.conversation_item_states: + item = self.conversation_item_states[current_item_id] + items.append(item) + current_item_id = next_item_trace[current_item_id] + return items @abstractmethod def recv(self, event: dict): + type_name = ServerEventType.get_type(event) + # conversation events if ServerEventType.conversation_created.match(event): - self.status = "created" - return - elif ServerEventType.conversation_item_created.match(event): - self._update_item(event) - return + return self._conversation_created(event) + elif ServerEventType.conversation_item_created.value == type_name: + return self._item_created(event) + elif ServerEventType.conversation_item_deleted.value == type_name: + return self._delete_item(event) - def _update_item(self, event: dict): - pass + # item event + elif ServerEventType.conversation_item_truncated.value == type_name: + return self._send_event_to_item(event) + elif ServerEventType.conversation_item_input_audio_transcription_completed.value == type_name: + return self._send_event_to_item(event) + elif ServerEventType.conversation_item_input_audio_transcription_failed.value == type_name: + return self._send_event_to_item(event) + # response event + elif ServerEventType.is_respond_event(event): + return self._on_response_event(event) + else: + return self.recv_invalid_event(event) -class ConversationItemStatus(Protocol): - session_id: str - conversation_id: str - index: int - item_id: str + def _conversation_created(self, event: dict): + cic = ConversationItemCreated(**event) + self.conversation_id = cic.conversation_id + return self.ack_server_event(cic) + + def _item_created(self, event: dict): + server_event = ConversationItemCreated(**event) + item = server_event.item + if item.id not in self.conversation_item_states: + conversation_item_state = ConversationItemState( + ctx=self.ctx, + created_event=server_event, + ) + self.conversation_item_states[item.id] = conversation_item_state + + # let conversation_item_state handle the item event. + state = self.conversation_item_states[item.id] + return state.recv(event) + + def _delete_item(self, event: dict): + cid = ConversationItemDeleted(**event) + item_id = cid.item_id + if item_id in self.conversation_item_states: + del self.conversation_item_states[item_id] + self.ctx.logger.info(f"Deleted item {item_id}") + return self.ack_server_event(cid) + return self.recv_invalid_event(event) + + def _on_response_event(self, event: dict): + response_id = ServerEventType.get_response_id(event) + if not response_id: + # response_id exists in protocol + return self.recv_invalid_event(event) + + if response_id not in self.responses: + if not ServerEventType.response_created.match(event): + self.ctx.logger.error("Response is not created") + else: + rc = ResponseCreated(**event) + self.response = self._create_response(rc) + return None + # response exists. + response = self.responses[response_id] + response.recv(event) + if response.is_done(): + # if response is done, reset current responding id. + self.responding_id = None + + def _send_event_to_item(self, event: dict): + item_id = ServerEventType.get_item_id(event) + # todo: + if item_id in self.conversation_item_states: + return self.conversation_item_states[item_id].recv(event) + return self.recv_invalid_event(event) + + def _create_response(self, event: ResponseCreated) -> ResponseState: + # return response state + return ResponseState( + ctx=self.ctx, + event=event, + ) + + +class ConversationItemState(StateOfServer): + def __init__( + self, + ctx: ServerContext, + created_event: ConversationItemCreated, + ): + super().__init__(ctx) + self.previous_item_id: str = created_event.previous_item_id + self.item: MessageItem = created_event.item + self._on_conversation_item_created(created_event) + + def _destroy(self): + self.item = None + + def recv(self, event: dict): + type_name = ServerEventType.get_type(event) + + # conversation item is created yet. + if ServerEventType.conversation_created.value == type_name: + obj = ConversationItemCreated(**event) + return self._on_conversation_item_created(obj) + + elif ServerEventType.conversation_item_truncated.value == type_name: + obj = ConversationItemTruncated(**event) + # todo truncate audio file + return self.ack_server_event(obj) + + elif ServerEventType.conversation_item_input_audio_transcription_completed.value == type_name: + obj = ConversationInputAudioTranscriptionCompleted(**event) + # update transcription. + if self.message is not None and self.message.type == MessageType.AUDIO.value: + self.message.content = obj.transcript + self.ctx.update_history_message(self.message) + return self.ack_server_event(obj) + + elif ServerEventType.conversation_item_input_audio_transcription_failed.value == type_name: + obj = ConversationInputAudioTranscriptionFailed(**event) + # todo + self.ctx.logger.error(f"Conversation item {self.item.id} transcription failed: %r", obj.error) + return self.ack_server_event(obj) + else: + return self.recv_invalid_event(event) + + def _on_conversation_item_created(self, server_event: ConversationItemCreated): + self.previous_item_id = server_event.previous_item_id + self.item = server_event.item + self.message = self.item.to_complete_message() + # add new message item. + self.ctx.add_message_item(server_event.item, server_event.previous_item_id) + if self.item.has_audio(): + self.ctx.save_audio_data(item) + return self.ack_server_event(server_event) + + +class InputAudiState(StateOfServer): - @abstractmethod def recv(self, event: dict): + type_name = ServerEventType.get_type(event) + if ServerEventType.input_audio_buffer_cleared == type_name: + return self._on_input_audio_buffer_cleared(event) + elif ServerEventType.input_audio_buffer_committed == type_name: + return self._on_input_audio_buffer_committed(event) + elif ServerEventType.input_audio_buffer_speech_started == type_name: + return self._on_input_audio_buffer_started(event) + elif ServerEventType.input_audio_buffer_speech_stopped == type_name: + return self._on_input_audio_buffer_stopped(event) + + def _on_input_audio_buffer_stopped(self, event: dict): + se = InputAudioBufferSpeechStopped(**event) + # todo: truncate audio + return self.ack_server_event(se) + + def _on_input_audio_buffer_started(self, event: dict): + se = InputAudioBufferSpeechStarted(**event) + return self.ack_server_event(se) + + def _on_input_audio_buffer_committed(self, event: dict): + se = InputAudioBufferCommitted(**event) + return self.ack_server_event(se) + + def _on_input_audio_buffer_cleared(self, event: dict): + se = InputAudioBufferCleared(**event) + return self.ack_server_event(se) + + def _destroy(self): + pass + + +class ResponseState(StateOfServer): + """ + handle all response events. + """ + + def __init__( + self, + ctx: ServerContext, + event: ResponseCreated, + ): + super().__init__(ctx) + self.response_id = event.response.id + self.response = event.response + self.item_states: dict[str, ResponseItemState] = {} + self.responding_item_id: Optional[str] = None + self._on_response_created(event) + + def _destroy(self): + for item in self.item_states.values(): + item.destroy() + self.item_states = {} + + def is_done(self) -> bool: + return self.response.status in {"completed", "cancelled", "failed"} + + def recv(self, event: dict) -> None: + type_name = ServerEventType.get_type(event) + response_id = ServerEventType.get_response_id(event) + if response_id != self.response_id: + return self.recv_invalid_event(event) + + if ServerEventType.response_created.value == type_name: + rc = ResponseCreated(**event) + return self.ack_server_event(rc) + + elif ServerEventType.response_done.value == type_name: + return self._on_response_done(event) + + elif ServerEventType.response_output_item_added.value == type_name: + # send head package + return self._on_response_output_item_added(event) + + elif self.responding_item_id: + item_state = self.item_states[self.responding_item_id] + # let the item state handle event + item_state.recv(event) + # update the status after item is added + if item_state.is_done(): + self.responding_item_id = None + + else: + return self.recv_invalid_event(event) + + def _on_response_created(self, event: ResponseCreated): + self.response = event.response + self.response_id = event.response.id + + # start response + self.ctx.start_response(self.response_id) + return self.ack_server_event(event) + + def _on_response_done(self, event: dict) -> None: + """ + response is done, update the + """ + rd = ResponseDone(**event) + # update message item + self.response = rd.response + self.ctx.stop_response(rd.response.id) + if rd.response.output: + # update history messages again + for item in rd.response.output: + self.ctx.update_history_message(item.to_complete_message()) + return self.ack_server_event(rd) + + def _on_response_output_item_added(self, event: dict) -> None: + se = ResponseOutputItemAdded(**event) + item_id = se.item.id + if not item_id: + return self.recv_invalid_event(event) + # create response item state + state = ResponseItemState(self.ctx, se) + self.item_states[item_id] = state + self.responding_item_id = item_id + + # todo: 最后统一处理消息发送. + return self.ack_server_event(se) + + +class ResponseItemState(StateOfServer): + + def __init__(self, ctx: ServerContext, event: ResponseOutputItemAdded): + super().__init__(ctx) + self.item = event.item + self.response_id = event.response_id + self.output_index = event.output_index + # send head + self._on_response_output_item_added(event) + + def _destroy(self): pass + def is_done(self) -> bool: + return self.item.status in {"completed"} + + def _on_response_output_item_added(self, event: ResponseOutputItemAdded): + self.ctx.send_response_chunk(event.response_id, event.item.to_message_head()) + return self.ack_server_event(event) + + def recv(self, event: dict) -> None: + type_name = ServerEventType.get_type(event) + if ServerEventType.response_output_item_added.value == type_name: + se = ResponseOutputItemAdded(**event) + return self._on_response_output_item_added(se) + + elif ServerEventType.response_output_item_done.value == type_name: + # update message item. + se = ResponseOutputItemDone(**event) + return self._on_response_output_item_done(se) + + elif ServerEventType.response_content_part_added.value == type_name: + se = ResponseContentPartAdded(**event) + return self._on_response_content_part_added(se) + + elif ServerEventType.response_content_part_done.value == type_name: + se = ResponseContentPartDone(**event) + return self._on_response_content_part_done(se) + + elif ServerEventType.response_text_delta.value == type_name: + se = ResponseTextDelta(**event) + self.ctx.send_response_chunk(se.response_id, se.as_message_chunk()) + return self.ack_server_event(se) -class InputAudioBuffer(Protocol): - pass + elif ServerEventType.response_text_done.value == type_name: + se = ResponseTextDone(**event) + # no need to handle + return self.ack_server_event(se) + elif ServerEventType.response_audio_delta.value == type_name: + se = ResponseAudioDelta(**event) + self.ctx.send_speaking_audio_chunk(se.response_id, se.get_audio_bytes()) + return self.ack_server_event(se) + + elif ServerEventType.response_audio_done.value == type_name: + se = ResponseAudioDone(**event) + return self.ack_server_event(se) + + elif ServerEventType.response_audio_transcript_delta.value == type_name: + se = ResponseAudioTranscriptDelta(**event) + self.ctx.send_response_chunk(se.response_id, se.as_message_chunk()) + return self.ack_server_event(se) + + elif ServerEventType.response_audio_transcript_done.value == type_name: + se = ResponseAudioTranscriptDone(**event) + return self.ack_server_event(se) + + elif ServerEventType.response_function_call_arguments_delta.value == type_name: + se = ResponseFunctionCallArgumentsDelta(**event) + self.ctx.send_response_chunk(se.response_id, se.as_message_chunk()) + return self.ack_server_event(se) + + elif ServerEventType.response_function_call_arguments_done.value == type_name: + se = ResponseFunctionCallArgumentsDone(**event) + return self.ack_server_event(se) + + else: + return self.recv_invalid_event(event) -class ResponseBuffer(Protocol): - output_items: dict[int, OutputItemBuffer] + def _on_response_output_item_done(self, event: ResponseOutputItemDone) -> None: + self.item = event.item + self.ctx.send_response_chunk(event.response_id, event.item.to_complete_message()) + return self.ack_server_event(event) + def _on_response_content_part_added(self, event: ResponseContentPartAdded) -> None: + return self.ack_server_event(event) -class OutputItemBuffer(Protocol): - pass + def _on_response_content_part_done(self, event: ResponseContentPartDone) -> None: + return self.ack_server_event(event) diff --git a/ghostos/prototypes/realtime/openai/ws.py b/ghostos/prototypes/realtime/openai/ws.py index a89ffdb4..70a407b6 100644 --- a/ghostos/prototypes/realtime/openai/ws.py +++ b/ghostos/prototypes/realtime/openai/ws.py @@ -124,6 +124,7 @@ def closed(self) -> bool: connect = OpenAIWSConnection +# some local tests if __name__ == "__main__": import os from ghostos.helpers import Timeleft diff --git a/ghostos/prototypes/spherogpt/bolt.py b/ghostos/prototypes/spherogpt/bolt.py index 5cb433d7..41b83816 100644 --- a/ghostos/prototypes/spherogpt/bolt.py +++ b/ghostos/prototypes/spherogpt/bolt.py @@ -232,7 +232,7 @@ def factory(self, con: Container) -> Optional[SpheroBolt]: conversation = con.force_fetch(Conversation) eventbus = con.force_fetch(EventBus) logger = con.force_fetch(LoggerItf) - task = conversation.task() + task = conversation.get_task() return SpheroBoltImpl( logger, eventbus, diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index 21b95c0b..c0bc9820 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -52,7 +52,7 @@ def main_chat(): with st.container(border=True): GhostTaskRoute().render_page_link(use_container_width=True) if st.button("Clear Messages", use_container_width=True): - thread = conversation.thread() + thread = conversation.get_thread() thread = thread.reset_history([]) conversation.update_thread(thread) st.rerun() @@ -137,14 +137,14 @@ def main_chat(): def realtime(route: GhostChatRoute, conversation: Conversation): - thread = conversation.thread() + thread = conversation.get_thread() render_thread_messages(thread, max_turn=20) debug = get_app_conf().BoolOpts.DEBUG_MODE.get() def get_conversation(route: GhostChatRoute) -> Conversation: conversation = Singleton.get(Conversation, st.session_state, force=False) - if not conversation or conversation.closed(): + if not conversation or conversation.is_closed(): shell = Singleton.get(Shell, st.session_state) # create conversation conversation = shell.sync(route.get_ghost(), route.get_context()) @@ -159,8 +159,8 @@ def main_task(): with st.container(border=True): route.render_page_link(use_container_width=True) conversation = get_conversation(route) - task = conversation.task() - thread = conversation.thread() + task = conversation.get_task() + thread = conversation.get_thread() st.title("Ghost Task Info") render_task_info_settings(task, thread) @@ -168,7 +168,7 @@ def main_task(): def chatting(route: GhostChatRoute, conversation: Conversation): chat_input = st.chat_input("message") - thread = conversation.thread() + thread = conversation.get_thread() render_thread_messages(thread, max_turn=20) debug = get_app_conf().BoolOpts.DEBUG_MODE.get() diff --git a/ghostos/prototypes/streamlitapp/tests/tools/history.py b/ghostos/prototypes/streamlitapp/tests/tools/history.py index 878891ba..3580ce82 100644 --- a/ghostos/prototypes/streamlitapp/tests/tools/history.py +++ b/ghostos/prototypes/streamlitapp/tests/tools/history.py @@ -2,5 +2,5 @@ x = st.slider("Select a value") st.write(x, "squared is", x * x) -values = {k: v for k, v in st.query_params.items()} +values = {k: v for k, v in st.query_params.conversation_item_states()} st.write(values) diff --git a/ghostos/scripts/cli/run_aifunc.py b/ghostos/scripts/cli/run_aifunc.py index 3301da0b..6cadf2d0 100644 --- a/ghostos/scripts/cli/run_aifunc.py +++ b/ghostos/scripts/cli/run_aifunc.py @@ -39,7 +39,7 @@ def find_aifunc_by_name(filename_or_modulename: str) -> FoundAIFunc: is_temp = False aifunc = None - for name, value in module.__dict__.items(): + for name, value in module.__dict__.conversation_item_states(): if name.startswith("_"): continue if not inspect.isclass(value): diff --git a/ghostos/thoughts/basic.py b/ghostos/thoughts/basic.py index 6723b9b1..002e9027 100644 --- a/ghostos/thoughts/basic.py +++ b/ghostos/thoughts/basic.py @@ -28,7 +28,7 @@ def chat_preparers(self, g: Ghost, e: Event) -> Iterable[PromptPipe]: assistant_name = g.identifier().name yield OtherAgentOrTaskPipe( assistant_name=assistant_name, - task_id=g.session().task().task_id, + task_id=g.session().get_task.task_id, ) @abstractmethod @@ -47,7 +47,7 @@ def instruction(self, g: Ghost, e: Event) -> str: def initialize_chat(self, g: Ghost, e: Event) -> Prompt: session = g.session() - thread = session.thread() + thread = session.get_thread() system_prompt = g.system_prompt() thought_instruction = self.instruction(g, e) content = "\n\n".join([system_prompt, thought_instruction]) @@ -86,7 +86,7 @@ def think(self, g: Ghost, e: Event) -> Optional[Operator]: llm_api.deliver_chat_completion(chat, messenger) messages, callers = messenger.flush() # set chat system prompt to thread - session.thread().system_prompt = chat.system_prompt() + session.get_thread().system_prompt = chat.system_prompt() # callback actions for caller in callers: diff --git a/ghostos/thoughts/moss_thought.py b/ghostos/thoughts/moss_thought.py index 2a35830c..27d68f8f 100644 --- a/ghostos/thoughts/moss_thought.py +++ b/ghostos/thoughts/moss_thought.py @@ -47,7 +47,7 @@ def get_moss_runtime(self, g: Ghost) -> MossRuntime: if self.moss_runtime is not None: return self.moss_runtime - thread = g.session().thread() + thread = g.session().get_thread() compiler = g.moss() # init default pycontext default_pycontext = self.init_pycontext() @@ -93,7 +93,7 @@ def instruction(self, g: Ghost, e: Event) -> str: def on_created(self, g: Ghost, e: Event) -> Optional[Operator]: session = g.session() - thread = session.thread() + thread = session.get_thread() pycontext = self.init_pycontext() thread.update_pycontext(pycontext) return super().on_created(g, e) diff --git a/ghostos/thoughts/pymodule_editor.py b/ghostos/thoughts/pymodule_editor.py index cea253e1..7dbfc5a1 100644 --- a/ghostos/thoughts/pymodule_editor.py +++ b/ghostos/thoughts/pymodule_editor.py @@ -103,7 +103,7 @@ def instruction(self, g: Ghost, e: Event) -> str: ) if self.thought.referencing: referencing = "\n\n# referencing\n\nThere are some references for you:" - for import_path, prompt in self.thought.referencing.items(): + for import_path, prompt in self.thought.referencing.conversation_item_states(): target = import_from_path(import_path) source = inspect.getsource(target) referencing += f""" diff --git a/tests/core/messages/test_arr_stream_receiver.py b/tests/core/messages/test_arr_stream_receiver.py index 448ebc03..5c5c59ac 100644 --- a/tests/core/messages/test_arr_stream_receiver.py +++ b/tests/core/messages/test_arr_stream_receiver.py @@ -244,7 +244,7 @@ def send_data(s: Stream, c: str): def test_array_receiver_bad_case_1(): item = Message( msg_id='25c6d3d9-9bb1-45e1-ac7e-585380975ea1', - ref_id='call_SyYPOCVP60bvyLIMP3gemVYy', + call_id='call_SyYPOCVP60bvyLIMP3gemVYy', index=None, type='function_call', stage='', @@ -263,7 +263,7 @@ def test_array_receiver_bad_case_1(): item2 = Message( **{ "msg_id": "", - "ref_id": None, + "call_id": None, "index": None, "type": "function_call", "stage": "", diff --git a/tests/core/messages/test_messages.py b/tests/core/messages/test_messages.py index 737d2965..8139ed3e 100644 --- a/tests/core/messages/test_messages.py +++ b/tests/core/messages/test_messages.py @@ -83,7 +83,7 @@ def test_patch_default_type_message(): def test_function_call_message(): head = Message.new_head( typ_=MessageType.FUNCTION_CALL, - ref_id="abc", + call_id="abc", name="abc", ) patched = head.patch( @@ -93,18 +93,18 @@ def test_function_call_message(): ) ) assert patched is not None - assert patched.ref_id == "abc" + assert patched.call_id == "abc" assert patched.name == "abc" assert patched.content == "hello world" def test_message_path_bad_case(): - item1 = Message(msg_id='d5ff6a6a-2b05-4819-864d-82afdf9ac5fc', ref_id=None, + item1 = Message(msg_id='d5ff6a6a-2b05-4819-864d-82afdf9ac5fc', call_id=None, from_id='chatcmpl-AXs0YM2VxVZbo50C1lIOC0qlWumtN', index=None, type='function_call', stage='', role='assistant', name=None, content='{"', memory=None, attrs=None, payloads={}, callers=[], seq='chunk', created=0.0) - item2 = Message(msg_id='d5ff6a6a-2b05-4819-864d-82afdf9ac5fc', ref_id='call_DCaC3PJy336sZ9ryhxijgFlq', + item2 = Message(msg_id='d5ff6a6a-2b05-4819-864d-82afdf9ac5fc', call_id='call_DCaC3PJy336sZ9ryhxijgFlq', from_id='chatcmpl-AXs0YM2VxVZbo50C1lIOC0qlWumtN', index=None, type='function_call', stage='', role='assistant', name='moss', content='{"', memory=None, attrs=None, payloads={}, callers=[], seq='chunk', created=1732636557.282) diff --git a/tests/framework/messenger/test_messenger.py b/tests/framework/messenger/test_messenger.py index 3f5f59f0..726464b8 100644 --- a/tests/framework/messenger/test_messenger.py +++ b/tests/framework/messenger/test_messenger.py @@ -40,7 +40,7 @@ def test_messenger_with_function_call(): msg = Message.new_chunk(content=c) items.append(msg) for c in content: - msg = Message.new_chunk(content=c, typ_=MessageType.FUNCTION_CALL, ref_id="123", name="good") + msg = Message.new_chunk(content=c, typ_=MessageType.FUNCTION_CALL, call_id="123", name="good") items.append(msg) with stream: messenger.send(items) From a451a3f596e597a9223ace04858dfffa2625c301 Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 5 Dec 2024 22:47:25 +0800 Subject: [PATCH 119/148] dev: first test round of openai realtime --- .ghostos/configs/openai_realtime_config.yml | 1 + .ghostos/configs/realtime_conf.yml | 5 + .../realtime/abcd.py => abcd/realtime.py} | 191 ++++---- ghostos/bootstrap.py | 9 +- ghostos/core/messages/__init__.py | 1 + ghostos/core/runtime/threads.py | 4 +- ghostos/entity.py | 20 + .../realtime => framework/audio}/__init__.py | 0 .../framework/audio/pyaudio_io/__init__.py | 11 + ghostos/framework/audio/pyaudio_io/example.py | 48 ++ .../audio}/pyaudio_io/listener.py | 30 +- .../audio}/pyaudio_io/speaker.py | 11 +- ghostos/framework/ghostos/session_impl.py | 2 +- ghostos/framework/openai_realtime/__init__.py | 4 + ghostos/framework/openai_realtime/app.py | 181 ++++++++ .../openai_realtime}/client.py | 187 ++++---- .../openai_realtime}/configs.py | 18 +- ghostos/framework/openai_realtime/driver.py | 28 ++ .../openai_realtime}/event_data_objects.py | 4 - .../openai_realtime}/event_from_client.py | 2 +- .../openai_realtime}/event_from_server.py | 0 ghostos/framework/openai_realtime/output.py | 185 ++++++++ .../openai_realtime}/state_of_client.py | 67 +-- .../openai_realtime}/state_of_server.py | 33 +- .../openai_realtime}/ws.py | 38 +- ghostos/framework/realtime/__init__.py | 2 + ghostos/framework/realtime/defaults.py | 59 +++ ghostos/helpers/timeutils.py | 2 +- ghostos/prototypes/realtime/README.md | 16 - .../prototypes/realtime/openai/__states.py | 431 ------------------ ghostos/prototypes/realtime/openai/app.py | 267 ----------- .../prototypes/realtime/openai/broadcast.py | 95 ---- ghostos/prototypes/realtime/openai/context.py | 229 ---------- ghostos/prototypes/realtime/openai/utils.py | 15 - .../realtime/pyaudio_io/__init__.py | 0 .../example.py} | 0 36 files changed, 845 insertions(+), 1351 deletions(-) create mode 100644 .ghostos/configs/openai_realtime_config.yml create mode 100644 .ghostos/configs/realtime_conf.yml rename ghostos/{prototypes/realtime/abcd.py => abcd/realtime.py} (61%) rename ghostos/{prototypes/realtime => framework/audio}/__init__.py (100%) create mode 100644 ghostos/framework/audio/pyaudio_io/__init__.py create mode 100644 ghostos/framework/audio/pyaudio_io/example.py rename ghostos/{prototypes/realtime => framework/audio}/pyaudio_io/listener.py (59%) rename ghostos/{prototypes/realtime => framework/audio}/pyaudio_io/speaker.py (84%) create mode 100644 ghostos/framework/openai_realtime/__init__.py create mode 100644 ghostos/framework/openai_realtime/app.py rename ghostos/{prototypes/realtime/openai => framework/openai_realtime}/client.py (67%) rename ghostos/{prototypes/realtime/openai => framework/openai_realtime}/configs.py (54%) create mode 100644 ghostos/framework/openai_realtime/driver.py rename ghostos/{prototypes/realtime/openai => framework/openai_realtime}/event_data_objects.py (98%) rename ghostos/{prototypes/realtime/openai => framework/openai_realtime}/event_from_client.py (98%) rename ghostos/{prototypes/realtime/openai => framework/openai_realtime}/event_from_server.py (100%) create mode 100644 ghostos/framework/openai_realtime/output.py rename ghostos/{prototypes/realtime/openai => framework/openai_realtime}/state_of_client.py (84%) rename ghostos/{prototypes/realtime/openai => framework/openai_realtime}/state_of_server.py (95%) rename ghostos/{prototypes/realtime/openai => framework/openai_realtime}/ws.py (84%) create mode 100644 ghostos/framework/realtime/__init__.py create mode 100644 ghostos/framework/realtime/defaults.py delete mode 100644 ghostos/prototypes/realtime/README.md delete mode 100644 ghostos/prototypes/realtime/openai/__states.py delete mode 100644 ghostos/prototypes/realtime/openai/app.py delete mode 100644 ghostos/prototypes/realtime/openai/broadcast.py delete mode 100644 ghostos/prototypes/realtime/openai/context.py delete mode 100644 ghostos/prototypes/realtime/openai/utils.py delete mode 100644 ghostos/prototypes/realtime/pyaudio_io/__init__.py rename ghostos/prototypes/{realtime/openai/__init__.py => realtime_console/example.py} (100%) diff --git a/.ghostos/configs/openai_realtime_config.yml b/.ghostos/configs/openai_realtime_config.yml new file mode 100644 index 00000000..6f31cf5a --- /dev/null +++ b/.ghostos/configs/openai_realtime_config.yml @@ -0,0 +1 @@ +{ } \ No newline at end of file diff --git a/.ghostos/configs/realtime_conf.yml b/.ghostos/configs/realtime_conf.yml new file mode 100644 index 00000000..440e174b --- /dev/null +++ b/.ghostos/configs/realtime_conf.yml @@ -0,0 +1,5 @@ +default: "openai" +apps: + openai: + type: "ghostos.framework.openai_realtime.configs:OpenAIRealtimeAppConf" + data: { } \ No newline at end of file diff --git a/ghostos/prototypes/realtime/abcd.py b/ghostos/abcd/realtime.py similarity index 61% rename from ghostos/prototypes/realtime/abcd.py rename to ghostos/abcd/realtime.py index e903c160..b10ea92b 100644 --- a/ghostos/prototypes/realtime/abcd.py +++ b/ghostos/abcd/realtime.py @@ -2,75 +2,29 @@ from abc import ABC, abstractmethod from typing import ( Generic, - Protocol, Literal, List, ClassVar, Iterable, Tuple, TypeVar, Optional, Dict, Callable, Type, - Self, - Union, + List, Iterable, Tuple, TypeVar, Optional, Dict, ) +import time from ghostos.abcd import Conversation from ghostos.core.messages import Message, ReceiverBuffer -import time -from enum import Enum +from ghostos.entity import ModelEntityMeta, to_entity_model_meta, from_entity_model_meta from pydantic import BaseModel, Field -from queue import Queue -from contextlib import contextmanager - - -# class State(ABC): -# state_name: ClassVar[str] -# -# @abstractmethod -# def conversation(self) -> Conversation: -# pass -# -# @abstractmethod -# def operate(self, op: Operator) -> Tuple[str, str | None]: -# """ -# :param op: -# :return: accept level | error message -# """ -# pass -# -# @abstractmethod -# def run_operator(self) -> Union["State", None]: -# """ -# :return: None means no operation, go on handle event -# """ -# pass -# -# @abstractmethod -# def run_server_event(self) -> Union[State, None]: -# """ -# :return: a new state, or continue -# """ -# pass -# -# def tick(self) -> Union[State, None]: -# """ -# :return: if not none, means a new state is returned. and: -# 1. replace current state with new state -# 2. put the current state to a recycling queue, join it without blocking. -# """ -# new_state = self.run_operator() -# if new_state: -# return new_state -# new_state = self.run_server_event() -# if new_state: -# return new_state -# return None -# -# @abstractmethod -# def join(self): -# """ -# """ -# pass -# -# -# S = TypeVar("S", bound=State) class Realtime(ABC): @abstractmethod - def create(self, conversation: Conversation, app_name: str = "") -> RealtimeApp: + def create( + self, + conversation: Conversation, + listener: Listener, + speaker: Speaker, + app_name: str = "", + config: Optional[RealtimeAppConfig] = None, + ) -> RealtimeApp: + pass + + @abstractmethod + def get_config(self) -> RealtimeConfig: pass @abstractmethod @@ -86,10 +40,23 @@ def driver_name(self) -> str: class RealtimeConfig(BaseModel): - apps: Dict[str, RealtimeAppConfig] = Field( + default: str = Field(description="default app") + apps: Dict[str, ModelEntityMeta] = Field( default_factory=dict, ) + def add_app_conf(self, name: str, app_conf: RealtimeAppConfig): + self.apps[name] = to_entity_model_meta(app_conf) + + def get_app_conf(self, name: str) -> Optional[RealtimeAppConfig]: + data = self.apps.get(name, None) + if data is None: + return None + conf = from_entity_model_meta(data) + if not isinstance(conf, RealtimeAppConfig): + raise TypeError(f"App config {name} is not a RealtimeAppConfig") + return conf + C = TypeVar("C", bound=RealtimeAppConfig) @@ -114,7 +81,7 @@ def create( class Listener(ABC): @abstractmethod - def hearing(self) -> Optional[bytes]: + def hearing(self, second: float = 1) -> Optional[bytes]: pass @abstractmethod @@ -148,11 +115,6 @@ def flush(self) -> bytes: pass -class RealtimeOP(Protocol): - name: str - description: str - - class RealtimeApp(ABC): """ realtime agent in multi-threading programming pattern. @@ -217,6 +179,10 @@ def messages(self) -> Iterable[Message]: """ pass + @abstractmethod + def set_mode(self, *, listening: bool): + pass + @abstractmethod def state(self) -> Tuple[str, List[str]]: """ @@ -258,48 +224,49 @@ def __exit__(self, exc_type, exc_val, exc_tb): return intercepted -def example(app: RealtimeApp): - with app: - while True: - state, ops = app.state() - print(state, ops) - outputting = app.output() - if outputting is None: - time.sleep(0.1) - continue - while outputting is not None: - print(outputting.head()) - chunks = outputting.chunks() - for c in chunks: - print(c) - print(outputting.tail()) - - -def streamlit_example(app: RealtimeApp): - import streamlit as st - with app: - for message in app.messages(): - with st.container(): - st.write(message) - - while True: - with st.empty(): - rendered = False - while not rendered: - state, operators = app.state() - with st.container(): - if operators: - for op in operators: - st.write(op) - with st.status(state): - buffer = app.output() - if buffer is None: - continue - rendered = buffer - if rendered is None: - time.sleep(0.1) - else: - break +if __name__ == "__example__": + def example(app: RealtimeApp): + with app: + while True: + state, ops = app.state() + print(state, ops) + outputting = app.output() + if outputting is None: + time.sleep(0.1) + continue + while outputting is not None: + print(outputting.head()) + chunks = outputting.chunks() + for c in chunks: + print(c) + print(outputting.tail()) + + + def streamlit_example(app: RealtimeApp): + import streamlit as st + with app: + for message in app.messages(): with st.container(): - st.write(buffer.tail()) - break + st.write(message) + + while True: + with st.empty(): + rendered = False + while not rendered: + state, operators = app.state() + with st.container(): + if operators: + for op in operators: + st.write(op) + with st.status(state): + buffer = app.output() + if buffer is None: + continue + rendered = buffer + if rendered is None: + time.sleep(0.1) + else: + break + with st.container(): + st.write(buffer.tail()) + break diff --git a/ghostos/bootstrap.py b/ghostos/bootstrap.py index 99c96b6a..4d265689 100644 --- a/ghostos/bootstrap.py +++ b/ghostos/bootstrap.py @@ -120,6 +120,7 @@ def default_application_contracts() -> Contracts: from ghostos.framework.documents import DocumentRegistry from ghostos.framework.ghostos import GhostOS from ghostos.framework.assets import ImageAssets, AudioAssets + from ghostos.framework.realtime import Realtime from ghostos.core.aifunc import AIFuncExecutor, AIFuncRepository return Contracts([ @@ -159,6 +160,8 @@ def default_application_contracts() -> Contracts: # root GhostOS, + + Realtime, ]) @@ -189,8 +192,9 @@ def default_application_providers( from ghostos.framework.logger import DefaultLoggerProvider from ghostos.framework.variables import WorkspaceVariablesProvider from ghostos.framework.ghostos import GhostOSProvider - from ghostos.core.aifunc import DefaultAIFuncExecutorProvider, AIFuncRepoByConfigsProvider from ghostos.framework.documents import ConfiguredDocumentRegistryProvider + from ghostos.framework.realtime import ConfigBasedRealtimeProvider + from ghostos.core.aifunc import DefaultAIFuncExecutorProvider, AIFuncRepoByConfigsProvider return [ # --- logger ---# @@ -234,7 +238,8 @@ def default_application_providers( DefaultAIFuncExecutorProvider(), AIFuncRepoByConfigsProvider(runtime_frame_dir="aifunc_frames"), - GhostOSProvider() + GhostOSProvider(), + ConfigBasedRealtimeProvider(), ] diff --git a/ghostos/core/messages/__init__.py b/ghostos/core/messages/__init__.py index 96f9a20a..c51aae00 100644 --- a/ghostos/core/messages/__init__.py +++ b/ghostos/core/messages/__init__.py @@ -17,3 +17,4 @@ from ghostos.core.messages.buffers import Buffer, Flushed from ghostos.core.messages.utils import copy_messages from ghostos.core.messages.transport import Stream, Receiver, new_basic_connection, ReceiverBuffer +from ghostos.core.messages.pipeline import SequencePipe diff --git a/ghostos/core/runtime/threads.py b/ghostos/core/runtime/threads.py index 93fa9c41..76d64a97 100644 --- a/ghostos/core/runtime/threads.py +++ b/ghostos/core/runtime/threads.py @@ -101,8 +101,8 @@ def update_message(self, message: Message) -> bool: found = False for exists in self.added: if exists.msg_id == message.msg_id: - Found = True - exists = message + found = True + exists = message.get_copy() messages.append(exists) if found: self.added = messages diff --git a/ghostos/entity.py b/ghostos/entity.py index 0b3f5029..1857dbca 100644 --- a/ghostos/entity.py +++ b/ghostos/entity.py @@ -19,6 +19,10 @@ 'Entity', 'EntityType', 'EntityClass', 'ModelEntity', + 'ModelEntityMeta', + 'to_entity_model_meta', + 'from_entity_model_meta', + ] @@ -74,6 +78,11 @@ class EntityMeta(TypedDict): content: Required[str] +class ModelEntityMeta(TypedDict): + type: Required[str] + data: Required[dict] + + EntityType = Union[Entity, EntityMeta, BaseModel] @@ -81,6 +90,17 @@ def is_entity_type(value: Any) -> bool: return hasattr(value, '__to_entity_meta__') +def to_entity_model_meta(value: BaseModel) -> ModelEntityMeta: + type_ = generate_import_path(type(value)) + data = value.model_dump(exclude_defaults=True) + return ModelEntityMeta(type=type_, data=data) + + +def from_entity_model_meta(value: ModelEntityMeta) -> BaseModel: + cls = import_from_path(value['type']) + return cls(**value['data']) + + def to_entity_meta(value: Union[EntityType, Any]) -> EntityMeta: if value is None: return EntityMeta( diff --git a/ghostos/prototypes/realtime/__init__.py b/ghostos/framework/audio/__init__.py similarity index 100% rename from ghostos/prototypes/realtime/__init__.py rename to ghostos/framework/audio/__init__.py diff --git a/ghostos/framework/audio/pyaudio_io/__init__.py b/ghostos/framework/audio/pyaudio_io/__init__.py new file mode 100644 index 00000000..d5cc6b0e --- /dev/null +++ b/ghostos/framework/audio/pyaudio_io/__init__.py @@ -0,0 +1,11 @@ +from ghostos.abcd.realtime import Speaker, Listener + + +def get_pyaudio_listener() -> Listener: + from ghostos.framework.audio.pyaudio_io.listener import Listener + return Listener() + + +def get_pyaudio_speaker() -> Speaker: + from ghostos.framework.audio.pyaudio_io.speaker import Speaker + return Speaker() diff --git a/ghostos/framework/audio/pyaudio_io/example.py b/ghostos/framework/audio/pyaudio_io/example.py new file mode 100644 index 00000000..470e9229 --- /dev/null +++ b/ghostos/framework/audio/pyaudio_io/example.py @@ -0,0 +1,48 @@ +from ghostos.framework.audio.pyaudio_io.listener import PyAudioListener +from ghostos.framework.audio.pyaudio_io.speaker import PyAudioSpeaker +from pyaudio import PyAudio, paInt16 +from io import BytesIO +from ghostos.helpers import Timeleft + +if __name__ == '__main__': + + listener = PyAudioListener() + speaker = PyAudioSpeaker() + ticker = Timeleft(0) + + hearing = BytesIO() + print("start listening, %f" % ticker.passed()) + with listener: + timeleft = Timeleft(3) + while timeleft.alive(): + data = listener.hearing() + if data is not None: + hearing.write(data) + heard = listener.flush() + print("end listening, %f" % ticker.passed()) + assert heard is not None + print("heard data: %d", len(heard)) + + print("test raw outputting, %f" % ticker.passed()) + stream = PyAudio().open( + format=paInt16, + channels=1, + rate=44100, + output=True, + ) + stream.write(heard) + stream.stop_stream() + stream.close() + print("end raw outputting, %f" % ticker.passed()) + + # print("start speaking buffer, %f" % ticker.passed()) + # with speaker: + # while data := hearing.read(1024): + # speaker.speak(data) + # print("end speaking buffer, %f" % ticker.passed()) + + print("start speaking flushed") + with speaker: + speaker.speak(heard) + + print("end speaking flushed") diff --git a/ghostos/prototypes/realtime/pyaudio_io/listener.py b/ghostos/framework/audio/pyaudio_io/listener.py similarity index 59% rename from ghostos/prototypes/realtime/pyaudio_io/listener.py rename to ghostos/framework/audio/pyaudio_io/listener.py index 2825b240..d51b1af5 100644 --- a/ghostos/prototypes/realtime/pyaudio_io/listener.py +++ b/ghostos/framework/audio/pyaudio_io/listener.py @@ -1,14 +1,19 @@ try: - from pyaudio import PyAudio + from pyaudio import PyAudio, paInt16 except ImportError: raise ImportError(f"Pyaudio is required, please install pyaudio or ghostos[audio] first") from typing import Optional from io import BytesIO -from ghostos.prototypes.realtime.abcd import Speaker +from ghostos.abcd.realtime import Listener +CHUNK = 1024 +FORMAT = paInt16 +CHANNELS = 1 +RATE = 44100 -class PyAudioSpeaker(Speaker): + +class PyAudioListener(Listener): def __init__(self): self.stream: Optional[PyAudio.Stream] = None @@ -19,7 +24,10 @@ def __enter__(self): raise RuntimeError("PyAudioSpeaker already initialized") self.buffer = BytesIO() self.stream = PyAudio().open( - + format=paInt16, + channels=1, + rate=44100, + input=True, ) return self @@ -28,12 +36,18 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.stream.stop_stream() self.stream.close() self.stream = None + self.buffer = None - def speak(self, data: bytes): + def hearing(self, second: float = 1) -> Optional[bytes]: if self.stream is None: - raise RuntimeError("PyAudioSpeaker is not started in context manager") - self.stream.write(data) - self.buffer.write(data) + return None + sending_buffer = BytesIO() + for i in range(0, int((RATE / CHUNK) * second)): + data = self.stream.read(CHUNK) + sending_buffer.write(data) + self.buffer.write(data) + + return sending_buffer.getvalue() def flush(self) -> bytes: if self.buffer is None: diff --git a/ghostos/prototypes/realtime/pyaudio_io/speaker.py b/ghostos/framework/audio/pyaudio_io/speaker.py similarity index 84% rename from ghostos/prototypes/realtime/pyaudio_io/speaker.py rename to ghostos/framework/audio/pyaudio_io/speaker.py index 2825b240..04d5157e 100644 --- a/ghostos/prototypes/realtime/pyaudio_io/speaker.py +++ b/ghostos/framework/audio/pyaudio_io/speaker.py @@ -1,11 +1,11 @@ try: - from pyaudio import PyAudio + from pyaudio import PyAudio, paInt16 except ImportError: raise ImportError(f"Pyaudio is required, please install pyaudio or ghostos[audio] first") from typing import Optional from io import BytesIO -from ghostos.prototypes.realtime.abcd import Speaker +from ghostos.abcd.realtime import Speaker class PyAudioSpeaker(Speaker): @@ -19,7 +19,10 @@ def __enter__(self): raise RuntimeError("PyAudioSpeaker already initialized") self.buffer = BytesIO() self.stream = PyAudio().open( - + format=paInt16, + channels=1, + rate=44100, + output=True, ) return self @@ -28,12 +31,12 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.stream.stop_stream() self.stream.close() self.stream = None + self.buffer = None def speak(self, data: bytes): if self.stream is None: raise RuntimeError("PyAudioSpeaker is not started in context manager") self.stream.write(data) - self.buffer.write(data) def flush(self) -> bytes: if self.buffer is None: diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index 305c3d1e..aae2db63 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -337,7 +337,7 @@ def _update_subtasks(self): return tasks = self.get_task_briefs(*children) for tid, tb in tasks: - if TaskState.is_dead(tb.status): + if TaskState.is_dead(tb.state_name): continue children.append(tid) self.task.children = children diff --git a/ghostos/framework/openai_realtime/__init__.py b/ghostos/framework/openai_realtime/__init__.py new file mode 100644 index 00000000..8e513adb --- /dev/null +++ b/ghostos/framework/openai_realtime/__init__.py @@ -0,0 +1,4 @@ +from ghostos.framework.openai_realtime.app import RealtimeAppImpl +from ghostos.framework.openai_realtime.configs import OpenAIRealtimeAppConf +from ghostos.framework.openai_realtime.ws import OpenAIWebsocketsConf, OpenAIWSConnection +from ghostos.framework.openai_realtime.driver import OpenAIRealtimeDriver diff --git a/ghostos/framework/openai_realtime/app.py b/ghostos/framework/openai_realtime/app.py new file mode 100644 index 00000000..9eab659a --- /dev/null +++ b/ghostos/framework/openai_realtime/app.py @@ -0,0 +1,181 @@ +from typing import Optional, Tuple, List, Iterable +from .configs import OpenAIRealtimeAppConf +from .client import AppClient +from .state_of_client import SynchronizingState, StateOfClient, AppState +from .output import OutputBuffer, DefaultOutputBuffer +from collections import deque +from queue import Empty +from ghostos.abcd import Conversation +from ghostos.core.messages import ReceiverBuffer, Message +from ghostos.abcd.realtime import RealtimeApp, Listener, Speaker +from threading import Thread +import time + +__all__ = ['RealtimeAppImpl'] + + +class RealtimeAppImpl(RealtimeApp): + + def __init__( + self, + conf: OpenAIRealtimeAppConf, + conversation: Conversation, + listener: Listener, + speaker: Speaker, + ): + self._conversation = conversation + self._config = conf + self._listener = listener + self._speaker = speaker + self._started: bool = False + self._closed: bool = False + self._client: Optional[AppClient] = None + self._state: Optional[StateOfClient] = None + self._output: OutputBuffer = self._create_output_buffer() + self._operators: deque = deque() + self._threads: List[Thread] = [] + + def _create_output_buffer(self) -> OutputBuffer: + return DefaultOutputBuffer(self.is_closed, logger=self._conversation.logger) + + def start(self): + if self.is_closed(): + # todo + return + if self._started: + return + self._started = True + if self._client is None: + self._client = AppClient(self._config, self._conversation) + if self._state is None: + self._state = SynchronizingState(self._client) + self._state.on_init() + self._threads.append(Thread(target=self._main_state_thread)) + self._threads.append(Thread(target=self._speaking_thread)) + self._threads.append(Thread(target=self._listening_thread)) + for t in self._threads: + t.start() + + def _main_state_thread(self): + while not self.is_closed(): + state = self._state + if state is None: + state = SynchronizingState(self._client) + state.on_init() + continue + state: StateOfClient = state + + # run operators + if len(self._operators) > 0: + op = self._operators.popleft() + next_state = state.operate(op) + else: + # tick frame + next_state = state.tick_frame() + + # init next state + if next_state is not None: + self._operators.clear() + next_state.on_init() + self._state = next_state + state.destroy() + continue + + if state.recv_server_event(): + self._client.logger.info("handle server event") + continue + elif event := self._client.conversation.pop_event(): + self._client.handle_ghostos_event(event) + continue + + time.sleep(0.2) + + def _speaking_thread(self): + while not self.is_closed(): + response_id = self._output.get_response_id() + if response_id is None: + time.sleep(0.5) + continue + self._run_speaking_loop(response_id) + + def _run_speaking_loop(self, response_id: str): + with self._speaker: + while not self.is_closed(): + output_buffer = self._output + queue = output_buffer.speaking_queue(response_id) + if queue is None: + break + try: + data = queue.get(block=True, timeout=1) + if data is None: + break + self._client.audio_buffer_append(data) + except Empty: + continue + + def _listening_thread(self): + while not self.is_closed(): + client = self._client + if not client.is_listening(): + time.sleep(0.5) + continue + session_id = client.get_session_id() + self._run_listening_loop(session_id) + + def _run_listening_loop(self, session_id: str): + with self._listener: + while not self.is_closed(): + client = self._client + if not client.is_server_responding() or session_id != client.get_session_id(): + break + data = self._listener.hearing() + if data is not None: + client.audio_buffer_append(data) + else: + time.sleep(0.1) + + def close(self): + if self._closed: + return + self._closed = True + for t in self._threads: + t.join() + self._client.ctx.update_local_conversation() + self._client.close() + + def is_closed(self) -> bool: + return self._closed or self._conversation.is_closed() + + def messages(self) -> Iterable[Message]: + return self._output.get_outputted_messages() + + def set_mode(self, *, listening: bool): + self._client.ctx.listening = listening + + def state(self) -> Tuple[str, List[str]]: + if self.is_closed(): + return AppState.closed, [] + elif self._state is None: + return AppState.created, [] + + state_name = self._state.state_name() + operators = self._state.operators() + return state_name, operators + + def operate(self, operator: str) -> bool: + if self.is_closed(): + return False + if self._state is None: + return False + if self._state.allow(operator): + self._operators.append(operator) + return True + return False + + def fail(self, error: Exception) -> bool: + self._client.logger.exception(error) + self.close() + return True + + def output(self) -> Optional[ReceiverBuffer]: + return self._output.output_item() diff --git a/ghostos/prototypes/realtime/openai/client.py b/ghostos/framework/openai_realtime/client.py similarity index 67% rename from ghostos/prototypes/realtime/openai/client.py rename to ghostos/framework/openai_realtime/client.py index 5d49f69e..1e21b76b 100644 --- a/ghostos/prototypes/realtime/openai/client.py +++ b/ghostos/framework/openai_realtime/client.py @@ -1,11 +1,11 @@ -from typing import Dict, Optional, List, Set, Self, Union -from queue import Queue +import base64 +from typing import Dict, Optional, List from ghostos.abcd import Conversation from ghostos.contracts.logger import LoggerItf -from ghostos.contracts.assets import AudioAssets, FileInfo +from ghostos.contracts.assets import AudioAssets from ghostos.core.messages import Message, MessageType -from ghostos.core.runtime import GoThreadInfo, Event as GhostOSEvent, EventTypes as GhostOSEventTypes -from .configs import OpenAIRealtimeConf +from ghostos.core.runtime import Turn, Event as GhostOSEvent, EventTypes as GhostOSEventTypes +from .configs import OpenAIRealtimeAppConf from .ws import OpenAIWSConnection from .event_data_objects import MessageItem, SessionObject from .event_from_server import ServerSessionCreated @@ -17,9 +17,11 @@ ResponseCreate, InputAudioBufferCommit, InputAudioBufferClear, + InputAudioBufferAppend, ) from .state_of_server import ServerContext, SessionState from .state_of_client import Client +from .output import OutputBuffer class Context(ServerContext): @@ -27,6 +29,8 @@ class Context(ServerContext): def __init__( self, conversation: Conversation, + listening: bool, + output: OutputBuffer, logger: Optional[LoggerItf] = None, ): self.conversation: Conversation = conversation @@ -41,16 +45,11 @@ def __init__( self.buffer_message_ids: List[str] = [] self.buffer_messages: Dict[str, Message] = {} - self.error_messages: List[Message] = [] - """ errors """ + self.output_buffer: OutputBuffer = output + self.listening: bool = listening - # status. - self.unsent_message_ids: List[str] = [] - self.sent_message_ids: Set[str] = set() - self.response_id: Optional[str] = None - self.response_queue: Optional[Queue] = None - self.speaking_queue: Optional[Queue] = None - self.listening: bool = False + def get_responding_id(self) -> Optional[str]: + return self.output_buffer.get_response_id() def _reset_history_messages(self): self.history_messages: Dict[str, Message] = {} @@ -62,16 +61,17 @@ def _reset_history_messages(self): def _reset_buffer_messages(self): self.buffer_message_ids = [] self.buffer_messages: Dict[str, Message] = {} - self.unsent_message_ids = [] - self.sent_message_ids = set() def update_local_conversation(self) -> None: - self.stop_response(self.response_id) + self.output_buffer.stop_response() self.listening = False buffered = [] function_call = False for msg_id in self.buffer_message_ids: + if msg_id in self.history_messages: + # already updated. + continue message = self.buffer_messages[msg_id] if not message.is_complete(): continue @@ -92,19 +92,12 @@ def update_local_conversation(self) -> None: self.thread = self.conversation.get_thread(True) self._reset_history_messages() - def send_response_chunk(self, response_id: str, chunk: Optional[Message]) -> bool: - if chunk is None: - return False - if response_id != self.response_id: - return False - if self.response_queue is not None: - self.response_queue.put(chunk) - return True - return False + def respond_message_chunk(self, response_id: str, chunk: Optional[Message]) -> bool: + return self.output_buffer.add_response_chunk(response_id, chunk) - def send_error_message(self, error: str) -> None: + def respond_error_message(self, error: str) -> None: message = MessageType.ERROR.new(content=error) - self.error_messages.append(message) + self.output_buffer.add_error_message(message) def save_audio_data(self, item: MessageItem) -> None: if not item.has_audio(): @@ -135,108 +128,96 @@ def update_history_message(self, message: Optional[Message]) -> None: self.buffer_message_ids.append(message.msg_id) self.buffer_messages[message.msg_id] = message - if message.msg_id not in self.sent_message_ids: - self.unsent_message_ids.append(message.msg_id) - def add_message_item(self, item: MessageItem, previous_item_id: str) -> None: + def add_message_item(self, item: MessageItem, previous_item_id: Optional[str] = None) -> None: if item is None: return message = item.to_complete_message() - self.update_history_message(message) - if previous_item_id: - self_item_id = message.msg_id - new_buffer_ids = [] - inserted = False - for buffer_id in self.buffer_message_ids: - if buffer_id == self_item_id: - continue - else: - new_buffer_ids.append(buffer_id) - - if buffer_id == previous_item_id: - new_buffer_ids.append(self_item_id) - inserted = True - if not inserted: - new_buffer_ids.append(self_item_id) - self.buffer_message_ids = new_buffer_ids + if message is not None: + self.update_history_message(message) + self.output_buffer.add_message(message, previous_item_id) def start_response(self, response_id: str) -> None: - if response_id == self.response_id: - return - if self.response_queue is not None: - queue = self.response_queue - queue.put_nowait(None) - - self.response_id = response_id - self.response_queue = Queue() - self.speaking_queue = Queue() + if response_id: + self.output_buffer.start_response(response_id) def is_responding(self) -> bool: - return self.response_id is not None + return self.output_buffer.get_response_id() is not None def stop_response(self, response_id: str) -> bool: - if response_id == self.response_id: - self.response_id = None - if self.response_queue is not None: - self.response_queue.put(None) - if self.speaking_queue is not None: - self.speaking_queue.put(None) - self.response_queue = None - self.speaking_queue = None - return True - return False + if response_id != self.get_responding_id(): + return False + self.output_buffer.stop_response() + return True - def send_speaking_audio_chunk(self, response_id: str, data: bytes) -> bool: - if response_id == self.response_id and self.response_id is not None: - if self.speaking_queue is not None: - self.speaking_queue.put_nowait(data) - return True - else: - self.logger.error("speaking audio chunk but queue is not exists") - self.logger.debug( - "speaking audio chunk of response id %s is not current response %s", - response_id, self.response_id, - ) - return False + def respond_speaking_audio_chunk(self, response_id: str, data: bytes) -> bool: + if self.get_responding_id() != response_id: + return False + return self.respond_speaking_audio_chunk(response_id, data) class AppClient(Client): def __init__( self, - conf: OpenAIRealtimeConf, + conf: OpenAIRealtimeAppConf, conversation: Conversation, ): - self.conf: OpenAIRealtimeConf = conf + self.conf: OpenAIRealtimeAppConf = conf self.conversation: Conversation = conversation - self.logger: LoggerItf = conversation.logger - self.ctx = Context(conversation=conversation) + self.ctx: Context = Context(conversation=conversation, listening=True, logger=conversation.logger) + self.logger = conversation.logger self.connection: OpenAIWSConnection = self.connect() self.session_state: SessionState = self._create_session_state() self.synchronized: bool = False + self._closed: bool = False def connect(self) -> OpenAIWSConnection: + self._validate_closed() return OpenAIWSConnection( self.conf.ws_conf, - logger=self.logger, + logger=self.ctx.logger, ) + def _validate_closed(self): + if self._closed: + raise RuntimeError("App Client is closed") + + def close(self) -> None: + if self._closed: + return + self._closed = True + + def get_session_id(self) -> str: + self._validate_closed() + if self.session_state: + return self.session_state.session_id + return "" + def reconnect(self) -> None: + self._validate_closed() if self.connection is not None: connection = self.connection connection.close() if self.session_state is not None: self.session_state.destroy() + self.session_state = None self.connection: OpenAIWSConnection = self.connect() self.session_state: SessionState = self._create_session_state() self.synchronized = False - def _create_session_state(self) -> SessionState: - ce = SessionUpdate( - session=self.get_session_obj(), + def audio_buffer_append(self, buffer: bytes) -> None: + content = base64.b64encode(buffer).decode() + ce = InputAudioBufferAppend( + audio=content ) self._send_client_event(ce) + + def is_listening(self) -> bool: + return self.ctx.listening and not self.ctx.is_responding() + + def _create_session_state(self) -> SessionState: e = self.connection.recv(timeout=self.conf.session_created_timeout, timeout_error=True) se = ServerSessionCreated(**e) return SessionState(self.ctx, se) @@ -246,6 +227,11 @@ def synchronize_server_session(self): return previous_item_id = "" count = 0 + ce = SessionUpdate( + session=self.get_session_obj(), + ) + self._send_client_event(ce) + for msg_id in self.ctx.history_message_order: message = self.ctx.history_messages[msg_id] self._send_message_to_server(message, previous_item_id) @@ -254,34 +240,29 @@ def synchronize_server_session(self): count += 1 self.logger.info("Synchronizing server session done with item %d", count) - def update_local_conversation(self) -> None: - # todo: function calling - self.ctx.update_local_conversation() - def cancel_responding(self) -> bool: if self.ctx.is_responding(): ce = ResponseCancel() self._send_client_event(ce) - self.ctx.stop_response(self.ctx.response_id) + response_id = self.ctx.get_responding_id() + self.ctx.stop_response(response_id) return True return False def start_listening(self) -> bool: if not self.ctx.listening: self.ctx.listening = True - self.ctx.stop_response(self.ctx.response_id) + self.cancel_responding() return True return False def stop_listening(self) -> bool: if self.ctx.listening: + # stop listening self.ctx.listening = False return True return False - def is_listening(self) -> bool: - return self.ctx.listening - def commit_audio_input(self) -> bool: if self.ctx.listening: self.ctx.listening = False @@ -315,7 +296,7 @@ def create_response(self) -> bool: self._send_client_event(ce) return True - def is_responding(self) -> bool: + def is_server_responding(self) -> bool: return self.ctx.is_responding() def receive_server_event(self) -> bool: @@ -325,7 +306,11 @@ def receive_server_event(self) -> bool: return True return False - def _send_message_to_server(self, message: Message, previous_item_id: str = "") -> None: + def handle_ghostos_event(self, event: GhostOSEvent): + for msg in Turn.iter_event_message(event): + self._send_message_to_server(msg) + + def _send_message_to_server(self, message: Message, previous_item_id: Optional[str] = None) -> None: ce = ConversationItemCreate( previous_item_id=previous_item_id, item=MessageItem.from_message(message), @@ -335,5 +320,5 @@ def _send_message_to_server(self, message: Message, previous_item_id: str = "") def _send_client_event(self, event: ClientEvent): self.connection.send(event.to_dict()) - def send_error_message(self, error: str) -> None: - self.ctx.send_error_message(error) + def respond_error_message(self, error: str) -> None: + self.ctx.respond_error_message(error) diff --git a/ghostos/prototypes/realtime/openai/configs.py b/ghostos/framework/openai_realtime/configs.py similarity index 54% rename from ghostos/prototypes/realtime/openai/configs.py rename to ghostos/framework/openai_realtime/configs.py index b56b2b29..65019ce8 100644 --- a/ghostos/prototypes/realtime/openai/configs.py +++ b/ghostos/framework/openai_realtime/configs.py @@ -1,10 +1,18 @@ -from typing import Optional, Literal -from pydantic import BaseModel, Field +from typing import Optional, ClassVar +from pydantic import Field +from ghostos.abcd.realtime import RealtimeAppConfig +from ghostos.contracts.configs import YamlConfig from .ws import OpenAIWebsocketsConf from .event_data_objects import SessionObject +__all__ = ['OPENAI_REALTIME_DRIVER_NAME', 'OpenAIRealtimeAppConf'] + +OPENAI_REALTIME_DRIVER_NAME = "openai_realtime_driver" + + +class OpenAIRealtimeAppConf(YamlConfig, RealtimeAppConfig): + relative_path: ClassVar[str] = "openai_realtime_config.yml" -class OpenAIRealtimeConf(BaseModel): name: str = Field( description="Name of the agent", ) @@ -19,6 +27,8 @@ class OpenAIRealtimeConf(BaseModel): default=None, description="basic session settings, if None, use openai default session", ) - start_mode: Literal["listening", "idle"] = Field("idle") session_created_timeout: int = Field(10) + + def driver_name(self) -> str: + return OPENAI_REALTIME_DRIVER_NAME diff --git a/ghostos/framework/openai_realtime/driver.py b/ghostos/framework/openai_realtime/driver.py new file mode 100644 index 00000000..a2e5a592 --- /dev/null +++ b/ghostos/framework/openai_realtime/driver.py @@ -0,0 +1,28 @@ +from typing import Optional + +from ghostos.abcd import Conversation +from ghostos.abcd.realtime import RealtimeDriver, C, Listener, Speaker, RealtimeApp +from ghostos.framework.openai_realtime.configs import OPENAI_REALTIME_DRIVER_NAME, OpenAIRealtimeAppConf +from ghostos.framework.openai_realtime.app import RealtimeAppImpl + +__all__ = ['OpenAIRealtimeDriver'] + + +class OpenAIRealtimeDriver(RealtimeDriver[OpenAIRealtimeAppConf]): + + def driver_name(self) -> str: + return OPENAI_REALTIME_DRIVER_NAME + + def create( + self, + config: OpenAIRealtimeAppConf, + conversation: Conversation, + listener: Optional[Listener] = None, + speaker: Optional[Speaker] = None, + ) -> RealtimeApp: + return RealtimeAppImpl( + config, + conversation, + listener, + speaker, + ) diff --git a/ghostos/prototypes/realtime/openai/event_data_objects.py b/ghostos/framework/openai_realtime/event_data_objects.py similarity index 98% rename from ghostos/prototypes/realtime/openai/event_data_objects.py rename to ghostos/framework/openai_realtime/event_data_objects.py index 9b242835..3c8bca92 100644 --- a/ghostos/prototypes/realtime/openai/event_data_objects.py +++ b/ghostos/framework/openai_realtime/event_data_objects.py @@ -265,7 +265,3 @@ class SessionObject(SessionObjectBase): tool_choice: str = Field(default="auto") temperature: float = Field(default=0.8) max_response_output_tokens: Union[int, Literal['inf']] = Field(default='inf') - - def get_update_event(self) -> dict: - data = self.model_dump(exclude={'id'}) - return data diff --git a/ghostos/prototypes/realtime/openai/event_from_client.py b/ghostos/framework/openai_realtime/event_from_client.py similarity index 98% rename from ghostos/prototypes/realtime/openai/event_from_client.py rename to ghostos/framework/openai_realtime/event_from_client.py index dc78263c..174507db 100644 --- a/ghostos/prototypes/realtime/openai/event_from_client.py +++ b/ghostos/framework/openai_realtime/event_from_client.py @@ -115,7 +115,7 @@ class InputAudioBufferClear(ClientEvent): class ConversationItemCreate(ClientEvent): type: ClassVar[str] = ClientEventType.conversation_item_create.value - previous_item_id: str = Field("") + previous_item_id: Optional[str] = Field(None) item: MessageItem = Field() diff --git a/ghostos/prototypes/realtime/openai/event_from_server.py b/ghostos/framework/openai_realtime/event_from_server.py similarity index 100% rename from ghostos/prototypes/realtime/openai/event_from_server.py rename to ghostos/framework/openai_realtime/event_from_server.py diff --git a/ghostos/framework/openai_realtime/output.py b/ghostos/framework/openai_realtime/output.py new file mode 100644 index 00000000..9266c1ab --- /dev/null +++ b/ghostos/framework/openai_realtime/output.py @@ -0,0 +1,185 @@ +import time +from abc import ABC, abstractmethod +from typing import List, Optional, Dict, Iterable, Callable +from queue import Queue +from ghostos.contracts.logger import LoggerItf +from ghostos.core.messages import Message, ReceiverBuffer, SequencePipe + + +class OutputBuffer(ABC): + @abstractmethod + def stop_response(self): + pass + + @abstractmethod + def start_response(self, response_id: str): + pass + + @abstractmethod + def add_response_chunk(self, response_id: str, chunk: Message) -> bool: + pass + + @abstractmethod + def add_message(self, message: Message, previous_item_id: Optional[str]) -> bool: + pass + + @abstractmethod + def get_outputted_messages(self) -> List[Message]: + pass + + @abstractmethod + def get_response_id(self) -> Optional[str]: + pass + + @abstractmethod + def add_audio_output(self, response_id: str, data: bytes, filetype: str = "wav") -> bool: + pass + + @abstractmethod + def add_error_message(self, error: Message): + pass + + @abstractmethod + def output_item(self) -> Optional[ReceiverBuffer]: + pass + + @abstractmethod + def speaking_queue(self, response_id: str) -> Optional[Queue]: + pass + + +class DefaultOutputBuffer(OutputBuffer): + + def __init__( + self, + close_check: Callable[[], bool], + logger: LoggerItf, + ): + self.logger = logger + # status. + self.response_id: Optional[str] = None + self.response_chunks: Optional[List[Message]] = None + self.speak_queue: Optional[Queue] = None + self.close_check = close_check + + self.outputted_message_ids: List[str] = [] + self.outputted_messages: Dict[str, Message] = {} + self.error_messages: List[Message] = [] + self.unsent_message_ids: List[str] = [] + + def stop_response(self): + self.response_id = None + self.response_chunks = None + if self.speak_queue is not None: + self.speak_queue.put(None) + self.speak_queue = None + + def start_response(self, response_id: str): + self.response_id = response_id + self.response_chunks = [] + self.speak_queue = Queue() + + def add_message(self, message: Message, previous_item_id: Optional[str]) -> bool: + if message is None or not message.is_complete(): + return False + msg_id = message.msg_id + if msg_id not in self.outputted_message_ids: + self.outputted_message_ids.append(msg_id) + self.unsent_message_ids.append(msg_id) + self.outputted_messages[msg_id] = message + if previous_item_id is not None: + outputted_message_ids = [] + current_message_id = msg_id + inserted = False + for msg_id in self.outputted_message_ids: + if msg_id == current_message_id: + continue + outputted_message_ids.append(msg_id) + if msg_id == previous_item_id: + outputted_message_ids.append(current_message_id) + inserted = True + if not inserted: + outputted_message_ids.append(current_message_id) + self.outputted_message_ids = outputted_message_ids + + return True + + def add_response_chunk(self, response_id: str, chunk: Message) -> bool: + if chunk is None: + return False + if response_id != self.response_id: + return False + if self.response_chunks is None: + self.response_chunks = [chunk] + else: + self.response_chunks.append(chunk) + return True + + def get_outputted_messages(self) -> List[Message]: + messages = [] + for msg_id in self.outputted_message_ids: + message = self.outputted_messages[msg_id] + messages.append(message) + return messages + + def get_response_id(self) -> Optional[str]: + return self.response_id + + def add_audio_output(self, response_id: str, data: bytes, filetype: str = "wav") -> bool: + if response_id != self.response_id: + return False + queue = self.speak_queue + if queue is None: + return False + queue.put(data) + + def add_error_message(self, error: Message): + self.error_messages.append(error) + + def speaking_queue(self, response_id: str) -> Optional[Queue]: + return self.speak_queue + + def output_item(self) -> Optional[ReceiverBuffer]: + chunks = self._output_chunks() + if chunks is None: + return None + + sent = SequencePipe().across(chunks) + return ReceiverBuffer.new(sent) + + def _output_chunks(self) -> Optional[Iterable[Message]]: + if len(self.error_messages) > 0: + error = self.error_messages.pop(0) + return [error] + + if len(self.unsent_message_ids) > 0: + msg_id = self.unsent_message_ids.pop(0) + if msg_id not in self.outputted_message_ids: + message = self.outputted_messages[msg_id] + return [message] + + if self.response_id is None: + return None + + chunk_idx = 0 + output_item_id = "" + response_id = self.response_id + while not self.close_check(): + if response_id != self.response_id or self.response_chunks is None: + break + + if output_item_id in self.outputted_messages: + continue + + if len(self.response_chunks) > chunk_idx: + item = self.response_chunks[chunk_idx] + output_item_id = item.msg_id + if item.is_complete(): + if output_item_id not in self.outputted_messages: + self.outputted_messages[output_item_id] = item + yield item + break + chunk_idx += 1 + yield item + else: + time.sleep(0.1) diff --git a/ghostos/prototypes/realtime/openai/state_of_client.py b/ghostos/framework/openai_realtime/state_of_client.py similarity index 84% rename from ghostos/prototypes/realtime/openai/state_of_client.py rename to ghostos/framework/openai_realtime/state_of_client.py index 2ff5d5b7..48761134 100644 --- a/ghostos/prototypes/realtime/openai/state_of_client.py +++ b/ghostos/framework/openai_realtime/state_of_client.py @@ -1,12 +1,13 @@ from __future__ import annotations from typing import List, Optional, Tuple, Protocol, Self from abc import ABC, abstractmethod -from .configs import OpenAIRealtimeConf -from ghostos.core.messages import Message +from .configs import OpenAIRealtimeAppConf +from ghostos.core.runtime import Event as GhostOSEvent from enum import Enum class AppState(str, Enum): + created = "created" closed = "closed" connecting = "connecting" @@ -42,7 +43,7 @@ class OperatorName(str, Enum): class Client(Protocol): - conf: OpenAIRealtimeConf + conf: OpenAIRealtimeAppConf @abstractmethod def reconnect(self) -> None: @@ -59,10 +60,6 @@ def synchronize_server_session(self): """ pass - @abstractmethod - def update_local_conversation(self) -> None: - pass - @abstractmethod def cancel_responding(self) -> bool: pass @@ -79,6 +76,10 @@ def stop_listening(self) -> bool: def is_listening(self) -> bool: pass + @abstractmethod + def audio_buffer_append(self, buffer: bytes) -> None: + pass + @abstractmethod def commit_audio_input(self) -> bool: """ @@ -96,7 +97,7 @@ def create_response(self) -> bool: pass @abstractmethod - def is_responding(self) -> bool: + def is_server_responding(self) -> bool: pass @abstractmethod @@ -104,7 +105,11 @@ def receive_server_event(self) -> bool: pass @abstractmethod - def send_error_message(self, error: str) -> None: + def respond_error_message(self, error: str) -> None: + pass + + @abstractmethod + def handle_ghostos_event(self, event: GhostOSEvent): pass @@ -124,14 +129,14 @@ def on_init(self): pass @abstractmethod - def status(self) -> str: + def state_name(self) -> str: """ :return: """ pass - def rotate(self) -> bool: + def recv_server_event(self) -> bool: return self.client.receive_server_event() @abstractmethod @@ -158,7 +163,7 @@ def default_mode(self) -> Self: return ListeningState(self.client) elif self.client.conf.start_mode == "idle": return IdleState(self.client) - elif self.client.is_responding(): + elif self.client.is_server_responding(): return RespondingState(self.client) else: return IdleState(self.client) @@ -169,7 +174,7 @@ class ConnectingState(StateOfClient): connecting the websocket server """ - def status(self) -> str: + def state_name(self) -> str: # when connecting nothing is able to do. return AppState.connecting.value @@ -182,7 +187,7 @@ def allow(self, operator: str) -> bool: def operate(self, operator: str) -> Optional[Self]: return None - def rotate(self) -> bool: + def recv_server_event(self) -> bool: return False def operators(self) -> List[str]: @@ -197,13 +202,13 @@ class SynchronizingState(StateOfClient): synchronizing conversation history to the websocket server """ - def status(self) -> str: + def state_name(self) -> str: return AppState.synchronizing.value def allow(self, operator: str) -> bool: return False - def rotate(self) -> bool: + def recv_server_event(self) -> bool: return False def operate(self, operator: str) -> Optional[Self]: @@ -224,7 +229,7 @@ class ListeningState(StateOfClient): def on_init(self): self.client.start_listening() - def status(self) -> str: + def state_name(self) -> str: return AppState.listening.value def operators(self) -> List[str]: @@ -259,7 +264,7 @@ def operate(self, operator: str) -> Optional[Self]: return None def tick_frame(self) -> Optional[Self]: - if self.client.is_responding(): + if self.client.is_server_responding(): # responding not cancel listening return RespondingState(self.client) return None @@ -268,14 +273,14 @@ def tick_frame(self) -> Optional[Self]: class CreateResponseState(StateOfClient): def on_init(self): - if self.client.is_responding(): + if self.client.is_server_responding(): self.client.cancel_responding() if self.client.is_listening(): self.client.commit_audio_input() self.client.create_response() return - def status(self) -> Tuple[str, List[str]]: + def state_name(self) -> Tuple[str, List[str]]: return AppState.waiting_response, self.operators() def operate(self, operator: str) -> Optional[Self]: @@ -287,7 +292,7 @@ def operators(self) -> List[str]: return [] def tick_frame(self) -> Optional[Self]: - if self.client.is_responding(): + if self.client.is_server_responding(): return RespondingState(self.client) return None @@ -295,20 +300,20 @@ def tick_frame(self) -> Optional[Self]: class RespondingState(StateOfClient): def on_init(self): - if not self.client.is_responding(): - self.client.send_error_message("enter responding state but server is not responding") + if not self.client.is_server_responding(): + self.client.respond_error_message("enter responding state but server is not responding") return - def status(self) -> str: + def state_name(self) -> str: return AppState.responding.value def operate(self, operator: str) -> Optional[Self]: if operator == OperatorName.cancel_responding.value: - if self.client.is_responding(): + if self.client.is_server_responding(): self.client.cancel_responding() return self.default_mode() elif operator == OperatorName.listen.value: - if self.client.is_responding(): + if self.client.is_server_responding(): self.client.cancel_responding() return ListeningState(self.client) else: @@ -321,7 +326,7 @@ def operators(self) -> List[str]: ] def tick_frame(self) -> Optional[Self]: - if self.client.is_responding(): + if self.client.is_server_responding(): return None else: return self.default_mode() @@ -332,13 +337,12 @@ class IdleState(StateOfClient): def on_init(self): if self.client.is_listening(): self.client.stop_listening() - elif self.client.is_responding(): + elif self.client.is_server_responding(): self.client.cancel_responding() # when idle, update local conversation. - self.client.update_local_conversation() return - def status(self) -> str: + def state_name(self) -> str: return AppState.idle.value def operate(self, operator: str) -> Optional[Self]: @@ -352,5 +356,6 @@ def operators(self) -> List[str]: ] def tick_frame(self) -> Optional[Self]: - self.client.update_local_conversation() + if self.client.is_listening(): + return ListeningState(self.client) return None diff --git a/ghostos/prototypes/realtime/openai/state_of_server.py b/ghostos/framework/openai_realtime/state_of_server.py similarity index 95% rename from ghostos/prototypes/realtime/openai/state_of_server.py rename to ghostos/framework/openai_realtime/state_of_server.py index 4186f085..1c610929 100644 --- a/ghostos/prototypes/realtime/openai/state_of_server.py +++ b/ghostos/framework/openai_realtime/state_of_server.py @@ -2,10 +2,10 @@ from abc import ABC, abstractmethod from typing import Protocol, Optional, Dict, Self, List, Union from .event_from_server import * -from .configs import SessionObject from .event_data_objects import ( MessageItem, RateLimit, + SessionObject, ) from pydantic import ValidationError from ghostos.core.messages import Message, MessageType @@ -16,11 +16,11 @@ class ServerContext(Protocol): logger: LoggerItf @abstractmethod - def send_response_chunk(self, response_id: str, chunk: Union[Message, None]) -> bool: + def respond_message_chunk(self, response_id: str, chunk: Union[Message, None]) -> bool: pass @abstractmethod - def send_error_message(self, error: str) -> None: + def respond_error_message(self, error: str) -> None: pass @abstractmethod @@ -28,19 +28,27 @@ def update_history_message(self, message: Union[Message, None]) -> None: pass @abstractmethod - def add_message_item(self, item: MessageItem, previous_item_id: str) -> None: + def update_local_conversation(self) -> None: + pass + + @abstractmethod + def add_message_item(self, item: MessageItem, previous_item_id: Optional[str] = None) -> None: pass @abstractmethod def start_response(self, response_id: str) -> None: pass + @abstractmethod + def get_responding_id(self) -> Optional[str]: + pass + @abstractmethod def stop_response(self, response_id: str) -> bool: pass @abstractmethod - def send_speaking_audio_chunk(self, response_id: str, data: bytes) -> bool: + def respond_speaking_audio_chunk(self, response_id: str, data: bytes) -> bool: pass @abstractmethod @@ -90,7 +98,7 @@ def recv_invalid_event(self, event: dict): error = "Received invalid event: %r" % se self.ctx.logger.error(error) # send error message. - return self.ctx.send_error_message(error) + return self.ctx.respond_error_message(error) def ack_server_event(self, event: ServerEvent): self.ctx.logger.info( @@ -486,6 +494,7 @@ def _on_response_done(self, event: dict) -> None: # update history messages again for item in rd.response.output: self.ctx.update_history_message(item.to_complete_message()) + self.ctx.update_local_conversation() return self.ack_server_event(rd) def _on_response_output_item_added(self, event: dict) -> None: @@ -519,7 +528,7 @@ def is_done(self) -> bool: return self.item.status in {"completed"} def _on_response_output_item_added(self, event: ResponseOutputItemAdded): - self.ctx.send_response_chunk(event.response_id, event.item.to_message_head()) + self.ctx.respond_message_chunk(event.response_id, event.item.to_message_head()) return self.ack_server_event(event) def recv(self, event: dict) -> None: @@ -543,7 +552,7 @@ def recv(self, event: dict) -> None: elif ServerEventType.response_text_delta.value == type_name: se = ResponseTextDelta(**event) - self.ctx.send_response_chunk(se.response_id, se.as_message_chunk()) + self.ctx.respond_message_chunk(se.response_id, se.as_message_chunk()) return self.ack_server_event(se) elif ServerEventType.response_text_done.value == type_name: @@ -553,7 +562,7 @@ def recv(self, event: dict) -> None: elif ServerEventType.response_audio_delta.value == type_name: se = ResponseAudioDelta(**event) - self.ctx.send_speaking_audio_chunk(se.response_id, se.get_audio_bytes()) + self.ctx.respond_speaking_audio_chunk(se.response_id, se.get_audio_bytes()) return self.ack_server_event(se) elif ServerEventType.response_audio_done.value == type_name: @@ -562,7 +571,7 @@ def recv(self, event: dict) -> None: elif ServerEventType.response_audio_transcript_delta.value == type_name: se = ResponseAudioTranscriptDelta(**event) - self.ctx.send_response_chunk(se.response_id, se.as_message_chunk()) + self.ctx.respond_message_chunk(se.response_id, se.as_message_chunk()) return self.ack_server_event(se) elif ServerEventType.response_audio_transcript_done.value == type_name: @@ -571,7 +580,7 @@ def recv(self, event: dict) -> None: elif ServerEventType.response_function_call_arguments_delta.value == type_name: se = ResponseFunctionCallArgumentsDelta(**event) - self.ctx.send_response_chunk(se.response_id, se.as_message_chunk()) + self.ctx.respond_message_chunk(se.response_id, se.as_message_chunk()) return self.ack_server_event(se) elif ServerEventType.response_function_call_arguments_done.value == type_name: @@ -583,7 +592,7 @@ def recv(self, event: dict) -> None: def _on_response_output_item_done(self, event: ResponseOutputItemDone) -> None: self.item = event.item - self.ctx.send_response_chunk(event.response_id, event.item.to_complete_message()) + self.ctx.respond_message_chunk(event.response_id, event.item.to_complete_message()) return self.ack_server_event(event) def _on_response_content_part_added(self, event: ResponseContentPartAdded) -> None: diff --git a/ghostos/prototypes/realtime/openai/ws.py b/ghostos/framework/openai_realtime/ws.py similarity index 84% rename from ghostos/prototypes/realtime/openai/ws.py rename to ghostos/framework/openai_realtime/ws.py index 70a407b6..8f0abcaf 100644 --- a/ghostos/prototypes/realtime/openai/ws.py +++ b/ghostos/framework/openai_realtime/ws.py @@ -2,19 +2,18 @@ import time import socks -from typing import Union +from typing import Union, Optional +import urllib3.util import websockets import json import logging from websockets.sync.client import connect as ws_connect, ClientConnection -from threading import Thread -from queue import Queue, Empty -from collections import deque -from pydantic import BaseModel, Field -from ghostos.prototypes.realtime.abcd import ChanIn from ghostos.contracts.logger import LoggerItf, get_console_logger from ghostos.helpers import get_openai_key +from pydantic import BaseModel, Field + +__all__ = ['OpenAIWSConnection', 'OpenAIWebsocketsConf'] # 拆一个 base model 方便未来做成表单. @@ -23,6 +22,7 @@ class OpenAIWebsocketsConf(BaseModel): default_factory=get_openai_key, description="The OpenAI key used to authenticate with WebSockets.", ) + proxy: Optional[str] = Field(None, description="The proxy to connect to. only support socket v5 now") uri: str = Field("wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01") close_check: float = Field( default=0.5, @@ -42,19 +42,19 @@ def __init__( self, conf: OpenAIWebsocketsConf, *, - sock=None, logger: LoggerItf = None, ): """ - :param conf: - :param sock: :param logger: """ self._running = False self._closed = False self._logger = logger if logger else logging.getLogger() self._conf = conf + sock = None + if conf.proxy is not None: + sock = self._create_socket(conf.proxy, conf.uri) # 同步创建 connection. self._ws = ws_connect( uri=self._conf.uri, @@ -65,6 +65,19 @@ def __init__( sock=sock, ) + def _create_socket(self, proxy: str, uri: str): + parsed = urllib3.util.parse_url(proxy) + if parsed.scheme != "socks5": + raise NotImplementedError(f"Only socks5 is supported, got {parsed.scheme}") + host = parsed.hostname + port = parsed.port + s = socks.socksocket() + s.set_proxy(socks.SOCKS5, host, port) + + uri_parsed = urllib3.util.parse_url(uri) + s.connect((uri_parsed.hostname, 443)) + return s + def client(self) -> ClientConnection: if self._closed: raise RuntimeError("Connection was already stopped") @@ -129,17 +142,12 @@ def closed(self) -> bool: import os from ghostos.helpers import Timeleft - s = socks.socksocket() - s.set_proxy(socks.SOCKS5, "localhost", 1080) - s.connect(("api.openai.com", 443)) - socket = s - _token = os.environ["OPENAI_API_KEY"] print("+++++ token", _token) _conf = OpenAIWebsocketsConf(token=_token) + _conf.proxy = "socks5://127.0.0.1:1080" _c = connect( _conf, - sock=socket, logger=get_console_logger(debug=True), ) diff --git a/ghostos/framework/realtime/__init__.py b/ghostos/framework/realtime/__init__.py new file mode 100644 index 00000000..403e7baf --- /dev/null +++ b/ghostos/framework/realtime/__init__.py @@ -0,0 +1,2 @@ +from ghostos.abcd.realtime import Realtime +from ghostos.framework.realtime.defaults import ConfigBasedRealtimeProvider diff --git a/ghostos/framework/realtime/defaults.py b/ghostos/framework/realtime/defaults.py new file mode 100644 index 00000000..2a49a907 --- /dev/null +++ b/ghostos/framework/realtime/defaults.py @@ -0,0 +1,59 @@ +from typing import Optional, Dict + +from ghostos.abcd import Conversation +from ghostos.abcd.realtime import ( + Realtime, RealtimeConfig, RealtimeDriver, Listener, Speaker, RealtimeAppConfig, + RealtimeApp, +) +from ghostos.contracts.configs import YamlConfig, Configs +from ghostos.container import Container, Provider, INSTANCE +from ghostos.framework.openai_realtime import OpenAIRealtimeDriver + + +class BasicRealtimeConfig(RealtimeConfig, YamlConfig): + relative_path = "realtime_conf.yml" + + +class ConfigsBasedRealtime(Realtime): + + def __init__(self, configs: Configs): + self._drivers: Dict[str: RealtimeDriver] = {} + self._configs = configs + + def create( + self, + conversation: Conversation, + listener: Listener, + speaker: Speaker, + app_name: str = "", + config: Optional[RealtimeAppConfig] = None, + ) -> RealtimeApp: + realtime_conf = self.get_config() + if config is None: + if not app_name: + app_name = realtime_conf.default + config = realtime_conf.get_app_conf(app_name) + if config is None: + raise NotImplementedError(f"No config for {app_name}") + driver: RealtimeDriver = self._drivers.get(config.driver_name()) + if driver is None: + raise NotImplementedError(f"No driver for {config.driver_name()}") + return driver.create(config, conversation, listener, speaker) + + def get_config(self) -> RealtimeConfig: + return self._configs.get(BasicRealtimeConfig) + + def register(self, driver: RealtimeDriver): + self._drivers[driver.driver_name()] = driver + + +class ConfigBasedRealtimeProvider(Provider[Realtime]): + + def singleton(self) -> bool: + return True + + def factory(self, con: Container) -> Optional[INSTANCE]: + configs = con.get(Configs) + realtime = ConfigsBasedRealtime(configs) + realtime.register(OpenAIRealtimeDriver()) + return realtime diff --git a/ghostos/helpers/timeutils.py b/ghostos/helpers/timeutils.py index 87c30479..8b2fc0d1 100644 --- a/ghostos/helpers/timeutils.py +++ b/ghostos/helpers/timeutils.py @@ -20,7 +20,7 @@ def alive(self) -> bool: def passed(self) -> float: now = time.time() - return now - self.start + return round(now - self.start, 4) def timestamp_datetime() -> datetime: diff --git a/ghostos/prototypes/realtime/README.md b/ghostos/prototypes/realtime/README.md deleted file mode 100644 index f8f2aaad..00000000 --- a/ghostos/prototypes/realtime/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# test about openai realtime-api - -## abcd - -1. agent is not application itself -2. - - - - -## test production steps - -1. websocket connection. -2. console chatter -3. streamlit chatter: with conversation history -4. \ No newline at end of file diff --git a/ghostos/prototypes/realtime/openai/__states.py b/ghostos/prototypes/realtime/openai/__states.py deleted file mode 100644 index 17e99141..00000000 --- a/ghostos/prototypes/realtime/openai/__states.py +++ /dev/null @@ -1,431 +0,0 @@ -from __future__ import annotations -from abc import abstractmethod, ABC -from typing import ( - Self, Literal, List, Optional, Union, Dict, Protocol, ClassVar, Type, Callable, Tuple, - Iterable, -) -from enum import Enum -from ghostos.prototypes.realtime.abcd import ( - ConversationProtocol, - State, - OperationType, RealtimeOperator, -) -from ghostos.helpers import uuid -from ghostos.abcd import Conversation -from ghostos.contracts.logger import LoggerItf, get_logger -from ghostos.core.messages import Message, MessageType -from ghostos.container import Container -from pydantic import BaseModel, Field -from collections import deque -from .event_from_server import * -from .ws import OpenAIWebsocketsConf, OpenAIWSConnection -from .configs import OpenAIRealtimeConf -from .broadcast import SimpleBroadcaster, Broadcaster -from .utils import parse_message_to_client_event, parse_server_event_to_message - - -class StateCtx: - - def __init__( - self, - conf: OpenAIRealtimeConf, - container: Container, - conversation: Conversation, - logger: LoggerItf, - connection: Optional[OpenAIWSConnection], - ): - self.conf: OpenAIRealtimeConf = conf - self.container = container - self.logger: LoggerItf = logger - self.conversation: Conversation = conversation - self.connection: Optional[OpenAIWSConnection] = connection - - def recv_from_server_nowait(self) -> Union[dict, None]: - if self.connection is None: - return None - return self.connection.recv(timeout=0) - - def publish_event(self, event: Union[dict, None]): - type_ = ServerEventType.get_type(event) - self.broadcaster.publish(type_, event) - - def send_to_server(self, event: ClientEvent): - if not self.connection: - raise RuntimeError("No connection to send event") - self.connection.send(event.to_openai_event()) - - def messages(self) -> List[Message]: - raise NotImplementedError("todo") - - -class AbsState(State, ABC): - """ - base state class - """ - - prior_ops: ClassVar[Set[OperatorType]] - pending_ops: ClassVar[Set[OperatorType]] - block_ops: ClassVar[Dict[str, OperatorType]] - - include_events: ClassVar[Union[Set[ServerEventType], None]] = None - exclude_events: ClassVar[Union[Set[ServerEventType], None]] = None - - def __init__( - self, - ctx: StateCtx, - ): - self._ctx = ctx - self._op_queue = deque() - self._inited = False - - @abstractmethod - def _on_state_created(self) -> None: - pass - - def _run_operator(self, op: AbsOperator) -> State: - return op.run(self._ctx) - - def conversation(self) -> ConversationProtocol: - return self._ctx.conversation - - def operate(self, op: AbsOperator) -> Tuple[OperationType, Union[str, None]]: - if not self._inited: - self._on_state_created() - - type_ = op.type - if type_ in self.block_ops: - return "blocked", self.block_ops[type_] - elif type_ in self.prior_ops: - self._op_queue.insert(0, op) - return "", None - elif type_ in self.pending_ops: - self._op_queue.append(op) - return "queued", None - else: - return "illegal", "operation not allowed" - - def run_operator(self) -> Union[State, None]: - if not self._inited: - self._inited = True - # block when ticked. - self._on_state_created() - if len(self._op_queue) == 0: - return None - op = self._op_queue.popleft() - return self._run_operator(op) - - def run_frame(self) -> Union[State, None]: - e = self._ctx.recv_from_server_nowait() - if e is None: - return None - - # ignore event - e = self._filter_server_event(e) - if e is None: - return None - - # broadcast first. - self._ctx.publish_event(e) - # add conversation item - self._filter_conversation_message(e) - - # handle server event that should add message to conversation. - return self._handle_server_event(e) - - def _filter_server_event(self, e: dict) -> Union[dict, None]: - # ignore checks - type_ = ServerEventType.get_type(e) - if self.include_events and type_ not in self.include_events: - # ignore - return None - if self.exclude_events and type_ in self.exclude_events: - return None - return e - - def _filter_conversation_message(self, e: dict) -> None: - # add conversation item. - message = parse_server_event_to_message(e) - if message is not None: - self._ctx.conversation.add(message) - - def _handle_server_event(self, event: dict) -> Union[State, None]: - # handle later - type_ = ServerEventType.get_type(event) - method = f"_on_{type_.name}" - if hasattr(self, method): - _new_state = getattr(self, method)(event) - if _new_state: - return _new_state - # default is ignore - return None - - def _on_response_created(self, event: dict) -> Union[State, None]: - return RespondingState(self._ctx) - - def join(self): - del self._ctx - - -class ConnectingState(AbsState): - """ - create websocket connection - """ - state_name = StateName.connecting - - prior_ops = { - OperatorName.stop, - OperatorName.session_update, - } - block_ops = { - OperatorName.reconnect, - } - include_events = { - ServerEventType.session_created, - } - - def _on_state_created(self): - if self._ctx.connection is not None: - # close the old one - self._ctx.connection.close() - self._ctx.connection = None - - socket = None - if self._ctx.connect_sock: - socket = self._ctx.connect_sock() - # always connect at first. - self._connection = OpenAIWSConnection( - self._ctx.conf.ws_conf, - sock=socket, - logger=self._ctx.logger, - ) - - def _on_session_created(self, event: dict) -> Union[State, None]: - e = ServerSessionCreated(**event) - - # align the session - session_obj = self._ctx.get_session_obj() - # shall update session - if session_obj: - # update session - send = ClientSessionUpdateEvent(session=session_obj) - self._ctx.send_to_server(send) - # update conversation - messages = self._ctx.messages() - for item in messages: - e = parse_message_to_client_event(item) - if e is not None: - self._ctx.send_to_server(e) - - # new state. - return SessionUpdatingState(self._ctx) - else: - # use default session. - self._ctx.session = e.session - return IdleState(self._ctx) - - -class SessionUpdatingState(AbsState): - """ - updating session. - """ - state_name = StateName.session_updating - - prior_ops = { - OperatorName.stop, - OperatorName.reconnect, - - } - pending_ops = { - OperatorName.create_response, - OperatorName.text_input, - OperatorName.function_output, - OperatorName.input_audio, - OperatorName.start_listening, - } - block_ops = { - OperatorName.response_cancel: "not responding", - OperatorName.truncate_listening: "not listening", - OperatorName.session_update: "updating session", - } - - def _on_state_created(self) -> None: - session = self._ctx.get_session_obj() - update = ClientSessionUpdateEvent(session=session) - self._ctx.send_to_server(update) - - -class IdleState(AbsState): - state_name = StateName.idle - - prior_ops = { - OperatorName.stop, - OperatorName.reconnect, - OperatorName.response_cancel, - OperatorName.input_audio, - OperatorName.start_listening, - } - pending_ops = { - OperatorName.create_response, - OperatorName.session_update, - OperatorName.text_input, - OperatorName.function_output, - } - block_ops = { - OperatorName.truncate_listening: "not listening", - } - - def _on_state_created(self) -> None: - return None - - -class RespondingState(AbsState): - state_name = StateName.responding - - prior_ops = { - OperatorName.stop, - OperatorName.reconnect, - OperatorName.response_cancel, - OperatorName.input_audio, - OperatorName.start_listening, - } - pending_ops = { - OperatorName.session_update, - OperatorName.text_input, - OperatorName.function_output, - } - block_ops = { - OperatorName.create_response: "responding", - OperatorName.truncate_listening: "not listening", - } - - def __init__(self, ctx: StateCtx, response_id: str): - super().__init__(ctx) - self._response_id = response_id - - def _on_state_created(self): - return - - -class StoppedState(AbsState): - state_name = StateName.stopped - prior_ops = {} - pending_ops = {} - block_ops = { - OperatorName.stop: "is stopped" - } - - def _on_state_created(self) -> None: - return None - - def join(self): - if self._ctx.connection is not None: - self._ctx.connection.close() - - -class ListeningState(AbsState): - prior_ops = { - OperatorName.stop, - OperatorName.reconnect, - OperatorName.create_response, - OperatorName.truncate_listening, - OperatorName.function_output, - } - pending_ops = { - OperatorName.session_update, - OperatorName.text_input, - } - block_ops = { - OperatorName.input_audio: "is listening", - OperatorName.response_cancel: "listening", - OperatorName.start_listening: "is listening", - } - - def _on_state_created(self) -> None: - pass - - -class FailedState(AbsState): - prior_ops = { - OperatorName.stop, - OperatorName.reconnect, - } - pending_ops = set() - block_ops = { - OperatorName.response_cancel: "failed, reconnect or stop", - OperatorName.input_audio: "failed, reconnect or stop", - OperatorName.start_listening: "failed, reconnect or stop", - OperatorName.session_update: "failed, reconnect or stop", - OperatorName.text_input: "failed, reconnect or stop", - OperatorName.function_output: "failed, reconnect or stop", - OperatorName.create_response: "failed, reconnect or stop", - OperatorName.truncate_listening: "failed, reconnect or stop", - } - include_events = { - ServerEventType.error, - } - - def _on_state_created(self) -> None: - if self._ctx.connection is not None: - self._ctx.connection.close() - - -# --- operators --- # - -class OperatorType(str, Enum): - stop = "stop" - reconnect = "reconnect" - function_output = "function_output" - update_session = "update_session" - - -class AbsOperator(RealtimeOperator): - - def on_accept(self, ctx: StateCtx): - return - - @abstractmethod - def run(self, ctx: StateCtx) -> Union[State, None]: - """ - default action of the operator. - """ - pass - - -class Stop(AbsOperator): - type = OperatorType.stop - - def run(self, ctx: StateCtx) -> Union[State, None]: - return StoppedState(ctx) - - -class Reconnect(AbsOperator): - type = OperatorType.reconnect - - def run(self, ctx: StateCtx) -> Union[State, None]: - return ConnectingState(ctx) - - -class FunctionOutput(AbsOperator): - type = OperatorType.function_output - call_id: str - output: str - - def on_accept(self, ctx: StateCtx): - # message = MessageType.FUNCTION_OUTPUT.new( - # role="", - # ) - pass - - def run(self, ctx: StateCtx) -> Union[State, None]: - pass - - -class UpdateSession(RealtimeOperator): - type = OperatorType.update_session - - instruction: Optional[str] = Field( - default=None, - description="Instruction of the session", - ) - tool_choice: str = Field(default="auto") - tools: List[Function] = Field(default_factory=list) diff --git a/ghostos/prototypes/realtime/openai/app.py b/ghostos/prototypes/realtime/openai/app.py deleted file mode 100644 index 6e11dbcb..00000000 --- a/ghostos/prototypes/realtime/openai/app.py +++ /dev/null @@ -1,267 +0,0 @@ -from __future__ import annotations -from abc import ABC, abstractmethod -from queue import Queue -from collections import deque -import time -from typing import List, Optional, Dict, Iterable, Tuple, Callable, Union -from threading import Thread -from ghostos.abcd import Conversation -from ghostos.core.messages import ReceiverBuffer -from ghostos.prototypes.realtime.abcd import ( - RealtimeApp, - Listener, Speaker, - RealtimeOP, -) -from collections import deque -from ghostos.container import Container -from ghostos.core.messages import ( - Message, -) -from ghostos.contracts.logger import LoggerItf, get_logger -from ghostos.contracts.pool import DefaultPool -# from concurrent.futures import ThreadPoolExecutor -# from queue import Queue -# from .protocols import StateName, ServerEventType -from .configs import OpenAIRealtimeConf -from .context import Context -from .state_of_client import StateOfClient -from .state_of_server import StateOfServer -from .ws import OpenAIWSConnection - - -# from .broadcast import SimpleBroadcaster, Broadcaster - - -class OpenAIRealtimeApp(RealtimeApp): - - def __init__( - self, - conf: OpenAIRealtimeConf, - conversation: Conversation, - listener: Listener, - speaker: Speaker, - proxy: Optional[Callable] = None, - ): - self._conf = conf - self._state: StateOfClient = StateOfClient() - self._conversation = conversation - self._proxy = proxy - self._logger = conversation.logger - self._pool = DefaultPool(10) - self._ctx: Context = "" - self._server_state: StateOfServer = StateOfServer() - self._client_state: StateOfClient = StateOfClient() - self._operators: deque[RealtimeOP] = deque() - self._listener: Listener = listener - self._speaker: Speaker = speaker - # status. - self._started = False - - def start(self): - if self._started: - return - if self._ctx.is_closed(): - raise RuntimeError("App is already closed") - self._started = True - self._pool.submit(self._listening_thread) - self._pool.submit(self._speaking_thread) - self._pool.submit(self._main_state_loop) - - def close(self): - if self._ctx.is_closed() or not self._started: - return - self._ctx.closed = True - self._pool.shutdown() - # todo: 1. update conversation. 2. destroy dependencies. - - def is_closed(self) -> bool: - return self._ctx.is_closed() - - def send_message(self, messages: List[Message]): - raise NotImplementedError("Not implemented yet") - - def fail(self, error: Exception) -> bool: - self._logger.exception(error) - return False - - def get_state_desc(self) -> Tuple[str, bool]: - return self._state.name(), self._state.is_outputting() - - def operate(self, op: RealtimeOP) -> Tuple[bool, Optional[str]]: - ok, error = self._state.allow(op) - if ok: - self._operators.append(op) - return ok, error - - def output(self) -> Optional[ReceiverBuffer]: - iterator = self._output_messages() - if iterator is None: - return None - return ReceiverBuffer.new(iterator) - - def messages(self) -> Iterable[Message]: - # clear unsync messages. - self._ctx.sync_ghostos_conversation() - return self._ctx.thread.get_messages(truncated=True) - - def _output_messages(self) -> Optional[Iterable[Message]]: - response_id = self._ctx.response_id - if response_id is None: - return None - msg_id = None - while response_id == self._ctx.response_id and not self.is_closed(): - if self._ctx.response_queue is None: - return None - - if msg_id and msg_id in self._ctx.buffer_messages: - break - self._ctx. - - @staticmethod - def _destroy_state(state: State) -> None: - state._destroy() - - def _main_state_loop(self): - while not self._ctx.is_closed(): - state = self._state - while len(self._operators) > 0: - op = self._operators.popleft() - next_state = state.handle(op) - if next_state is not None: - self._pool.submit(state._destroy) - state = next_state - # clear all - self._operators.clear() - continue - - next_state = state.run_frame() - if next_state is not None: - self._pool.submit(state._destroy) - self._state = next_state - - def _listening_thread(self): - while not self._closed: - if not self._ctx.listening: - time.sleep(0.5) - continue - self._try_listening() - - def _speaking_thread(self): - while not self._closed: - speaking_id = self._ctx.speaking_id - if speaking_id is None: - time.sleep(0.5) - continue - self._try_speaking(speaking_id, self._ctx.speaking_queue) - - def _try_speaking(self, speaking_id: str, queue: Queue): - with self._speaker: - while self._ctx.speaking_id and speaking_id == self._ctx.speaking_id: - data = queue.get(block=True) - if data is None: - break - self._speaker.speak(data) - - def _try_listening(self): - if not self._ctx.listening: - return - with self._listener: - while self._ctx.listening: - data = self._listener.hearing() - if data is None: - return - self._send_audio_buffer(data) - buffer = self._listener.flush() - - def _send_audio_buffer(self, data: bytes) -> None: - pass - - # def start(self) -> None: - # if self._started: - # raise RuntimeError("agent already started") - # - # _funcs: Dict[str, List[Function]] = {} - - # def _main(self): - # # bind shells. - # _ctx = StateCtx( - # conf=self._conf, - # container=self._container, - # conversation=self._conversation, - # broadcaster=_broadcast, - # session=None, - # connection=None, - # connect_sock=self._proxy, - # logger=self._logger, - # ) - # self._state = ConnectingState(_ctx) - # - # while not self._closed: - # state = self._state - # new_state = state.tick() - # if new_state is None: - # time.sleep(0.05) - # else: - # # destroy - # self._pool.submit(state.join) - # # renew the state - # self._state = new_state - # if new_state.state_name == StateName.stopped: - # # stop the world - # break - # # recycle - # _broadcast.close() - # if self._state is not None: - # self._state.join() - # - # def close(self) -> None: - # if self._closed: - # return - # self._closed = True - # self._pool.shutdown() - # - # def fail(self, error: Exception) -> bool: - # self._logger.exception(error) - # self.close() - # return False - # - # def output(self) -> Optional[ReceiverBuffer]: - # pass - -# class ConversationShell(Shell): -# """ -# non-block conversation item updater -# """ -# -# def __init__(self, conversation: Conversation): -# self._conversation = conversation -# self._recv_queue = Queue() -# self._closed = False -# self._main_thread = Thread(target=self._main) -# -# def name(self) -> str: -# return "__conversation__" -# -# def functions(self) -> List[Function]: -# return [] -# -# def subscribing(self) -> List[str]: -# return ServerEventType.conversation_item_events() -# -# def on_sync(self, ghost: Ghost) -> ChanIn[Union[dict, None]]: -# self._main_thread.start() -# return self._recv_queue -# -# def _main(self): -# while not self._closed: -# e = self._recv_queue.get(block=True) -# if e is None: -# self._closed = True -# break -# self._add_conversation(e) -# -# def _add_conversation(self, e: dict) -> None: -# raise NotImplementedError("todo") -# -# def destroy(self): -# self._main_thread.join() diff --git a/ghostos/prototypes/realtime/openai/broadcast.py b/ghostos/prototypes/realtime/openai/broadcast.py deleted file mode 100644 index 201d5d4d..00000000 --- a/ghostos/prototypes/realtime/openai/broadcast.py +++ /dev/null @@ -1,95 +0,0 @@ -from typing import Dict, List, Optional, Union -from abc import ABC, abstractmethod -from copy import deepcopy -from ghostos.prototypes.realtime.abcd import ChanIn -from ghostos.contracts.logger import LoggerItf, get_logger - -__all__ = ['Broadcaster', 'SimpleBroadcaster'] - - -class Broadcaster(ABC): - """ - broadcast event to all channels - """ - - @abstractmethod - def subscribe( - self, - subscriber: str, - chan: ChanIn, - topics: List[str], - ) -> None: - pass - - @abstractmethod - def publish(self, topic: str, data: Union[dict, None]): - pass - - @abstractmethod - def close(self): - pass - - -class SimpleBroadcaster(Broadcaster): - - def __init__(self, logger: Optional[LoggerItf] = None): - self.subscriber_channels: Dict[str, ChanIn] = {} - self.topic_to_subscribers: Dict[str, List[str]] = {} - self._closed = False - self._start_join = False - self._logger = logger if logger else get_logger() - - def subscribe( - self, - subscriber: str, - chan: ChanIn, - topics: List[str], - ) -> None: - if self._closed: - raise RuntimeError("Broadcaster already closed") - if subscriber in self.subscriber_channels: - raise ValueError(f"Subscriber {subscriber} already subscribed") - self.subscriber_channels[subscriber] = chan - for topic in topics: - if topic not in self.topic_to_subscribers: - self.topic_to_subscribers[topic] = [] - subscribers = self.topic_to_subscribers[topic] - subscribers.append(subscriber) - self.topic_to_subscribers[topic] = subscribers - return None - - def publish(self, topic: str, data: Union[dict, None]): - if self._closed: - raise RuntimeError("Broadcaster already closed") - if topic not in self.topic_to_subscribers: - return - subscribers = self.topic_to_subscribers[topic] - if not subscribers: - return - for subscriber in subscribers: - if self._closed: - break - chan = self.subscriber_channels[subscriber] - # copied = deepcopy(data) - try: - chan.put(data, block=False, timeout=0.5) - except TimeoutError as e: - raise RuntimeError(f"Failed to publish because subscriber {subscriber} chan timed out: {e}") - except Exception as e: - self._logger.error( - "put topic %s event to subscriber %s failed", - topic, subscriber, - exc_info=e, - ) - continue - - def close(self): - if self._closed: - return - self._logger.debug("%s is closing", self.__class__.__name__) - self._closed = True - for chan in self.subscriber_channels.values(): - chan.put(None) - chan.task_done() - self.topic_to_subscribers = {} - self.subscriber_channels = {} diff --git a/ghostos/prototypes/realtime/openai/context.py b/ghostos/prototypes/realtime/openai/context.py deleted file mode 100644 index 4d3b65f0..00000000 --- a/ghostos/prototypes/realtime/openai/context.py +++ /dev/null @@ -1,229 +0,0 @@ -from __future__ import annotations -from typing import Tuple, List, Dict, Optional, Iterable -from abc import ABC, abstractmethod -from .ws import OpenAIWSConnection -from .configs import SessionObject, OpenAIRealtimeConf -from .event_data_objects import MessageItem -from ghostos.core.messages import Message, MessageType, Role -from ghostos.core.runtime.events import EventTypes, Event -from ghostos.abcd import Conversation, GoThreadInfo -from ghostos.contracts.logger import get_logger, LoggerItf -from ghostos.contracts.assets import AudioAssets, FileInfo -from queue import Queue, Empty -from enum import Enum -from .state_of_server import ServerContext - - -class RealtimeAppStage(str, Enum): - CREATED = "created" - RESPONDING = "responding" - - -class Context(ServerContext): - """ - realtime app context - """ - - conf: OpenAIRealtimeConf - conversation: Conversation - listening: bool - thread: GoThreadInfo - logger: LoggerItf - connection: Optional[OpenAIWSConnection] - audio_format: str = "wav" - response_id: Optional[str] - - stage: str - status_desc: str - - def __init__( - self, - conf: OpenAIRealtimeConf, - conversation: Conversation, - ): - self.stage = RealtimeAppStage.CREATED - self.status_desc = "" - self._closed: bool = False - - self.conf = conf - self.conversation = conversation - # sync instructions - # todo: realtime logger - self.logger = get_logger("OpenAIRealtime", conversation.scope) - self.thread = conversation.get_thread(truncated=True) - # todo sync - self._history_message_ids: set[str] = set( - item.msg_id for item in self.thread.get_messages(True) - ) - - self.messages_order: List[str] = [] - self.buffer_messages: Dict[str, Message] = {} - - self.connection: Optional[OpenAIWSConnection] = None - self.error_messages: List[Message] = [] - - self.listening: bool = False - """if the client side shall listen """ - - # when realtime server is speaking, the audio bytes shall send through the speaking_queue - self.speaking: bool = False - self.speaking_queue: Optional[Queue] = None - - self.response_id: Optional[str] = None - self.response_queue: Optional[Queue] = None - - def get_session_obj(self) -> SessionObject: - session_obj = self.conf.session - session_obj.instructions = self.conversation.get_instructions() - tools = [] - for fn in self.conversation.get_functions(): - tools.append(fn.to_dict()) - session_obj.tools = tools - return session_obj - - def send_error_message(self, error: str) -> None: - pass - - def update_history_message(self, message: Message) -> None: - pass - - def stop_response(self, response_id: str) -> None: - pass - - def send_speaking_audio_chunk(self, data: bytes) -> None: - pass - - def change_stage(self, stage: str, desc: str): - self.stage = stage - self.status_desc = desc - - def is_closed(self) -> bool: - return self.conversation.is_closed() - - def send_err_message(self, error: str): - message = MessageType.ERROR.new(content=error, role=Role.SYSTEM.value) - self.error_messages.append(message) - - def update_complete_message_item(self, item: MessageItem, update: bool = True): - if item.status == "incomplete": - # incomplete item do not update yet. - return - # save audio file - if item.type == "message" and item.has_audio(): - audio_assets = self.conversation.container().force_fetch(AudioAssets) - if update or not audio_assets.has_binary(item.id): - audio_data = item.get_audio_bytes() - file_info = FileInfo( - fileid=item.id, - filename=f"{item.id}.{self.audio_format}", - filetype=f"audio/{self.audio_format}", - ) - audio_assets.save(file_info, audio_data) - - message = item.to_complete_message() - self.update_message(message, update) - - def update_message(self, message: Message, update: bool = True): - if message is None: - return - """ - update the ghostos message to current session. - """ - if message.msg_id in self._history_message_ids: - if update: - self.thread.update_message(message) - return - - if message.msg_id not in self.buffer_messages: - self.messages_order.append(message.msg_id) - if update or message.msg_id not in self.buffer_messages: - self.buffer_messages[message.msg_id] = message - - if message.type == MessageType.FUNCTION_CALL: - # 如果是 function call, 需要立刻运行一次同步流程. - self.sync_ghostos_conversation() - - def sync_ghostos_conversation(self): - # 1. 检查messages 是否有 function call - # 1.1 如果 function call 存在, 需要调用 conversation 运行一次 function call - # 1.2 如果 function call 不存在, 则仅仅更新 thread. - if len(self.buffer_messages) == 0: - return - has_call = False - messages = [] - for msg_id in self.messages_order: - if msg_id in self._history_messages: - # history message exists. - continue - if msg_id not in self.buffer_messages: - self.logger.error(f"Message {msg_id} not in buffered messages") - continue - message = self.buffer_messages[msg_id] - messages.append(message) - if message.type == MessageType.FUNCTION_CALL: - has_call = True - - event_type = EventTypes.ACTION_CALL if has_call else EventTypes.NOTIFY - event = event_type.new( - task_id=self.conversation.task_id, - messages=messages, - ) - if has_call: - r = self.conversation.respond_event(event) - with r: - for chunk in r.recv(): - # output the new items. - self.send_response_chunk(chunk) - self.thread = self.conversation.get_thread(truncated=True) - else: - self.thread.new_turn(event) - self.conversation.update_thread(self.thread) - self.thread = self.conversation.get_thread(truncated=True) - - self.reset_history_messages() - - def reset_history_messages(self): - # reset messages - self.messages_order = [] - self.buffer_messages = {} - for message in self.thread.get_history_messages(truncated=True): - if message.msg_id not in self._history_messages: - self._history_messages_order.append(message) - self._history_messages[message.msg_id] = message - - def start_response(self, response_id: Optional[str]): - if response_id is None: - self.response_id = None - elif response_id == self.response_id: - return None - - if self.response_queue is not None: - self.response_queue.put(None) - self.response_queue = None - if self.speaking_queue is not None: - self.speaking_queue.put(None) - self.speaking_queue = None - - # update speaking - if response_id: - self.response_queue = Queue() - self.speaking = True - self.speaking_queue = Queue() - - def cancel_response(self): - self.start_response(None) - - def send_response_chunk(self, chunk: Message): - if self.response_queue is None: - self.logger.error(f"no response queue for {chunk.msg_id}") - return - self.response_queue.put(chunk) - - def close(self): - if self._closed: - return - self._closed = True - # todo - - def __del__(self): - pass diff --git a/ghostos/prototypes/realtime/openai/utils.py b/ghostos/prototypes/realtime/openai/utils.py deleted file mode 100644 index 5c735335..00000000 --- a/ghostos/prototypes/realtime/openai/utils.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Union -from ghostos.core.messages import Message -from .event_from_server import ClientEvent - -__all__ = ['parse_message_to_client_event', 'parse_server_event_to_message'] - - -def parse_message_to_client_event(message: Message) -> Union[ClientEvent, None]: - # raise NotImplementedError("todo") - return None - - -def parse_server_event_to_message(event: dict) -> Union[Message, None]: - # raise NotImplementedError("todo") - return None diff --git a/ghostos/prototypes/realtime/pyaudio_io/__init__.py b/ghostos/prototypes/realtime/pyaudio_io/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/prototypes/realtime/openai/__init__.py b/ghostos/prototypes/realtime_console/example.py similarity index 100% rename from ghostos/prototypes/realtime/openai/__init__.py rename to ghostos/prototypes/realtime_console/example.py From a742efd4e72d5622c28fae4763ee75b32a2ce289 Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 6 Dec 2024 15:53:52 +0800 Subject: [PATCH 120/148] fix: fix message bug --- examples/agents/sphero_bolt_gpt.py | 2 +- ghostos/core/messages/message.py | 17 ++++++++++++++--- ghostos/framework/ghostos/session_impl.py | 2 +- ghostos/ghosts/moss_agent/agent.py | 2 +- tests/core/messages/test_openai_parser.py | 2 +- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/examples/agents/sphero_bolt_gpt.py b/examples/agents/sphero_bolt_gpt.py index d3a36c9e..d5f024fd 100644 --- a/examples/agents/sphero_bolt_gpt.py +++ b/examples/agents/sphero_bolt_gpt.py @@ -24,7 +24,7 @@ def example_spin_the_bolt(moss: Moss): def __moss_attr_prompts__(): yield "MossAgent", "" - yield from exports.conversation_item_states() + yield from exports.items() def __moss_agent_providers__(agent): diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 52ae95e6..30cb23e8 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -182,6 +182,10 @@ def is_protocol_type(cls, value: str) -> bool: # 6. 所有的完整消息要么能被解析成模型的消息, 要么就应该忽略它. 避免展示加工不了的. # 7. 用一个 caller 兼容各种模型的 action caller. # 8. 流式传输的消息包, 应该有 首包 / 间包 / 尾包. 尾包是一个粘包后的完整包. +# todo: openai 的 realtime api 协议比较整齐, 应该考虑用这个思路重构. 需要考虑几点: +# todo: 1. 传输协议和存储协议分开. +# todo: 2. 传输用弱类型. +# todo: 3. delta 用于流式传输, content part 用来解决富文本, item 解决消息体. class Message(BaseModel): """ message protocol """ @@ -197,6 +201,8 @@ class Message(BaseModel): default=None, description="Message content that for client side. empty means it shall not be showed", ) + + # todo: remove memory, use stage instead. memory: Optional[str] = Field( default=None, description="Message memory that for llm, if none, means content is memory", @@ -345,8 +351,10 @@ def patch(self, chunk: "Message") -> Optional["Message"]: """ # if the type is not same, it can't be patched pack_type = chunk.get_type() - if pack_type and self.type and pack_type != self.type: - return None + if pack_type and pack_type != self.type: + is_text = pack_type == MessageType.TEXT.value and not self.type + if not is_text: + return None # the chunk message shall have the same message id or empty one if chunk.msg_id and self.msg_id and chunk.msg_id != self.msg_id: return None @@ -381,6 +389,9 @@ def as_tail(self, copy: bool = True) -> Self: item.seq = "complete" return item + def get_unique_id(self) -> str: + return f"{self.type}:{self.msg_id}" + def update(self, pack: "Message") -> None: """ update the fields. @@ -528,7 +539,7 @@ class CallerOutput(BaseModel, MessageClass): ) content: Optional[str] = Field(description="caller output") - msg_id: Optional[str] = Field(None) + msg_id: str = Field("") payloads: Dict[str, Dict] = Field(default_factory=dict) def to_message(self) -> Message: diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index aae2db63..a6008b7d 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -423,8 +423,8 @@ def destroy(self) -> None: if self._destroyed: return self._destroyed = True - del self._task_locker self.container.shutdown() + del self._alive_check del self.container del self._firing_events del self.task diff --git a/ghostos/ghosts/moss_agent/agent.py b/ghostos/ghosts/moss_agent/agent.py index 4a484b2d..b74cbcc2 100644 --- a/ghostos/ghosts/moss_agent/agent.py +++ b/ghostos/ghosts/moss_agent/agent.py @@ -10,7 +10,7 @@ from ghostos.core.runtime import Event, GoThreadInfo from ghostos.core.moss import MossCompiler, PyContext, MossRuntime from ghostos.entity import ModelEntity -from ghostos.core.messages import Message, Caller, Role +from ghostos.core.messages import Caller, Role from ghostos.core.llms import ( Prompt, PromptPipe, AssistantNamePipe, run_prompt_pipeline, LLMFunc, diff --git a/tests/core/messages/test_openai_parser.py b/tests/core/messages/test_openai_parser.py index ad1c5f41..88c8ff6a 100644 --- a/tests/core/messages/test_openai_parser.py +++ b/tests/core/messages/test_openai_parser.py @@ -60,6 +60,6 @@ def test_openai_parser_bad_case_1(): with receiver: got = receiver.wait() assert len(got) == 2 - assert got[0].msg_id != got[1].msg_id + assert got[0].get_unique_id() != got[1].get_unique_id() assert got[0].type == "" assert got[1].type == MessageType.FUNCTION_CALL From 2b65afb5f3f2b1fda0580da6660615aac2e407e2 Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 6 Dec 2024 17:13:38 +0800 Subject: [PATCH 121/148] fix: fix streamlit message rendering --- ghostos/framework/eventbuses/__init__.py | 2 +- ghostos/prototypes/spherogpt/bolt.py | 49 ++++++++++++++++--- .../prototypes/streamlitapp/pages/ghosts.py | 5 +- tests/python/test_threads.py | 27 ++++++++++ 4 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 tests/python/test_threads.py diff --git a/ghostos/framework/eventbuses/__init__.py b/ghostos/framework/eventbuses/__init__.py index 111a4d1b..6bc71a52 100644 --- a/ghostos/framework/eventbuses/__init__.py +++ b/ghostos/framework/eventbuses/__init__.py @@ -1,2 +1,2 @@ from ghostos.core.runtime import EventBus -from ghostos.framework.eventbuses.memimpl import MemEventBusImplProvider +from ghostos.framework.eventbuses.memimpl import MemEventBusImplProvider, MemEventBusImpl diff --git a/ghostos/prototypes/spherogpt/bolt.py b/ghostos/prototypes/spherogpt/bolt.py index 41b83816..1844cd8b 100644 --- a/ghostos/prototypes/spherogpt/bolt.py +++ b/ghostos/prototypes/spherogpt/bolt.py @@ -33,7 +33,7 @@ class Command(BaseModel): """ Sphero Bolt Command that execute frame by frame in time. """ - name: str = Field(description="aim of the command in simple words") + name: str = Field("", description="aim of the command in simple words") duration: float = Field( default=0.0, description="the command running duration in seconds. " @@ -54,13 +54,12 @@ def run_frame(self, api: SpheroEduAPI, passed: float, frame: int) -> None: """ # import types in case you need. from spherov2.types import Color, ToyType - - # strip the spaces before each line. - code = "\n".join([line.strip() for line in self.code.splitlines()]) - # eval the python code defined in the command. # this is how the command work - eval(code) + for line in self.code.splitlines(): + line = line.strip() + if line: + eval(line) @classmethod def once(cls, name: str, code: str, duration: float): @@ -197,7 +196,7 @@ def _run_toy(self, toy) -> None: else: time.sleep(0.5) except Exception as e: - self._logger.error(f"SpheroBolt exception: {e}") + self._logger.exception(e) self._reset_command_at( f"stopped because of error {e}", successful=False, @@ -255,7 +254,41 @@ def bootstrap(container: Container) -> None: SpheroEduAPI.__name__: reflect_class_with_methods(SpheroEduAPI), } -if __name__ == "__main__": +if __name__ == "__exports__": from ghostos.helpers import yaml_pretty_dump print(yaml_pretty_dump(exports)) + +if __name__ == "__main__": + from ghostos.framework.eventbuses import MemEventBusImpl + from ghostos.contracts.logger import get_console_logger + + _logger = get_console_logger() + _eventbus = MemEventBusImpl() + sb = SpheroBoltImpl(_logger, _eventbus, "task_id", False) + sb.bootstrap() + + # class TestCommand(Command): + # code: str = "" + # + # def run_frame(self, api: SpheroEduAPI, passed: float, frame: int) -> None: + # api.roll(0, 100, 1) + # api.roll(90, 100, 1) + # api.roll(180, 100, 1) + # api.roll(270, 100, 1) + # api.roll(0, 100, 1) + c = Command( + name="roll in a circle", + code=""" +api.set_speed(100) +api.roll(0, 100, 1) +api.roll(90, 100, 1) +api.roll(180, 100, 1) +api.roll(270, 100, 1) +api.roll(360, 100, 1) +api.set_speed(0) +""", + duration=5 + ) + + sb.run(c) diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index c0bc9820..3c5bc236 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -88,7 +88,6 @@ def main_chat(): ) route.bind(st.session_state) - render_empty() # header st.title("Ghost") @@ -357,8 +356,7 @@ def render_task_info_settings(task: GoTaskStruct, thread: GoThreadInfo): st.write(task.model_dump(exclude_defaults=True)) st.subheader("Thread Info") - with st.container(border=True): - render_thread_messages(thread, max_turn=0) + render_thread_messages(thread, max_turn=0) def render_thread_messages(thread: GoThreadInfo, max_turn: int = 20): @@ -371,7 +369,6 @@ def render_thread_messages(thread: GoThreadInfo, max_turn: int = 20): count += render_turn(turn, debug) if count == 0: st.info("No thread messages yet") - render_empty() def render_event_object(event: Event, debug: bool): diff --git a/tests/python/test_threads.py b/tests/python/test_threads.py new file mode 100644 index 00000000..2d2dcc62 --- /dev/null +++ b/tests/python/test_threads.py @@ -0,0 +1,27 @@ +from threading import Thread, Event +import time + + +class TestCommand: + + def __init__(self, content: str, duration: float): + self.content = content + self.duration = duration + + def run(self): + start = time.time() + now = time.time() + while now - start < self.duration: + print(self.content) + time.sleep(1) + now = time.time() + + +def test_stop_able_threads(): + from multiprocessing import Process + + t = TestCommand('hello', 2) + p = Process(target=t.run, args=()) + p.start() + p.terminate() + p.join() From 9fcb4fac9be6280e8a59a7042180f4dd414406f5 Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 6 Dec 2024 17:31:10 +0800 Subject: [PATCH 122/148] feat: add force create conversation --- .../sphero_bolt_gpt_command_version.py} | 4 ++-- ghostos/abcd/concepts.py | 2 ++ ghostos/core/runtime/tasks.py | 9 ++++++--- ghostos/framework/ghostos/shell_impl.py | 14 ++++++++++++-- ghostos/framework/tasks/storage_tasks.py | 11 ++++++++--- .../spherogpt/{bolt.py => bolt_command_control.py} | 0 ghostos/prototypes/streamlitapp/pages/ghosts.py | 2 +- 7 files changed, 31 insertions(+), 11 deletions(-) rename examples/{agents/sphero_bolt_gpt.py => sphero/sphero_bolt_gpt_command_version.py} (84%) rename ghostos/prototypes/spherogpt/{bolt.py => bolt_command_control.py} (100%) diff --git a/examples/agents/sphero_bolt_gpt.py b/examples/sphero/sphero_bolt_gpt_command_version.py similarity index 84% rename from examples/agents/sphero_bolt_gpt.py rename to examples/sphero/sphero_bolt_gpt_command_version.py index d5f024fd..ac672aeb 100644 --- a/examples/agents/sphero_bolt_gpt.py +++ b/examples/sphero/sphero_bolt_gpt_command_version.py @@ -1,4 +1,4 @@ -from ghostos.prototypes.spherogpt.bolt import Command, SpheroBolt, SpheroEduAPI, exports +from ghostos.prototypes.spherogpt.bolt_command_control import Command, SpheroBolt, SpheroEduAPI, exports from ghostos.core.moss import Moss as Parent @@ -28,7 +28,7 @@ def __moss_attr_prompts__(): def __moss_agent_providers__(agent): - from ghostos.prototypes.spherogpt.bolt import SpheroBoltProvider + from ghostos.prototypes.spherogpt.bolt_command_control import SpheroBoltProvider return [SpheroBoltProvider()] diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index b1ac0d94..7f02eed4 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -292,11 +292,13 @@ def sync( context: Optional[G.ContextType] = None, username: str = "", user_role: str = Role.USER.value, + force: bool = False, ) -> Conversation[G]: """ create a top-level conversation with a ghost. top-level means task depth is 0. So it never locked until the conversation is created. + if force is True, the conversation will seize the task locker anyway. """ pass diff --git a/ghostos/core/runtime/tasks.py b/ghostos/core/runtime/tasks.py index 912da732..917db808 100644 --- a/ghostos/core/runtime/tasks.py +++ b/ghostos/core/runtime/tasks.py @@ -1,5 +1,3 @@ -import time -from datetime import datetime from typing import Optional, List, ClassVar, Dict, Self from abc import ABC, abstractmethod from enum import Enum @@ -365,8 +363,13 @@ def get_task_briefs(self, task_ids: List[str]) -> Dict[str, TaskBrief]: pass @abstractmethod - def lock_task(self, task_id: str, overdue: float) -> TaskLocker: + def lock_task(self, task_id: str, overdue: float, force: bool = False) -> TaskLocker: """ + get task locker + :param task_id: + :param overdue: when the locker is overdue + :param force: if force is True, will preempt the locker + :return: """ pass diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index 1ed00022..ccc246cb 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -101,8 +101,10 @@ def sync( self, ghost: Ghost, context: Optional[Ghost.ContextType] = None, + *, username: str = "", user_role: str = Role.USER.value, + force: bool = False, ) -> Conversation: driver = get_ghost_driver(ghost) task_id = driver.make_task_id(self._scope) @@ -116,7 +118,14 @@ def sync( task.meta = to_entity_meta(ghost) if context is not None: task.context = to_entity_meta(context) - conversation = self.sync_task(task, throw=True, is_background=False, username=username, user_role=user_role) + conversation = self.sync_task( + task, + throw=True, + is_background=False, + username=username, + user_role=user_role, + force=force, + ) return conversation def sync_task( @@ -127,8 +136,9 @@ def sync_task( is_background: bool, username: str = "", user_role: str = "", + force: bool = False, ) -> Optional[Conversation]: - locker = self._tasks.lock_task(task.task_id, self._conf.task_lock_overdue) + locker = self._tasks.lock_task(task.task_id, self._conf.task_lock_overdue, force) if locker.acquire(): conf = ConversationConf( max_session_steps=self._conf.max_session_steps, diff --git a/ghostos/framework/tasks/storage_tasks.py b/ghostos/framework/tasks/storage_tasks.py index f3f868b1..d3700747 100644 --- a/ghostos/framework/tasks/storage_tasks.py +++ b/ghostos/framework/tasks/storage_tasks.py @@ -17,12 +17,13 @@ class LockData(TypedDict): lock_id: str created: float - def __init__(self, storage: Storage, task_id: str, overdue: float): + def __init__(self, storage: Storage, task_id: str, overdue: float, force: bool = False): self.task_id = task_id self.storage = storage self.lock_id = uuid() self._acquired = False self._overdue = overdue + self._force = force def acquire(self) -> bool: filename = self.locker_file_name() @@ -34,6 +35,10 @@ def acquire(self) -> bool: if lock['lock_id'] == self.lock_id or now - float(lock["created"]) > self._overdue: self.create_lock() return True + if not self._acquired and self._force: + self.storage.remove(filename) + self.create_lock() + return True return False self.create_lock() @@ -114,8 +119,8 @@ def get_task_briefs(self, task_ids: List[str], states: Optional[List[TaskState]] for task in self.get_tasks(task_ids, states): yield TaskBrief.from_task(task) - def lock_task(self, task_id: str, overdue: float = 30) -> TaskLocker: - return SimpleStorageLocker(self._storage, task_id, overdue) + def lock_task(self, task_id: str, overdue: float = 30, force: bool = False) -> TaskLocker: + return SimpleStorageLocker(self._storage, task_id, overdue, force) class StorageTasksImplProvider(Provider[GoTasks]): diff --git a/ghostos/prototypes/spherogpt/bolt.py b/ghostos/prototypes/spherogpt/bolt_command_control.py similarity index 100% rename from ghostos/prototypes/spherogpt/bolt.py rename to ghostos/prototypes/spherogpt/bolt_command_control.py diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index 3c5bc236..86c30abb 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -146,7 +146,7 @@ def get_conversation(route: GhostChatRoute) -> Conversation: if not conversation or conversation.is_closed(): shell = Singleton.get(Shell, st.session_state) # create conversation - conversation = shell.sync(route.get_ghost(), route.get_context()) + conversation = shell.sync(route.get_ghost(), route.get_context(), force=True) Singleton(conversation, Conversation).bind(st.session_state) return conversation From 88247ffd0b84fc400c8687062857f4aeaf55e60f Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 6 Dec 2024 18:39:32 +0800 Subject: [PATCH 123/148] dev: optimize streamlit message outputs --- ghostos/framework/ghostos/session_impl.py | 24 +++++++++++++++---- ghostos/framework/llms/openai_driver.py | 2 +- ghostos/ghosts/moss_agent/agent.py | 8 +++++-- .../spherogpt/bolt_command_control.py | 11 +++++---- .../prototypes/streamlitapp/pages/ghosts.py | 13 ++++++++-- .../streamlitapp/widgets/renderer.py | 3 ++- 6 files changed, 46 insertions(+), 15 deletions(-) diff --git a/ghostos/framework/ghostos/session_impl.py b/ghostos/framework/ghostos/session_impl.py index a6008b7d..5e4115a9 100644 --- a/ghostos/framework/ghostos/session_impl.py +++ b/ghostos/framework/ghostos/session_impl.py @@ -126,7 +126,7 @@ def unmarshal_state(task: GoTaskStruct) -> Dict[str, EntityType]: def alive(self) -> bool: if self._failed or self._destroyed: return False - return self._alive_check() and self._refresh_callback() and (self.upstream is None or self.upstream.alive()) + return self._alive_check() and (self.upstream is None or self.upstream.alive()) def _validate_alive(self): if not self.alive(): @@ -219,13 +219,27 @@ def get_artifact(self) -> Ghost.ArtifactType: def get_instructions(self) -> str: return self.ghost_driver.get_instructions(self) - def refresh(self) -> bool: - if self._failed or self._destroyed or not self.alive(): + def refresh(self, throw: bool = False) -> bool: + if self._failed: + if throw: + raise RuntimeError(f"Session is already failed") + return False + if self._destroyed: + if throw: + raise RuntimeError(f"Session is already destroyed") + return False + + if not self.alive(): + if throw: + raise RuntimeError(f"Session is not alive") return False if self._refresh_callback(): self._saved = False return True - return False + elif throw: + raise RuntimeError(f"session refresh callback failed") + else: + return False def _reset(self): self._fetched_task_briefs = {} @@ -394,7 +408,7 @@ def _do_fire_events(self) -> None: self._firing_events = [] def __enter__(self): - if not self.refresh(): + if not self.refresh(throw=True): raise RuntimeError(f"Failed to start session") return self diff --git a/ghostos/framework/llms/openai_driver.py b/ghostos/framework/llms/openai_driver.py index cde1f427..e4e8dd2e 100644 --- a/ghostos/framework/llms/openai_driver.py +++ b/ghostos/framework/llms/openai_driver.py @@ -121,7 +121,7 @@ def _chat_completion(self, prompt: Prompt, stream: bool) -> Union[ChatCompletion raise AttributeError("empty chat!!") try: prompt.run_start = timestamp() - self._logger.info(f"start chat completion messages %s", messages) + self._logger.debug(f"start chat completion messages %s", messages) functions = prompt.get_openai_functions() tools = prompt.get_openai_tools() if self._model.use_tools: diff --git a/ghostos/ghosts/moss_agent/agent.py b/ghostos/ghosts/moss_agent/agent.py index b74cbcc2..6fd751f6 100644 --- a/ghostos/ghosts/moss_agent/agent.py +++ b/ghostos/ghosts/moss_agent/agent.py @@ -339,8 +339,12 @@ def update_prompt(self, prompt: Prompt) -> Prompt: def run(self, session: Session, caller: Caller) -> Union[Operator, None]: # prepare arguments. arguments = caller.arguments - data = json.loads(arguments) - args = self.Argument(**data) + try: + data = json.loads(arguments) + args = self.Argument(**data) + except json.JSONDecodeError: + content = arguments + args = self.Argument(code=content) code = args.code.strip() # if code is not exists, inform the llm diff --git a/ghostos/prototypes/spherogpt/bolt_command_control.py b/ghostos/prototypes/spherogpt/bolt_command_control.py index 1844cd8b..2e6efdf3 100644 --- a/ghostos/prototypes/spherogpt/bolt_command_control.py +++ b/ghostos/prototypes/spherogpt/bolt_command_control.py @@ -6,6 +6,7 @@ exit("This script requires the spherov2 to be installed.") from spherov2 import scanner +from spherov2.types import Color, ToyType from abc import ABC, abstractmethod from typing import Optional, List @@ -111,7 +112,8 @@ def __init__( def bootstrap(self): try: self._logger.info("SpheroBolt Bootstrap started") - self._main_thread = Thread(target=self._main) + _bolt = scanner.find_BOLT() + self._main_thread = Thread(target=self._main, args=(_bolt,)) self._main_thread.start() except Exception as e: raise NotImplementedError("Could not find the Bolt device. " + str(e)) @@ -155,12 +157,11 @@ def _reset_command_at(self, action: str, successful: bool, clear_all: bool): ) self._eventbus.send_event(event, self._notify) - def _main(self) -> None: + def _main(self, bolt) -> None: while not self._destroyed: - _bolt = scanner.find_BOLT() self._logger.info("SpheroBolt toy connected") try: - self._run_toy(_bolt) + self._run_toy(bolt) except Exception as e: self._logger.error(str(e)) self._logger.info("SpheroBolt toy reconnecting") @@ -171,6 +172,7 @@ def _run_toy(self, toy) -> None: while not self._destroyed: try: if self._executing_command and self._timeleft: + api.set_front_led(Color(0, 100, 0)) has_duration = self._executing_command.duration > 0 must_run = self._ticked_frames == 0 run_every = self._executing_command.run_every @@ -186,6 +188,7 @@ def _run_toy(self, toy) -> None: continue else: self._command_succeeded() + api.set_front_led(Color(0, 0, 0)) continue elif len(self._command_stack) > 0: current: Command = self._command_stack.pop(0) diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index 86c30abb..d749cf95 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -3,7 +3,7 @@ import streamlit_paste_button as spb import time from PIL.Image import Image -from typing import Iterable, List +from typing import Iterable, List, Optional from ghostos.prototypes.streamlitapp.pages.router import ( GhostChatRoute, GhostTaskRoute, ) @@ -88,7 +88,6 @@ def main_chat(): ) route.bind(st.session_state) - # header st.title("Ghost") with st.container(border=True): @@ -165,8 +164,17 @@ def main_task(): def chatting(route: GhostChatRoute, conversation: Conversation): + if "rerun_chat" not in st.session_state: + st.session_state["rerun_chat"] = 0 + st.session_state["rerun_chat"] += 1 + for i in range(st.session_state["rerun_chat"]): + st.empty() chat_input = st.chat_input("message") + with st.container(): + _chatting(route, conversation, chat_input) + +def _chatting(route: GhostChatRoute, conversation: Conversation, chat_input: Optional[str]): thread = conversation.get_thread() render_thread_messages(thread, max_turn=20) debug = get_app_conf().BoolOpts.DEBUG_MODE.get() @@ -369,6 +377,7 @@ def render_thread_messages(thread: GoThreadInfo, max_turn: int = 20): count += render_turn(turn, debug) if count == 0: st.info("No thread messages yet") + st.empty() def render_event_object(event: Event, debug: bool): diff --git a/ghostos/prototypes/streamlitapp/widgets/renderer.py b/ghostos/prototypes/streamlitapp/widgets/renderer.py index ad825da4..87a3ff63 100644 --- a/ghostos/prototypes/streamlitapp/widgets/renderer.py +++ b/ghostos/prototypes/streamlitapp/widgets/renderer.py @@ -110,7 +110,8 @@ def render_event(event: Event, debug: bool): render_messages(messages, debug, in_expander=True) else: messages = event.iter_message(show_instruction=True) - render_messages(messages, debug, in_expander=False) + with st.container(): + render_messages(messages, debug, in_expander=False) def render_event_object(event: Event, debug: bool): From 4bdea68223b81938f46e7f7f0951e8a9145d7a66 Mon Sep 17 00:00:00 2001 From: zhuming Date: Fri, 6 Dec 2024 22:26:10 +0800 Subject: [PATCH 124/148] dev: optimize streamlit app message render --- ghostos/framework/tasks/storage_tasks.py | 1 - ghostos/prototypes/streamlitapp/pages/ghosts.py | 7 +++---- ghostos/prototypes/streamlitapp/widgets/messages.py | 10 +++++----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/ghostos/framework/tasks/storage_tasks.py b/ghostos/framework/tasks/storage_tasks.py index d3700747..81e58bf4 100644 --- a/ghostos/framework/tasks/storage_tasks.py +++ b/ghostos/framework/tasks/storage_tasks.py @@ -36,7 +36,6 @@ def acquire(self) -> bool: self.create_lock() return True if not self._acquired and self._force: - self.storage.remove(filename) self.create_lock() return True return False diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index d749cf95..f7e4cfff 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -169,12 +169,11 @@ def chatting(route: GhostChatRoute, conversation: Conversation): st.session_state["rerun_chat"] += 1 for i in range(st.session_state["rerun_chat"]): st.empty() - chat_input = st.chat_input("message") - with st.container(): - _chatting(route, conversation, chat_input) + _chatting(route, conversation) -def _chatting(route: GhostChatRoute, conversation: Conversation, chat_input: Optional[str]): +def _chatting(route: GhostChatRoute, conversation: Conversation): + chat_input = st.chat_input("message") thread = conversation.get_thread() render_thread_messages(thread, max_turn=20) debug = get_app_conf().BoolOpts.DEBUG_MODE.get() diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py index 631074b8..8c7a2414 100644 --- a/ghostos/prototypes/streamlitapp/widgets/messages.py +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -37,12 +37,12 @@ def render_messages(messages: Iterable[Message], debug: bool, in_expander: bool, def render_message_group(group: MessageGroup, debug: bool, in_expander: bool, prefix: str = ""): + role = group.msg_role + name = group.msg_name + stage = group.stage + caption = f"{role}: {name}" if name else role + render_role = "user" if role == Role.USER.value else "assistant" with st.container(): - role = group.msg_role - name = group.msg_name - stage = group.stage - caption = f"{role}: {name}" if name else role - render_role = "user" if role == Role.USER.value else "assistant" if stage: with st.expander(stage, expanded=False): with st.chat_message(render_role): From 5ed26fcce354f37913daa1d6060dc52e330cf8ed Mon Sep 17 00:00:00 2001 From: zhuming Date: Sun, 8 Dec 2024 03:18:19 +0800 Subject: [PATCH 125/148] dev: complete new sphero shell interface --- examples/sphero/bolt_gpt_shell_agent.py | 48 +++ ghostos/contracts/workspace.py | 6 +- ghostos/framework/workspaces/basic.py | 3 + ghostos/ghosts/moss_agent/agent.py | 1 + ghostos/prototypes/spherogpt/bolt/__init__.py | 11 + .../prototypes/spherogpt/bolt/animations.py | 0 .../prototypes/spherogpt/bolt/movements.py | 88 ++++++ ghostos/prototypes/spherogpt/bolt/runtime.py | 70 +++++ .../prototypes/spherogpt/bolt/runtime_impl.py | 204 ++++++++++++ ghostos/prototypes/spherogpt/bolt/shell.py | 295 ++++++++++++++++++ .../prototypes/spherogpt/bolt/shell_impl.py | 250 +++++++++++++++ .../prototypes/streamlitapp/pages/ghosts.py | 1 + tests/python/test_typing.py | 10 +- 13 files changed, 984 insertions(+), 3 deletions(-) create mode 100644 examples/sphero/bolt_gpt_shell_agent.py create mode 100644 ghostos/prototypes/spherogpt/bolt/__init__.py create mode 100644 ghostos/prototypes/spherogpt/bolt/animations.py create mode 100644 ghostos/prototypes/spherogpt/bolt/movements.py create mode 100644 ghostos/prototypes/spherogpt/bolt/runtime.py create mode 100644 ghostos/prototypes/spherogpt/bolt/runtime_impl.py create mode 100644 ghostos/prototypes/spherogpt/bolt/shell.py create mode 100644 ghostos/prototypes/spherogpt/bolt/shell_impl.py diff --git a/examples/sphero/bolt_gpt_shell_agent.py b/examples/sphero/bolt_gpt_shell_agent.py new file mode 100644 index 00000000..91dff29c --- /dev/null +++ b/examples/sphero/bolt_gpt_shell_agent.py @@ -0,0 +1,48 @@ +from ghostos.prototypes.spherogpt.bolt import ( + CurveRoll, + Ball, + Move, +) +from ghostos.core.moss import Moss as Parent + + +class Moss(Parent): + body: Ball + """your sphero ball body""" + + +def example_spin_the_bolt(moss: Moss): + # body spin 360 degree in 1 second. + moss.body.new_move(True).spin(360, 1) + + +# +from ghostos.ghosts.moss_agent import MossAgent +from typing import TYPE_CHECKING + + +def __moss_attr_prompts__(): + yield "MossAgent", "" + + +def __moss_agent_providers__(agent): + from ghostos.prototypes.spherogpt.bolt import SpheroBoltBallAPIProvider, ConvoLevelSpheroBoltRuntimeProvider + return [SpheroBoltBallAPIProvider(), ConvoLevelSpheroBoltRuntimeProvider()] + + +__ghost__ = MossAgent( + name="SpheroGPT", + description="Sphero Bolt agent that control Sphero bolt as its body", + persona=""" +You are SpheroGPT, a toy robot that body is a ball. +You can roll, spin, and equipped with a 8*8 led light matrix. +Your goal is to pleasure human users, especially kids, who like you very much. +""", + instructions=""" +1. chat with user kindly. +2. follow the order and turn your actions to code with your ball body. +""", + moss_module=__name__ +) + +# diff --git a/ghostos/contracts/workspace.py b/ghostos/contracts/workspace.py index cbf721d7..ba66777e 100644 --- a/ghostos/contracts/workspace.py +++ b/ghostos/contracts/workspace.py @@ -25,11 +25,13 @@ def runtime(self) -> FileStorage: """ pass + @abstractmethod + def runtime_cache(self) -> FileStorage: + pass + @abstractmethod def configs(self) -> FileStorage: """ config path that configs located """ pass - - diff --git a/ghostos/framework/workspaces/basic.py b/ghostos/framework/workspaces/basic.py index aaab9ae4..4258e947 100644 --- a/ghostos/framework/workspaces/basic.py +++ b/ghostos/framework/workspaces/basic.py @@ -25,6 +25,9 @@ def root(self) -> FileStorage: def runtime(self) -> FileStorage: return self._runtime_storage + def runtime_cache(self) -> FileStorage: + return self._runtime_storage.sub_storage("cache") + def assets(self) -> FileStorage: return self._assets_storage diff --git a/ghostos/ghosts/moss_agent/agent.py b/ghostos/ghosts/moss_agent/agent.py index 6fd751f6..eb9ded6e 100644 --- a/ghostos/ghosts/moss_agent/agent.py +++ b/ghostos/ghosts/moss_agent/agent.py @@ -383,6 +383,7 @@ def run(self, session: Session, caller: Caller) -> Union[Operator, None]: return None except Exception as e: + session.logger.exception(e) return self.fire_error(session, caller, f"error during executing moss code: {e}") @staticmethod diff --git a/ghostos/prototypes/spherogpt/bolt/__init__.py b/ghostos/prototypes/spherogpt/bolt/__init__.py new file mode 100644 index 00000000..52083640 --- /dev/null +++ b/ghostos/prototypes/spherogpt/bolt/__init__.py @@ -0,0 +1,11 @@ +from ghostos.prototypes.spherogpt.bolt.shell_impl import SpheroBoltBallAPIProvider +from ghostos.prototypes.spherogpt.bolt.runtime_impl import ConvoLevelSpheroBoltRuntimeProvider + +from ghostos.prototypes.spherogpt.bolt.shell import ( + CurveRoll, + Ball, + Move, + LedMatrix, + Color, + Animation, +) diff --git a/ghostos/prototypes/spherogpt/bolt/animations.py b/ghostos/prototypes/spherogpt/bolt/animations.py new file mode 100644 index 00000000..e69de29b diff --git a/ghostos/prototypes/spherogpt/bolt/movements.py b/ghostos/prototypes/spherogpt/bolt/movements.py new file mode 100644 index 00000000..ccb49773 --- /dev/null +++ b/ghostos/prototypes/spherogpt/bolt/movements.py @@ -0,0 +1,88 @@ +from typing import List, Union, Dict, Optional, Self +from spherov2.sphero_edu import SpheroEduAPI +from pydantic import BaseModel, Field +from ghostos.entity import ModelEntityMeta, to_entity_model_meta, from_entity_model_meta + +from .runtime import BoltBallMovement +from .shell import CurveRoll + + +class RunAPIMovement(BoltBallMovement): + desc: str = Field(description="desc of the movement") + method: str = Field(description="sphero edu api name") + args: List[Union[str, int, float]] = Field(default_factory=list, description="args") + kwargs: Dict[str, Union[str, int, float]] = Field(default_factory=dict, description="kwargs") + duration: float = Field(0.0, description="duration of the movement") + + def start(self, api: SpheroEduAPI) -> None: + method = getattr(api, self.method) + method(*self.args, **self.kwargs) + + def run_frame(self, api: SpheroEduAPI, passed: float) -> bool: + return passed > self.duration + + def on_event(self, event_type: str) -> Optional[Self]: + return None + + +class CurveRollMovement(BoltBallMovement): + desc: str = Field(description="desc of the movement") + curve: CurveRoll = Field(description="curve roll") + stopped: bool = Field(default=False) + error: str = Field("") + + def start(self, api: SpheroEduAPI) -> None: + self.run_frame(api, 0) + + def run_frame(self, api: SpheroEduAPI, passed: float) -> bool: + try: + self.stopped = self.curve.run_frame(passed) + except Exception as e: + self.error = str(e) + return False + api.set_speed(self.curve.speed) + api.set_heading(self.curve.heading) + return self.stopped + + def on_event(self, event_type: str) -> Optional[Self]: + return None + + +class GroupMovement(BoltBallMovement): + children: List[ModelEntityMeta] = Field(default_factory=list) + iter_idx: int = Field(default=0) + new_child_start_at: float = Field(default=0.0) + event_desc: Optional[str] = Field(default=None) + event_moves: Dict[str, ModelEntityMeta] = Field(default_factory=dict) + + def add_child(self, move: BoltBallMovement): + meta = to_entity_model_meta(move) + self.children.append(meta) + + def get_child(self, idx: int) -> BoltBallMovement: + meta = self.children[idx] + return from_entity_model_meta(meta) + + def start(self, api: SpheroEduAPI) -> None: + if len(self.children) > 0: + self.iter_idx = 0 + child = self.get_child(self.iter_idx) + child.start(api) + + def run_frame(self, api: SpheroEduAPI, passed: float) -> bool: + if self.iter_idx >= len(self.children): + return False + child = self.get_child(self.iter_idx) + child_passed = passed - self.new_child_start_at + stopped = child.run_frame(api, child_passed) + if stopped: + self.iter_idx += 1 + self.new_child_start_at = passed + return self.run_frame(api, passed) + return False + + def on_event(self, event_type: str) -> Optional[Self]: + if event_type in self.event_moves: + meta = self.event_moves[event_type] + return from_entity_model_meta(meta) + return None diff --git a/ghostos/prototypes/spherogpt/bolt/runtime.py b/ghostos/prototypes/spherogpt/bolt/runtime.py new file mode 100644 index 00000000..b9ecfbe8 --- /dev/null +++ b/ghostos/prototypes/spherogpt/bolt/runtime.py @@ -0,0 +1,70 @@ +from abc import ABC, abstractmethod +from typing import Optional, Self +from ghostos.entity import ModelEntity +from spherov2.sphero_edu import SpheroEduAPI +from pydantic import BaseModel, Field + +_STOPPED = bool + + +class BoltBallMovement(BaseModel, ABC): + desc: str = Field("", description="description of the command") + stop_at_first: bool = Field(default=False, description="stop the world at first") + + @abstractmethod + def start(self, api: SpheroEduAPI) -> None: + pass + + @abstractmethod + def run_frame(self, api: SpheroEduAPI, passed: float) -> _STOPPED: + pass + + def succeed_log(self, passed: float) -> str: + if not self.desc: + return "" + return f"done `{self.desc}` after {round(passed, 4)} seconds" + + def interrupt_log(self, reason: str, passed: float) -> str: + return f"interrupt `{self.desc}` running because `{reason}` after {round(passed, 4)} seconds" + + @abstractmethod + def on_event(self, event_type: str) -> Optional[Self]: + pass + + +class BoltLedMatrixAnimation(ModelEntity, ABC): + + @abstractmethod + def start(self, api: SpheroEduAPI) -> None: + pass + + +class SpheroBoltRuntime(ABC): + + @abstractmethod + def get_task_id(self) -> str: + pass + + @abstractmethod + def add_movement(self, command: BoltBallMovement): + pass + + @abstractmethod + def set_charging_callback(self, event: str): + pass + + @abstractmethod + def set_off_charging_callback(self, event: str): + pass + + @abstractmethod + def add_animation(self, command: BoltLedMatrixAnimation): + pass + + @abstractmethod + def bootstrap(self): + pass + + @abstractmethod + def close(self): + pass diff --git a/ghostos/prototypes/spherogpt/bolt/runtime_impl.py b/ghostos/prototypes/spherogpt/bolt/runtime_impl.py new file mode 100644 index 00000000..0fa6d9e6 --- /dev/null +++ b/ghostos/prototypes/spherogpt/bolt/runtime_impl.py @@ -0,0 +1,204 @@ +from typing import Optional, Callable, Type, Self, ClassVar +from .runtime import SpheroBoltRuntime, BoltLedMatrixAnimation, BoltBallMovement +import time + +from spherov2 import scanner +from spherov2.sphero_edu import SpheroEduAPI, EventType as SpheroEventType +from ghostos.contracts.logger import LoggerItf +from ghostos.core.messages import MessageType +from ghostos.core.runtime import EventBus, Event, EventTypes as GhostOSEventTypes +from ghostos.container import Container, BootstrapProvider, INSTANCE +from ghostos.abcd import Conversation +from ghostos.helpers import Timeleft +from threading import Thread, Event +from collections import deque + +__all__ = ['SpheroBoltRuntimeImpl', 'ConvoLevelSpheroBoltRuntimeProvider'] + + +class SpheroBoltRuntimeImpl(SpheroBoltRuntime): + + def __init__( + self, + *, + task_id: str, + eventbus: EventBus, + logger: LoggerItf, + shall_notify: bool = False, + ): + self._task_id = task_id + self._shall_notify = shall_notify + self._eventbus = eventbus + self._logger = logger + self._stopped = Event() + self._closed = False + self._bootstrapped = False + self._error: Optional[str] = None + self._main_thread = Thread(target=self._main_thread) + self._move_queue = deque() + self._current_movement: Optional[BoltBallMovement] = None + self._current_movement_timeleft: Optional[Timeleft] = None + self._charging_callback: str = "feeling at charging" + self._off_charging_callback: str = "feeling stop charging" + + def bootstrap(self): + if self._bootstrapped: + self._logger.error(f"SpheroBolt Runtime already bootstrapped") + return + self._bootstrapped = True + self._main_thread.start() + + def _main_thread(self): + connected_error = 0 + while not self._stopped.is_set(): + try: + self._logger.info("SpheroBolt Bootstrap started") + _bolt = scanner.find_BOLT() + self._logger.info("SpheroBolt Bootstrap connected") + connected_error = 0 + self._run_bolt_loop(_bolt) + except Exception as e: + self._logger.exception(e) + self._logger.info("SpheroBolt Bootstrap failed") + connected_error += 1 + if connected_error > 3: + self._stopped.set() + self._error = 'failed to connected SpheroBolt' + self._send_event(GhostOSEventTypes.ERROR, "sphero bolt failed to connected") + raise RuntimeError(self._error) + + def _run_bolt_loop(self, _bolt): + with SpheroEduAPI(_bolt) as api: + self._init_sphero_edu_api(api) + while not self._stopped.is_set(): + if self._current_movement is None: + movement = self._start_new_movement(api) + if movement is not None: + self._set_current_movement(movement) + else: + # wait for new command. + time.sleep(0.5) + continue + + stopped = self._current_movement.run_frame(api, self._current_movement_timeleft.passed()) + if stopped: + self._clear_current_movement() + + def _init_sphero_edu_api(self, api): + events = [ + SpheroEventType.on_landing, + SpheroEventType.on_freefall, + SpheroEventType.on_collision, + ] + for event in events: + listener = self._get_listener(event, api) + api.register_event(event, listener) + + def _get_listener(self, event_type: SpheroEventType, api: SpheroEduAPI) -> Callable[[], None]: + def callback(): + if self._current_movement is not None: + self._clear_current_movement(event_type.name) + move = self._current_movement.on_event(event_type.name) + if move is not None: + self._set_current_movement(move) + else: + self._default_on_event(event_type, api) + + return callback + + def _default_on_event(self, event_type: SpheroEventType, api: SpheroEduAPI): + api.stop_roll() + api.clear_matrix() + if event_type == SpheroEventType.on_charging: + self._send_event(GhostOSEventTypes.NOTIFY, self._charging_callback) + elif event_type == SpheroEventType.on_not_charging: + self._send_event(GhostOSEventTypes.NOTIFY, self._off_charging_callback) + return + + def _set_current_movement(self, movement: BoltBallMovement): + if movement is None: + return + self._current_movement = movement + self._current_movement_timeleft = Timeleft(0) + + def _clear_current_movement(self, interrupt: Optional[str] = None): + if self._current_movement is not None and self._current_movement_timeleft is not None: + if not interrupt: + log = self._current_movement.succeed_log(self._current_movement_timeleft.passed()) + if log: + self._send_event(GhostOSEventTypes.NOTIFY, log) + else: + log = self._current_movement.interrupt_log(interrupt, self._current_movement_timeleft.passed()) + if log: + self._send_event(GhostOSEventTypes.NOTIFY, log) + + self._current_movement = None + self._current_movement_timeleft = None + + def _start_new_movement(self, api: SpheroEduAPI) -> Optional[BoltBallMovement]: + if len(self._move_queue) == 0: + return None + movement: BoltBallMovement = self._move_queue.popleft() + self._logger.debug("start new movement %r", movement) + movement.start(api) + return movement + + def _send_event(self, event_type: GhostOSEventTypes, content: str): + event = event_type.new( + task_id=self._task_id, + messages=[MessageType.TEXT.new_system(content=content)], + callback=True, + ) + self._eventbus.send_event(event, self._shall_notify) + + def add_movement(self, move: BoltBallMovement): + if move.stop_at_first: + self._move_queue.clear() + self._move_queue.append(move) + else: + self._move_queue.append(move) + + def add_animation(self, command: BoltLedMatrixAnimation): + pass + + def close(self): + if self._closed: + return + self._closed = True + self._stopped.set() + + def get_task_id(self) -> str: + return self._task_id + + def set_charging_callback(self, event: str): + self._charging_callback = event + + def set_off_charging_callback(self, event: str): + self._off_charging_callback = event + + +class ConvoLevelSpheroBoltRuntimeProvider(BootstrapProvider): + + def contract(self) -> Type[INSTANCE]: + return SpheroBoltRuntime + + def bootstrap(self, container: Container) -> None: + runtime = container.force_fetch(SpheroBoltRuntime) + runtime.bootstrap() + container.add_shutdown(runtime.close) + + def singleton(self) -> bool: + return True + + def factory(self, con: Container) -> Optional[SpheroBoltRuntime]: + logger = con.force_fetch(LoggerItf) + logger.error("runtime bootstrap at container %s", con.bloodline) + conversation = con.force_fetch(Conversation) + task = conversation.get_task() + eventbus = con.force_fetch(EventBus) + return SpheroBoltRuntimeImpl( + task_id=task.task_id, + eventbus=eventbus, + logger=logger, + shall_notify=task.shall_notify(), + ) diff --git a/ghostos/prototypes/spherogpt/bolt/shell.py b/ghostos/prototypes/spherogpt/bolt/shell.py new file mode 100644 index 00000000..585938a9 --- /dev/null +++ b/ghostos/prototypes/spherogpt/bolt/shell.py @@ -0,0 +1,295 @@ +from typing import Self, Optional, Callable, List, Literal, NamedTuple, Union +from abc import ABC, abstractmethod +from pydantic import BaseModel, Field + + +class CurveRoll(BaseModel): + """ + to define a curve rolling frame by frame. + """ + desc: str = Field(description="describe this curve in very few words") + heading: int = Field(0, description="Heading angle of the sphero bolt in degrees from -180 ~ 180", ge=-180, le=180) + speed: int = Field(90, description="speed of the sphero bolt rolling", ge=0, le=255) + duration: float = Field(1, description="duration of the rolling, if 0, means forever") + code: str = Field(description="the python code to change self heading, speed, stop at each frame of rolling.") + + def run_frame(self, passed: float) -> bool: + """ + real logic of the curve rolling. + sphero runtime will call `run_frame` method at each frame (for example: 0.01 second) + this method shall change the speed and heading at each frame. + + :param passed: the time in seconds that passed since the curve rolling started + :return: shall stop? + """ + # the real logic is eval the python code here, change the heading and spead to complete a curve. + # for example, a sin cure: + # self.speed = 90 + # self.heading = int(math.sin(passed % 3) * 180)) % 360 + for line in self.code.splitlines(): + eval(line) + return self.duration == 0 or passed > self.duration + + +class Move(ABC): + """ + to define a sequence of sphero bolt ball movements. + you can call several methods in order to define a sequence. + the move instance do not execute until it is run by `Ball` interface. + """ + + @abstractmethod + def roll(self, heading: int, speed: int, duration: float) -> Self: + """Combines heading(0-360°), speed(-255-255), and duration to make the robot roll with one line of code. + For example, to have the robot roll at 90°, at speed 200 for 2s, use ``roll(90, 200, 2)``""" + pass + + @abstractmethod + def spin(self, angle: int, duration: float) -> Self: + """Spins the robot for a given number of degrees over time, with 360° being a single revolution. + For example, to spin the robot 360° over 1s, use: ``spin(360, 1)``. + Use :func:`set_speed` prior to :func:`spin` to have the robot move in circle or an arc or circle. + + Note: Unlike official API, performance of spin is guaranteed, but may be longer than the specified duration.""" + pass + + @abstractmethod + def set_waddle(self, waddle: bool) -> Self: + """Turns the waddle walk on using `set_waddle(True)`` and off using ``set_waddle(False)``.""" + pass + + @abstractmethod + def roll_curve(self, curve: CurveRoll) -> Self: + """ + run a curve rolling frame by frame until it reach the duration. + """ + pass + + @abstractmethod + def stop_roll(self, heading: int = None) -> Self: + """Sets the speed to zero to stop the robot, effectively the same as the ``set_speed(0)`` command.""" + pass + + @abstractmethod + def reset_aim(self) -> Self: + """Resets the heading calibration (aim) angle to use the current direction of the robot as 0°.""" + pass + + @abstractmethod + def set_compass_direction(self, direction: int = 0) -> Self: + """ + Sets the direction relative to compass zero + """ + pass + + # below are events methods. only need call them for certain and clear purpose. + + @abstractmethod + def on_collision( + self, + log: str = "feeling collision", + callback: Optional[Callable[[Self], None]] = None, + ) -> None: + """ + when the bolt feeling collision. default is stop. + for example: + `move.on_collision(lambda m: m.spin(180, 1))` means when collision, spin 180 degree in 1 second. + """ + pass + + @abstractmethod + def on_freefall( + self, + log: str = "feeling freefall", + callback: Optional[Callable[[Self], None]] = None, + ) -> None: + """ + when the bolt feeling free fall. default is stop. + """ + pass + + @abstractmethod + def on_landing( + self, + log: str = "feeling landing", + callback: Optional[Callable[[Self], None]] = None, + ) -> Self: + """ + when the bolt feeling landing. default is stop. + """ + pass + + +class Ball(ABC): + """ + Sphero bolt body (which is a rolling ball) control interface. + """ + + @abstractmethod + def new_move(self, run_immediately: bool = False) -> Move: + """ + create a new Move instance, to define a sequence of movements. + :param run_immediately: run immediately if True, otherwise the move will not execute until run it. + """ + pass + + @abstractmethod + def run(self, move: Move, stop_at_first: bool = True) -> None: + """ + run the bolt ball movement + :param move: the Move instance that defined the movements by calling it methods one by one. + :param stop_at_first: shall stop any movement of the ball before executing the new move? + """ + pass + + @abstractmethod + def save_move(self, name: str, description: str, move: Move) -> None: + """ + define a move that you can call it anytime with the name only. + **remember** only save the important move + :param name: move name + :param description: describe the move, in less than 100 words + :param move: the Move instance. + """ + pass + + @abstractmethod + def set_matrix_rotation(self, rotation: Literal[0, 90, 180, 360] = 0) -> None: + """ + Rotates the LED matrix + :param rotation: 0 to 90, 180, 360 degrees + """ + pass + + @abstractmethod + def run_move(self, name: str) -> None: + """ + run a defined move + :param name: the name of the move. make sure you have run save_move() before calling it. + :raise: NotImplementedError if move is not defined + """ + pass + + @abstractmethod + def on_charging( + self, + log: str = "feeling at charging", + ) -> None: + """ + when the bolt feeling start charging + """ + pass + + @abstractmethod + def on_not_charging( + self, + log: str = "feeling stop charging", + ) -> None: + """ + when the bolt feeling stop charging + """ + pass + + +class Color(NamedTuple): + """ + tuple of RGB colors + """ + r: int + g: int + b: int + + +class Animation(ABC): + """ + to define an animation by sequence of frame. + the animation will be played on Sphero Bolt 8*8 led matrix. + """ + + @abstractmethod + def frame(self, matrix: List[List[Union[str, Color]]]) -> Self: + """ + define a frame of the Bolt LED matrix. + :param matrix: 8 * 8 array, each element is either an RGB Color tuple or a defined palette color name. + :return: + """ + pass + + @abstractmethod + def scroll_matrix_text(self, text: str, color_name: str, fps: int, wait: bool) -> Self: + """ + Scrolls text on the matrix, with specified color. + text max 25 characters + Fps 1 to 30 + wait: if the programs wait until completion + """ + pass + + @abstractmethod + def set_matrix_character(self, character: str, color_name: str): + """ + Sets a character on the matrix with color specified + """ + pass + + +class LedMatrix(ABC): + + @abstractmethod + def new_animation(self, fps: int = 1, transition: bool = True) -> Animation: + """ + create a new animation instance, to define a sequence of frames. + :param fps: + :param transition: + :return: + """ + pass + + @abstractmethod + def play_animation(self, animation: Animation, loop: int = 0) -> None: + pass + + @abstractmethod + def save_expression(self, name: str, description: str, animation: Animation) -> None: + """ + save animation as an expression, that you can play it when every you feel it. + """ + pass + + @abstractmethod + def play_expression(self, name: str, loop: int = 0) -> None: + """ + :param name: name of the defined expression animation. + :param loop: how many times the animation is played. zero means play forever. + """ + pass + + @abstractmethod + def save_palette(self, color_name: str, color: Color) -> None: + """ + save the color to the palette + :param color_name: such as 'red', 'green', 'blue' + :param color: RGB Color tuple + """ + pass + + @abstractmethod + def pause_animation(self) -> None: + """ + pause the playing animation + """ + pass + + @abstractmethod + def resume_animation(self): + """ + resume the playing animation + """ + pass + + @abstractmethod + def clear_matrix(self): + """ + clear the matrix. + """ + pass diff --git a/ghostos/prototypes/spherogpt/bolt/shell_impl.py b/ghostos/prototypes/spherogpt/bolt/shell_impl.py new file mode 100644 index 00000000..bceed30c --- /dev/null +++ b/ghostos/prototypes/spherogpt/bolt/shell_impl.py @@ -0,0 +1,250 @@ +from typing import Literal, Optional, Callable, Self, Dict + +from spherov2.commands.io import FrameRotationOptions + +from ghostos.contracts.storage import FileStorage +from ghostos.contracts.workspace import Workspace +from ghostos.entity import ModelEntityMeta, from_entity_model_meta, to_entity_model_meta +from ghostos.helpers import yaml_pretty_dump +from ghostos.prompter import Prompter +from ghostos.container import Container, Provider +from pydantic import BaseModel, Field +from .shell import Ball, Move, CurveRoll +from .runtime import SpheroBoltRuntime, BoltBallMovement +from .movements import ( + GroupMovement, + RunAPIMovement, + CurveRollMovement, +) +import yaml + +__all__ = ['SpheroBoltBallAPIProvider', 'BallApi'] + + +class SavedMove(BaseModel): + name: str = Field(description="move name") + description: str = Field(description="move description") + move_meta: ModelEntityMeta = Field(description="move meta") + + @classmethod + def new(cls, name: str, description: str, move: BoltBallMovement) -> Self: + return SavedMove( + name=name, + description=description, + move_meta=to_entity_model_meta(move), + ) + + def get_move(self) -> BoltBallMovement: + return from_entity_model_meta(self.move_meta) + + +class MovesMemoryCache(BaseModel): + moves: Dict[str, SavedMove] = Field(default_factory=dict) + + def add_saved(self, saved: SavedMove): + self.moves[saved.name] = saved + + @staticmethod + def filename(unique_id: str) -> str: + return f"{unique_id}_sphero_moves.yml" + + def to_content(self) -> str: + return yaml_pretty_dump(self.model_dump()) + + +class MoveAdapter(Move): + + def __init__( + self, + runtime: SpheroBoltRuntime, + run_immediately: bool, + event_desc: Optional[str] = None, + ): + self._runtime = runtime + self._run_immediately = run_immediately + self._move_added: int = 0 + self.buffer: GroupMovement = GroupMovement(desc="", event_desc=event_desc) + + def _add_move(self, movement: BoltBallMovement): + if self._run_immediately: + movement.stop_at_first = self._move_added == 0 + self._runtime.add_movement(movement) + else: + self._runtime.add_movement(movement) + + self._move_added += 1 + + def roll(self, heading: int, speed: int, duration: float) -> Self: + self._add_move(RunAPIMovement( + desc="roll", + method="roll", + duration=duration, + args=[heading, speed, duration], + )) + return self + + def spin(self, angle: int, duration: float) -> Self: + self._add_move(RunAPIMovement( + desc="spin", + method="spin", + duration=duration, + args=[angle, duration], + )) + return self + + def set_waddle(self, waddle: bool) -> Self: + self._add_move(RunAPIMovement( + desc="set_waddle", + method="set_waddle", + duration=0.0, + args=[waddle], + )) + return self + + def roll_curve(self, curve: CurveRoll) -> Self: + self._add_move(CurveRollMovement( + desc="roll_curve", + curve=curve, + )) + return self + + def stop_roll(self, heading: int = None) -> Self: + self._add_move(RunAPIMovement( + desc="stop_roll", + method="stop_roll", + duration=0.0, + args=[heading], + )) + return self + + def reset_aim(self) -> Self: + self._add_move(RunAPIMovement( + desc="reset_aim", + method="reset_aim", + duration=0.0, + args=[], + )) + return self + + def set_compass_direction(self, direction: int = 0) -> Self: + self._add_move(RunAPIMovement( + desc="reset_aim", + method="reset_aim", + duration=0.0, + args=[], + )) + return self + + def on_collision(self, log: str = "feeling collision", callback: Optional[Callable[[Self], None]] = None) -> None: + self._add_event_callback("on_collision", log, callback) + + def _add_event_callback( + self, + event_name: str, + log: str, + callback: Optional[Callable[[Self], None]] = None, + ) -> None: + sub_move = MoveAdapter( + runtime=self._runtime, + run_immediately=False, + event_desc=log, + ) + if callback is not None: + callback(sub_move) + event_move = sub_move.buffer + event_move.stop_at_first = True + self.buffer.event_moves[event_name] = event_move + + def on_freefall(self, log: str = "feeling freefall", callback: Optional[Callable[[Self], None]] = None) -> None: + self._add_event_callback("on_freefall", log, callback) + + def on_landing(self, log: str = "feeling landing", callback: Optional[Callable[[Self], None]] = None) -> Self: + self._add_event_callback("on_landing", log, callback) + + +class BallApi(Ball, Prompter): + + def __init__( + self, + runtime: SpheroBoltRuntime, + memory_cache: FileStorage, + ): + self._runtime = runtime + self._memory_cache_storage = memory_cache + self._memory_cache_file = MovesMemoryCache.filename(self._runtime.get_task_id()) + if self._memory_cache_storage.exists(self._memory_cache_file): + content = self._memory_cache_storage.get(self._memory_cache_file) + data = yaml.safe_load(content) + self._memory_cache = MovesMemoryCache(**data) + else: + self._memory_cache = MovesMemoryCache() + + def _save_cache(self): + content = self._memory_cache.to_content() + self._memory_cache_storage.put(self._memory_cache_file, content.encode()) + + def new_move(self, run_immediately: bool = False) -> Move: + return MoveAdapter(self._runtime, run_immediately) + + def run(self, move: Move, stop_at_first: bool = True) -> None: + if not isinstance(move, MoveAdapter): + raise TypeError(f"move instance must be created by this api new_move()") + movement = move.buffer + movement.stop_at_first = stop_at_first + self._runtime.add_movement(movement) + + def save_move(self, name: str, description: str, move: Move) -> None: + if not isinstance(move, MoveAdapter): + raise TypeError(f"move instance must be created by this api new_move()") + saved_move = SavedMove.new(name=name, description=description, move=move.buffer) + self._memory_cache.add_saved(saved_move) + self._save_cache() + + def set_matrix_rotation(self, rotation: Literal[0, 90, 180, 270] = 0) -> None: + rotations = { + 0: FrameRotationOptions.NORMAL, + 90: FrameRotationOptions.ROTATE_90_DEGREES, + 180: FrameRotationOptions.ROTATE_180_DEGREES, + 270: FrameRotationOptions.ROTATE_270_DEGREES, + } + move = RunAPIMovement( + desc="set_matrix_rotation", + method="set_matrix_rotation", + args=[rotations.get(rotation, FrameRotationOptions.NORMAL)] + ) + self._runtime.add_movement(move) + + def run_move(self, name: str) -> None: + got = self._memory_cache.moves.get(name, None) + if got is None: + raise NotImplementedError(f"move {name} not implemented") + self.run(got, stop_at_first=True) + + def on_charging(self, log: str = "feeling at charging") -> None: + self._runtime.set_charging_callback(log) + + def on_not_charging(self, log: str = "feeling stop charging") -> None: + self._runtime.set_off_charging_callback(log) + + def self_prompt(self, container: Container) -> str: + if len(self._memory_cache.moves) == 0: + return "" + lines = [] + for move in self._memory_cache.moves.values(): + line = f"- `{move.name}`: {move.description}" + lines.append(line) + return "saved moves, from name to description:\n".join(lines) + "\n\nyou can run the saved move by it's name" + + def get_title(self) -> str: + return "SpheroBolt Ball saved moves" + + +class SpheroBoltBallAPIProvider(Provider[Ball]): + + def singleton(self) -> bool: + return True + + def factory(self, con: Container) -> Optional[Ball]: + runtime = con.force_fetch(SpheroBoltRuntime) + workspace = con.force_fetch(Workspace) + return BallApi(runtime, workspace.runtime_cache()) diff --git a/ghostos/prototypes/streamlitapp/pages/ghosts.py b/ghostos/prototypes/streamlitapp/pages/ghosts.py index f7e4cfff..791ea8fe 100644 --- a/ghostos/prototypes/streamlitapp/pages/ghosts.py +++ b/ghostos/prototypes/streamlitapp/pages/ghosts.py @@ -376,6 +376,7 @@ def render_thread_messages(thread: GoThreadInfo, max_turn: int = 20): count += render_turn(turn, debug) if count == 0: st.info("No thread messages yet") + else: st.empty() diff --git a/tests/python/test_typing.py b/tests/python/test_typing.py index ef37c3af..7dc7d81c 100644 --- a/tests/python/test_typing.py +++ b/tests/python/test_typing.py @@ -1,4 +1,4 @@ -from typing import Union, TypedDict, Optional +from typing import Union, TypedDict, Optional, Literal import inspect @@ -69,3 +69,11 @@ def loo(self): assert typehints['car'] is str assert 'good' not in typehints assert 'loo' not in typehints + + +def test_literal_int(): + def foo(v: Literal[1, 2, 3]) -> int: + return v + + a = foo(3) + assert a == 3 From 6d1115e940bac8f5b0fb2a7629a0e5468f20b7e5 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 9 Dec 2024 01:29:46 +0800 Subject: [PATCH 126/148] feat: complete sphero v2 --- examples/sphero/bolt_gpt_shell_agent.py | 15 +- ghostos/abcd/concepts.py | 2 +- ghostos/abcd/utils.py | 2 +- ghostos/framework/storage/memstorage.py | 7 +- ghostos/framework/workspaces/__init__.py | 2 +- ghostos/ghosts/moss_agent/agent.py | 26 ++- ghostos/prototypes/spherogpt/bolt/__init__.py | 6 +- .../prototypes/spherogpt/bolt/animations.py | 0 .../bolt/{shell_impl.py => ball_impl.py} | 52 +++-- .../spherogpt/bolt/led_matrix_impl.py | 137 +++++++++++ .../prototypes/spherogpt/bolt/movements.py | 45 ++-- ghostos/prototypes/spherogpt/bolt/runtime.py | 10 +- .../prototypes/spherogpt/bolt/runtime_impl.py | 114 ++++++++-- ghostos/prototypes/spherogpt/bolt/shell.py | 214 ++++++++++++------ .../spherogpt/bolt/sphero_edu_api_patch.py | 29 +++ .../prototypes/spherogpt/bolt/test_main.py | 41 ++++ .../streamlitapp/widgets/messages.py | 13 +- tests/python/test_class.py | 19 ++ 18 files changed, 572 insertions(+), 162 deletions(-) delete mode 100644 ghostos/prototypes/spherogpt/bolt/animations.py rename ghostos/prototypes/spherogpt/bolt/{shell_impl.py => ball_impl.py} (82%) create mode 100644 ghostos/prototypes/spherogpt/bolt/led_matrix_impl.py create mode 100644 ghostos/prototypes/spherogpt/bolt/sphero_edu_api_patch.py create mode 100644 ghostos/prototypes/spherogpt/bolt/test_main.py diff --git a/examples/sphero/bolt_gpt_shell_agent.py b/examples/sphero/bolt_gpt_shell_agent.py index 91dff29c..e141c0fd 100644 --- a/examples/sphero/bolt_gpt_shell_agent.py +++ b/examples/sphero/bolt_gpt_shell_agent.py @@ -1,7 +1,9 @@ from ghostos.prototypes.spherogpt.bolt import ( - CurveRoll, + RollFunc, Ball, Move, + LedMatrix, + Animation, ) from ghostos.core.moss import Moss as Parent @@ -10,6 +12,9 @@ class Moss(Parent): body: Ball """your sphero ball body""" + face: LedMatrix + """you 8*8 led matrix face""" + def example_spin_the_bolt(moss: Moss): # body spin 360 degree in 1 second. @@ -26,8 +31,12 @@ def __moss_attr_prompts__(): def __moss_agent_providers__(agent): - from ghostos.prototypes.spherogpt.bolt import SpheroBoltBallAPIProvider, ConvoLevelSpheroBoltRuntimeProvider - return [SpheroBoltBallAPIProvider(), ConvoLevelSpheroBoltRuntimeProvider()] + from ghostos.prototypes.spherogpt.bolt import ( + SpheroBoltBallAPIProvider, + ConvoLevelSpheroBoltRuntimeProvider, + SpheroBoltLedMatrixProvider, + ) + return [SpheroBoltBallAPIProvider(), ConvoLevelSpheroBoltRuntimeProvider(), SpheroBoltLedMatrixProvider()] __ghost__ = MossAgent( diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index 7f02eed4..6d037162 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -617,7 +617,7 @@ def get_instructions(self) -> str: pass @abstractmethod - def refresh(self) -> bool: + def refresh(self, throw: bool = False) -> bool: """ refresh the session, update overdue time and task lock. """ diff --git a/ghostos/abcd/utils.py b/ghostos/abcd/utils.py index 4cf99a89..9532f959 100644 --- a/ghostos/abcd/utils.py +++ b/ghostos/abcd/utils.py @@ -77,7 +77,7 @@ def run_session_event(session: Session, event: Event, max_step: int) -> None: step += 1 if step > max_step: raise RuntimeError(f"Max step {max_step} reached") - if not session.refresh(): + if not session.refresh(True): raise RuntimeError("Session refresh failed") session.logger.debug("start session op %s", repr(op)) next_op = op.run(session) diff --git a/ghostos/framework/storage/memstorage.py b/ghostos/framework/storage/memstorage.py index c4a20e45..64cb2ea1 100644 --- a/ghostos/framework/storage/memstorage.py +++ b/ghostos/framework/storage/memstorage.py @@ -1,17 +1,20 @@ from typing import Optional, Iterable, Dict -from ghostos.contracts.storage import Storage +from ghostos.contracts.storage import FileStorage from os.path import join __all__ = ["MemStorage"] -class MemStorage(Storage): +class MemStorage(FileStorage): def __init__(self, saved: Dict[str, bytes] = None, namespace: str = ""): self._namespace = namespace self._saved: Dict[str, bytes] = saved if saved else {} + def abspath(self) -> str: + return "/test/mem/" + def sub_storage(self, relative_path: str) -> "Storage": namespace = self._namespace if relative_path: diff --git a/ghostos/framework/workspaces/__init__.py b/ghostos/framework/workspaces/__init__.py index 43cc7c6a..328db359 100644 --- a/ghostos/framework/workspaces/__init__.py +++ b/ghostos/framework/workspaces/__init__.py @@ -1 +1 @@ -from ghostos.framework.workspaces.basic import BasicWorkspaceProvider +from ghostos.framework.workspaces.basic import BasicWorkspaceProvider, BasicWorkspace diff --git a/ghostos/ghosts/moss_agent/agent.py b/ghostos/ghosts/moss_agent/agent.py index eb9ded6e..2e6fac8f 100644 --- a/ghostos/ghosts/moss_agent/agent.py +++ b/ghostos/ghosts/moss_agent/agent.py @@ -309,17 +309,19 @@ def bind(self, session: Session) -> None: class MossAction(Action, PromptPipe): + DEFAULT_NAME: ClassVar[str] = "moss" + class Argument(BaseModel): - name: ClassVar[str] = "moss" code: str = Field( description="generated moss code", ) - def __init__(self, runtime: MossRuntime): + def __init__(self, runtime: MossRuntime, name: str = DEFAULT_NAME): self.runtime: MossRuntime = runtime + self._name = name def name(self) -> str: - return self.Argument.name + return self._name def as_function(self) -> Optional[LLMFunc]: parameters = self.Argument.model_json_schema() @@ -336,16 +338,24 @@ def update_prompt(self, prompt: Prompt) -> Prompt: prompt.functions.append(llm_func) return prompt - def run(self, session: Session, caller: Caller) -> Union[Operator, None]: - # prepare arguments. - arguments = caller.arguments + @classmethod + def unmarshal_arguments(cls, arguments: str) -> str: try: data = json.loads(arguments) - args = self.Argument(**data) + args = cls.Argument(**data) except json.JSONDecodeError: content = arguments - args = self.Argument(code=content) + args = cls.Argument(code=content) code = args.code.strip() + return code.strip() + + def run(self, session: Session, caller: Caller) -> Union[Operator, None]: + # prepare arguments. + arguments = caller.arguments + code = self.unmarshal_arguments(arguments) + if code.startswith("{") and code.endswith("}"): + # unmarshal again. + code = self.unmarshal_arguments(code) # if code is not exists, inform the llm if not code: diff --git a/ghostos/prototypes/spherogpt/bolt/__init__.py b/ghostos/prototypes/spherogpt/bolt/__init__.py index 52083640..c247fc40 100644 --- a/ghostos/prototypes/spherogpt/bolt/__init__.py +++ b/ghostos/prototypes/spherogpt/bolt/__init__.py @@ -1,11 +1,11 @@ -from ghostos.prototypes.spherogpt.bolt.shell_impl import SpheroBoltBallAPIProvider +from ghostos.prototypes.spherogpt.bolt.ball_impl import SpheroBoltBallAPIProvider from ghostos.prototypes.spherogpt.bolt.runtime_impl import ConvoLevelSpheroBoltRuntimeProvider +from ghostos.prototypes.spherogpt.bolt.led_matrix_impl import SpheroBoltLedMatrixProvider from ghostos.prototypes.spherogpt.bolt.shell import ( - CurveRoll, + RollFunc, Ball, Move, LedMatrix, - Color, Animation, ) diff --git a/ghostos/prototypes/spherogpt/bolt/animations.py b/ghostos/prototypes/spherogpt/bolt/animations.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ghostos/prototypes/spherogpt/bolt/shell_impl.py b/ghostos/prototypes/spherogpt/bolt/ball_impl.py similarity index 82% rename from ghostos/prototypes/spherogpt/bolt/shell_impl.py rename to ghostos/prototypes/spherogpt/bolt/ball_impl.py index bceed30c..de20ed05 100644 --- a/ghostos/prototypes/spherogpt/bolt/shell_impl.py +++ b/ghostos/prototypes/spherogpt/bolt/ball_impl.py @@ -9,16 +9,17 @@ from ghostos.prompter import Prompter from ghostos.container import Container, Provider from pydantic import BaseModel, Field -from .shell import Ball, Move, CurveRoll -from .runtime import SpheroBoltRuntime, BoltBallMovement -from .movements import ( +from ghostos.prototypes.spherogpt.bolt.shell import Ball, Move, RollFunc, Animation +from ghostos.prototypes.spherogpt.bolt.runtime import SpheroBoltRuntime, BoltBallMovement +from ghostos.prototypes.spherogpt.bolt.led_matrix_impl import PauseAnimation, PlayAnimation +from ghostos.prototypes.spherogpt.bolt.movements import ( GroupMovement, RunAPIMovement, CurveRollMovement, ) import yaml -__all__ = ['SpheroBoltBallAPIProvider', 'BallApi'] +__all__ = ['SpheroBoltBallAPIProvider', 'BallImpl'] class SavedMove(BaseModel): @@ -44,6 +45,12 @@ class MovesMemoryCache(BaseModel): def add_saved(self, saved: SavedMove): self.moves[saved.name] = saved + def get_move(self, name: str) -> Optional[BoltBallMovement]: + got = self.moves.get(name, None) + if got is None: + return None + return from_entity_model_meta(got.move_meta) + @staticmethod def filename(unique_id: str) -> str: return f"{unique_id}_sphero_moves.yml" @@ -58,20 +65,22 @@ def __init__( self, runtime: SpheroBoltRuntime, run_immediately: bool, + animation: Optional[Animation] = None, event_desc: Optional[str] = None, ): self._runtime = runtime self._run_immediately = run_immediately + self._animation = animation self._move_added: int = 0 self.buffer: GroupMovement = GroupMovement(desc="", event_desc=event_desc) def _add_move(self, movement: BoltBallMovement): if self._run_immediately: movement.stop_at_first = self._move_added == 0 - self._runtime.add_movement(movement) - else: + movement.animation = self._animation self._runtime.add_movement(movement) + self.buffer.add_child(movement) self._move_added += 1 def roll(self, heading: int, speed: int, duration: float) -> Self: @@ -101,10 +110,10 @@ def set_waddle(self, waddle: bool) -> Self: )) return self - def roll_curve(self, curve: CurveRoll) -> Self: + def roll_by_func(self, fn: RollFunc) -> Self: self._add_move(CurveRollMovement( desc="roll_curve", - curve=curve, + curve=fn, )) return self @@ -162,7 +171,7 @@ def on_landing(self, log: str = "feeling landing", callback: Optional[Callable[[ self._add_event_callback("on_landing", log, callback) -class BallApi(Ball, Prompter): +class BallImpl(Ball, Prompter): def __init__( self, @@ -183,8 +192,8 @@ def _save_cache(self): content = self._memory_cache.to_content() self._memory_cache_storage.put(self._memory_cache_file, content.encode()) - def new_move(self, run_immediately: bool = False) -> Move: - return MoveAdapter(self._runtime, run_immediately) + def new_move(self, run_immediately: bool = False, animation: Optional[Animation] = None) -> Move: + return MoveAdapter(self._runtime, run_immediately, animation=animation) def run(self, move: Move, stop_at_first: bool = True) -> None: if not isinstance(move, MoveAdapter): @@ -200,6 +209,11 @@ def save_move(self, name: str, description: str, move: Move) -> None: self._memory_cache.add_saved(saved_move) self._save_cache() + def delete_move(self, name: str) -> None: + if name in self._memory_cache.moves: + del self._memory_cache.moves[name] + self._save_cache() + def set_matrix_rotation(self, rotation: Literal[0, 90, 180, 270] = 0) -> None: rotations = { 0: FrameRotationOptions.NORMAL, @@ -215,10 +229,11 @@ def set_matrix_rotation(self, rotation: Literal[0, 90, 180, 270] = 0) -> None: self._runtime.add_movement(move) def run_move(self, name: str) -> None: - got = self._memory_cache.moves.get(name, None) + got = self._memory_cache.get_move(name) if got is None: raise NotImplementedError(f"move {name} not implemented") - self.run(got, stop_at_first=True) + got.stop_at_first = True + self._runtime.add_movement(got) def on_charging(self, log: str = "feeling at charging") -> None: self._runtime.set_charging_callback(log) @@ -233,7 +248,12 @@ def self_prompt(self, container: Container) -> str: for move in self._memory_cache.moves.values(): line = f"- `{move.name}`: {move.description}" lines.append(line) - return "saved moves, from name to description:\n".join(lines) + "\n\nyou can run the saved move by it's name" + saved_content = "\n".join(lines) + return f""" +your saved moves, from name to description are below: +{saved_content} +you can run the saved move by it's name +""" def get_title(self) -> str: return "SpheroBolt Ball saved moves" @@ -242,9 +262,9 @@ def get_title(self) -> str: class SpheroBoltBallAPIProvider(Provider[Ball]): def singleton(self) -> bool: - return True + return False def factory(self, con: Container) -> Optional[Ball]: runtime = con.force_fetch(SpheroBoltRuntime) workspace = con.force_fetch(Workspace) - return BallApi(runtime, workspace.runtime_cache()) + return BallImpl(runtime, workspace.runtime_cache()) diff --git a/ghostos/prototypes/spherogpt/bolt/led_matrix_impl.py b/ghostos/prototypes/spherogpt/bolt/led_matrix_impl.py new file mode 100644 index 00000000..8ca5323b --- /dev/null +++ b/ghostos/prototypes/spherogpt/bolt/led_matrix_impl.py @@ -0,0 +1,137 @@ +from typing import List, Dict, Self, Optional, AnyStr + +from .sphero_edu_api_patch import SpheroEduAPI + +from ghostos.prototypes.spherogpt.bolt.shell import LedMatrix, Animation +from ghostos.prototypes.spherogpt.bolt.runtime import BoltLedMatrixCommand, SpheroBoltRuntime +from ghostos.prototypes.spherogpt.bolt.sphero_edu_api_patch import Color +from ghostos.container import Container, Provider +from pydantic import BaseModel, Field + + +def parse_str_to_color(s: AnyStr) -> Color: + color = str(s).lower() + if not color.startswith("0x"): + color = "0x" + color + digits = int(color, 0) + return Color(digits >> 16, (digits >> 8) % 256, digits % 256) + + +class AnimationMemoryCache(BaseModel): + palette: Dict[str, List[int]] = Field(default_factory=dict, description="Palette of colors. name to (r, g, b)") + + def add_palette(self, name: str, color: Color): + self.palette[name] = [color.r, color.g, color.b] + + +class ResumeAnimation(BoltLedMatrixCommand): + + def start(self, api: SpheroEduAPI) -> None: + api.resume_matrix_animation() + + +class ClearMatrix(BoltLedMatrixCommand): + + def start(self, api: SpheroEduAPI) -> None: + api.clear_matrix() + + +class PauseAnimation(BoltLedMatrixCommand): + + def start(self, api: SpheroEduAPI) -> None: + api.pause_matrix_animation() + + +class ScrollMatrixText(BoltLedMatrixCommand): + text: str = Field(description="the outputting text") + color: str = Field(default="ffffff", description="the palette color of the text") + fps: int = Field(default=1, description="the fps of the animation") + wait: bool = Field(default=True, description="wait for the animation to finish") + + def start(self, api: SpheroEduAPI) -> None: + rgb = parse_str_to_color(str(self.color)) + api.scroll_matrix_text(self.text, rgb, self.fps, self.wait) + + +class SetMatrixChar(BoltLedMatrixCommand): + character: str = Field(description="the charactor") + color: str = Field(default="ffffff", description="the palette color of the text") + + def start(self, api: SpheroEduAPI) -> None: + color = parse_str_to_color(self.color) + api.set_matrix_character(self.character, color) + + +class PlayAnimation(BoltLedMatrixCommand): + animation: Animation + + def start(self, api: SpheroEduAPI) -> None: + frames = self.animation.frames + fps = self.animation.fps + palette = [] + for color in self.animation.palette: + rgb = parse_str_to_color(color) + palette.append(rgb) + + api.register_matrix_animation( + frames, + fps=fps, + palette=palette, + transition=self.animation.transition, + ) + aid = api.get_animation_id() + api.play_matrix_animation(aid, self.animation.loop) + + def end(self, api: SpheroEduAPI, passed: float) -> bool: + duration = self.animation.duration + if 0 < duration <= passed: + api.clear_matrix() + return True + return False + + +class LedMatrixImpl(LedMatrix): + + def __init__(self, runtime: SpheroBoltRuntime): + self._runtime = runtime + self.last_command: Optional[BoltLedMatrixCommand] = None + + def _add_command(self, command: BoltLedMatrixCommand): + self._runtime.add_matrix_command(command) + self.last_command = command + + def play_animation(self, animation: Animation) -> None: + pa = PlayAnimation(animation=animation) + self._runtime.add_matrix_command(pa) + + def scroll_matrix_text(self, text: str, color: str = 'ffffff', fps: int = 1, wait: bool = True) -> Self: + if len(text) > 25: + raise AttributeError("Text length must be less than 25 characters") + s = ScrollMatrixText(text=text, color_name=color, fps=fps, wait=wait) + self._add_command(s) + return self + + def set_matrix_character(self, character: str, color: str): + s = SetMatrixChar(character=character, color=color) + self._add_command(s) + + def pause_animation(self) -> None: + self._add_command(PauseAnimation()) + + def resume_animation(self): + self._add_command(ResumeAnimation()) + + def clear_matrix(self): + self._add_command(ClearMatrix()) + + +class SpheroBoltLedMatrixProvider(Provider[LedMatrix]): + + def singleton(self) -> bool: + return False + + def factory(self, con: Container) -> Optional[LedMatrix]: + runtime = con.force_fetch(SpheroBoltRuntime) + return LedMatrixImpl( + runtime=runtime, + ) diff --git a/ghostos/prototypes/spherogpt/bolt/movements.py b/ghostos/prototypes/spherogpt/bolt/movements.py index ccb49773..dd79daeb 100644 --- a/ghostos/prototypes/spherogpt/bolt/movements.py +++ b/ghostos/prototypes/spherogpt/bolt/movements.py @@ -1,10 +1,10 @@ from typing import List, Union, Dict, Optional, Self -from spherov2.sphero_edu import SpheroEduAPI from pydantic import BaseModel, Field from ghostos.entity import ModelEntityMeta, to_entity_model_meta, from_entity_model_meta from .runtime import BoltBallMovement -from .shell import CurveRoll +from .sphero_edu_api_patch import SpheroEduAPI +from .shell import RollFunc, Animation class RunAPIMovement(BoltBallMovement): @@ -27,7 +27,7 @@ def on_event(self, event_type: str) -> Optional[Self]: class CurveRollMovement(BoltBallMovement): desc: str = Field(description="desc of the movement") - curve: CurveRoll = Field(description="curve roll") + curve: RollFunc = Field(description="curve roll") stopped: bool = Field(default=False) error: str = Field("") @@ -35,13 +35,10 @@ def start(self, api: SpheroEduAPI) -> None: self.run_frame(api, 0) def run_frame(self, api: SpheroEduAPI, passed: float) -> bool: - try: - self.stopped = self.curve.run_frame(passed) - except Exception as e: - self.error = str(e) - return False - api.set_speed(self.curve.speed) - api.set_heading(self.curve.heading) + self.stopped = self.curve.run_frame(passed) + if not self.stopped: + api.set_speed(self.curve.speed) + api.set_heading(self.curve.heading) return self.stopped def on_event(self, event_type: str) -> Optional[Self]: @@ -50,10 +47,11 @@ def on_event(self, event_type: str) -> Optional[Self]: class GroupMovement(BoltBallMovement): children: List[ModelEntityMeta] = Field(default_factory=list) - iter_idx: int = Field(default=0) - new_child_start_at: float = Field(default=0.0) event_desc: Optional[str] = Field(default=None) event_moves: Dict[str, ModelEntityMeta] = Field(default_factory=dict) + __iter_idx__: int = 0 + __new_child_started__: bool = False + __new_child_start_at__: float = 0.0 def add_child(self, move: BoltBallMovement): meta = to_entity_model_meta(move) @@ -65,19 +63,26 @@ def get_child(self, idx: int) -> BoltBallMovement: def start(self, api: SpheroEduAPI) -> None: if len(self.children) > 0: - self.iter_idx = 0 - child = self.get_child(self.iter_idx) + self.__iter_idx__ = 0 + child = self.get_child(self.__iter_idx__) child.start(api) + self.__new_child_started__ = True def run_frame(self, api: SpheroEduAPI, passed: float) -> bool: - if self.iter_idx >= len(self.children): - return False - child = self.get_child(self.iter_idx) - child_passed = passed - self.new_child_start_at + if self.__iter_idx__ >= len(self.children): + return True + child = self.get_child(self.__iter_idx__) + # start if not started + if not self.__new_child_started__: + child.start(api) + self.__new_child_started__ = True + + child_passed = passed - self.__new_child_start_at__ stopped = child.run_frame(api, child_passed) if stopped: - self.iter_idx += 1 - self.new_child_start_at = passed + self.__iter_idx__ += 1 + self.__new_child_start_at__ = passed + self.__new_child_started__ = False return self.run_frame(api, passed) return False diff --git a/ghostos/prototypes/spherogpt/bolt/runtime.py b/ghostos/prototypes/spherogpt/bolt/runtime.py index b9ecfbe8..e5d261fc 100644 --- a/ghostos/prototypes/spherogpt/bolt/runtime.py +++ b/ghostos/prototypes/spherogpt/bolt/runtime.py @@ -4,12 +4,15 @@ from spherov2.sphero_edu import SpheroEduAPI from pydantic import BaseModel, Field +from ghostos.prototypes.spherogpt.bolt.shell import Animation + _STOPPED = bool class BoltBallMovement(BaseModel, ABC): desc: str = Field("", description="description of the command") stop_at_first: bool = Field(default=False, description="stop the world at first") + animation: Optional[Animation] = Field(default=None) @abstractmethod def start(self, api: SpheroEduAPI) -> None: @@ -32,12 +35,15 @@ def on_event(self, event_type: str) -> Optional[Self]: pass -class BoltLedMatrixAnimation(ModelEntity, ABC): +class BoltLedMatrixCommand(ModelEntity, ABC): @abstractmethod def start(self, api: SpheroEduAPI) -> None: pass + def end(self, api: SpheroEduAPI, passed: float) -> bool: + return True + class SpheroBoltRuntime(ABC): @@ -58,7 +64,7 @@ def set_off_charging_callback(self, event: str): pass @abstractmethod - def add_animation(self, command: BoltLedMatrixAnimation): + def add_matrix_command(self, command: BoltLedMatrixCommand): pass @abstractmethod diff --git a/ghostos/prototypes/spherogpt/bolt/runtime_impl.py b/ghostos/prototypes/spherogpt/bolt/runtime_impl.py index 0fa6d9e6..7e37516a 100644 --- a/ghostos/prototypes/spherogpt/bolt/runtime_impl.py +++ b/ghostos/prototypes/spherogpt/bolt/runtime_impl.py @@ -1,9 +1,6 @@ -from typing import Optional, Callable, Type, Self, ClassVar -from .runtime import SpheroBoltRuntime, BoltLedMatrixAnimation, BoltBallMovement +from typing import Optional, Callable, Type import time -from spherov2 import scanner -from spherov2.sphero_edu import SpheroEduAPI, EventType as SpheroEventType from ghostos.contracts.logger import LoggerItf from ghostos.core.messages import MessageType from ghostos.core.runtime import EventBus, Event, EventTypes as GhostOSEventTypes @@ -12,6 +9,9 @@ from ghostos.helpers import Timeleft from threading import Thread, Event from collections import deque +from .sphero_edu_api_patch import SpheroEduAPI, Color, SpheroEventType, scanner +from .runtime import SpheroBoltRuntime, BoltLedMatrixCommand, BoltBallMovement +from .led_matrix_impl import PlayAnimation __all__ = ['SpheroBoltRuntimeImpl', 'ConvoLevelSpheroBoltRuntimeProvider'] @@ -36,11 +36,25 @@ def __init__( self._error: Optional[str] = None self._main_thread = Thread(target=self._main_thread) self._move_queue = deque() + self._animation_queue = deque() + self._current_animation: Optional[BoltLedMatrixCommand] = None + self._current_animation_timeleft: Optional[Timeleft] = None + self._clear_matrix_timeleft: Optional[Timeleft] = None self._current_movement: Optional[BoltBallMovement] = None self._current_movement_timeleft: Optional[Timeleft] = None self._charging_callback: str = "feeling at charging" + self._breathing: bool = False + self._moving: bool = False self._off_charging_callback: str = "feeling stop charging" + def _reset_all_state(self): + self._current_animation = None + self._current_animation_timeleft = None + self._current_movement = None + self._current_movement_timeleft = None + self._animation_queue.clear() + self._move_queue.clear() + def bootstrap(self): if self._bootstrapped: self._logger.error(f"SpheroBolt Runtime already bootstrapped") @@ -56,7 +70,15 @@ def _main_thread(self): _bolt = scanner.find_BOLT() self._logger.info("SpheroBolt Bootstrap connected") connected_error = 0 - self._run_bolt_loop(_bolt) + # run the loop until errors. + try: + self._run_bolt_loop(_bolt) + except Exception as exc: + self._logger.exception(exc) + self._send_event(GhostOSEventTypes.ERROR, "error occur during runtime: %s" % str(exc)) + self._reset_all_state() + continue + except Exception as e: self._logger.exception(e) self._logger.info("SpheroBolt Bootstrap failed") @@ -67,22 +89,58 @@ def _main_thread(self): self._send_event(GhostOSEventTypes.ERROR, "sphero bolt failed to connected") raise RuntimeError(self._error) + def _strobe(self, api: SpheroEduAPI, passed: float): + if self._moving: + return + if int(passed) % 6 < 3: + if not self._breathing: + api.set_front_led(Color(0, 25, 0)) + api.set_back_led(Color(0, 25, 0)) + self._breathing = True + elif self._breathing: + api.set_front_led(Color(0, 0, 0)) + api.set_back_led(Color(0, 0, 0)) + self._breathing = False + + def _set_current_animation(self, animation: BoltLedMatrixCommand, api: SpheroEduAPI): + animation.start(api) + self._current_animation = animation + self._current_animation_timeleft = Timeleft(0) + + def _check_end_of_animation(self, api: SpheroEduAPI): + if self._current_animation is None or self._current_animation_timeleft is None: + return + if self._current_animation.end(api, self._current_animation_timeleft.passed()): + self._current_animation = None + self._current_animation_timeleft = None + def _run_bolt_loop(self, _bolt): + start_at = Timeleft(0) with SpheroEduAPI(_bolt) as api: self._init_sphero_edu_api(api) while not self._stopped.is_set(): + if len(self._animation_queue) > 0: + self._current_animation = None + self._current_movement_timeleft = None + animation_command: Optional[BoltLedMatrixCommand] = self._animation_queue.popleft() + # animation command execute immediately + self._set_current_animation(animation_command, api) + + # trigger end of animation. + self._check_end_of_animation(api) + if self._current_movement is None: - movement = self._start_new_movement(api) + movement = self._get_new_movement() if movement is not None: - self._set_current_movement(movement) + self._set_current_movement(movement, api) else: - # wait for new command. + self._strobe(api, start_at.passed()) time.sleep(0.5) continue - - stopped = self._current_movement.run_frame(api, self._current_movement_timeleft.passed()) - if stopped: - self._clear_current_movement() + else: + stopped = self._current_movement.run_frame(api, self._current_movement_timeleft.passed()) + if stopped: + self._clear_current_movement(api) def _init_sphero_edu_api(self, api): events = [ @@ -97,10 +155,10 @@ def _init_sphero_edu_api(self, api): def _get_listener(self, event_type: SpheroEventType, api: SpheroEduAPI) -> Callable[[], None]: def callback(): if self._current_movement is not None: - self._clear_current_movement(event_type.name) + self._clear_current_movement(api, event_type.name) move = self._current_movement.on_event(event_type.name) if move is not None: - self._set_current_movement(move) + self._set_current_movement(move, api) else: self._default_on_event(event_type, api) @@ -115,14 +173,30 @@ def _default_on_event(self, event_type: SpheroEventType, api: SpheroEduAPI): self._send_event(GhostOSEventTypes.NOTIFY, self._off_charging_callback) return - def _set_current_movement(self, movement: BoltBallMovement): + def _set_current_movement(self, movement: BoltBallMovement, api: SpheroEduAPI): if movement is None: return + self._current_movement = movement self._current_movement_timeleft = Timeleft(0) + self._moving = True + # always clear matrix at first. + if movement.animation is not None: + api.clear_matrix() + pa = PlayAnimation(animation=movement.animation) + self.add_matrix_command(pa) - def _clear_current_movement(self, interrupt: Optional[str] = None): + api.set_front_led(Color(0, 200, 0)) + self._logger.debug("start new movement %r", movement) + movement.start(api) + + def _clear_current_movement(self, api: SpheroEduAPI, interrupt: Optional[str] = None): if self._current_movement is not None and self._current_movement_timeleft is not None: + api.stop_roll() + api.set_front_led(Color(0, 0, 0)) + self._moving = False + if self._current_movement.animation is not None: + api.clear_matrix() if not interrupt: log = self._current_movement.succeed_log(self._current_movement_timeleft.passed()) if log: @@ -135,12 +209,10 @@ def _clear_current_movement(self, interrupt: Optional[str] = None): self._current_movement = None self._current_movement_timeleft = None - def _start_new_movement(self, api: SpheroEduAPI) -> Optional[BoltBallMovement]: + def _get_new_movement(self) -> Optional[BoltBallMovement]: if len(self._move_queue) == 0: return None movement: BoltBallMovement = self._move_queue.popleft() - self._logger.debug("start new movement %r", movement) - movement.start(api) return movement def _send_event(self, event_type: GhostOSEventTypes, content: str): @@ -158,8 +230,8 @@ def add_movement(self, move: BoltBallMovement): else: self._move_queue.append(move) - def add_animation(self, command: BoltLedMatrixAnimation): - pass + def add_matrix_command(self, command: BoltLedMatrixCommand): + self._animation_queue.append(command) def close(self): if self._closed: diff --git a/ghostos/prototypes/spherogpt/bolt/shell.py b/ghostos/prototypes/spherogpt/bolt/shell.py index 585938a9..5b341a55 100644 --- a/ghostos/prototypes/spherogpt/bolt/shell.py +++ b/ghostos/prototypes/spherogpt/bolt/shell.py @@ -1,17 +1,20 @@ -from typing import Self, Optional, Callable, List, Literal, NamedTuple, Union +from typing import Self, Optional, Callable, List, Literal, Tuple from abc import ABC, abstractmethod from pydantic import BaseModel, Field -class CurveRoll(BaseModel): +class RollFunc(BaseModel): """ to define a curve rolling frame by frame. """ - desc: str = Field(description="describe this curve in very few words") heading: int = Field(0, description="Heading angle of the sphero bolt in degrees from -180 ~ 180", ge=-180, le=180) speed: int = Field(90, description="speed of the sphero bolt rolling", ge=0, le=255) duration: float = Field(1, description="duration of the rolling, if 0, means forever") - code: str = Field(description="the python code to change self heading, speed, stop at each frame of rolling.") + code: str = Field( + default="", + description="the python code to change self heading, speed, stop at each frame of rolling." + "if empty, means run straight", + ) def run_frame(self, passed: float) -> bool: """ @@ -26,9 +29,20 @@ def run_frame(self, passed: float) -> bool: # for example, a sin cure: # self.speed = 90 # self.heading = int(math.sin(passed % 3) * 180)) % 360 - for line in self.code.splitlines(): - eval(line) - return self.duration == 0 or passed > self.duration + if self.code: + code = "\n".join([line.lstrip() for line in self.code.splitlines()]) + exec(code) + return not (self.duration == 0 or passed < self.duration) + + +class Straight(RollFunc): + heading: int = Field(0) + speed: int = Field(90) + duration: float = Field(1) + code: str = "" + + def run_frame(self, passed: float) -> bool: + return not (self.duration == 0 or passed < self.duration) class Move(ABC): @@ -59,7 +73,7 @@ def set_waddle(self, waddle: bool) -> Self: pass @abstractmethod - def roll_curve(self, curve: CurveRoll) -> Self: + def roll_by_func(self, fn: RollFunc) -> Self: """ run a curve rolling frame by frame until it reach the duration. """ @@ -120,16 +134,73 @@ def on_landing( pass +class Animation(BaseModel): + """ + to define an animation by sequence of frame. + the animation will be played on Sphero Bolt 8*8 led matrix. + """ + fps: int = Field(1, description="frames per second", ge=1, le=30), + transition: bool = Field(True, description="if true, fade between frames"), + palette: List[str] = Field( + default_factory=lambda: ["000000", "ff0000", "00ff00", "0000ff", "ffffff"], + description="define color palette, the index is the color id. " + "in default case: 0 is black, 1 is red, 2 is green, 3 is blue, 4 is white", + ), + loop: bool = Field( + default=True, + description="loop count for animation", + ), + duration: float = Field(default=0.0, description="duration of animation in seconds, clear matrix after animation"), + frames: List[List[List[int]]] = Field( + default_factory=lambda: [ + [ + # a simple smile + [0, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 1, 0, 0, 1, 1, 0], + [1, 0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 1, 0], + [0, 0, 1, 1, 1, 1, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0], + ] + ], + description="list of animation frame, every frame is a 8*8 matrix, each element is an palette color index", + ) + + def add_frame(self, frame: List[List[int]]) -> None: + self.frames.append(frame) + + def add_frame_by_node( + self, + nodes: List[Tuple[int, int, int]], + background_color: int = 0, + ): + """ + add a frame by declare several nodes only. + :param nodes: list of nodes. [(row, col, color), ...] + :param background_color: color index from palette + """ + row = [background_color] * 8 + frame = [row] * 8 # create an empty + for node in nodes: + row_idx, col_idx, color_idx = node + target_row = frame[row_idx] + target_row[col_idx] = color_idx + self.add_frame(frame) + + class Ball(ABC): """ Sphero bolt body (which is a rolling ball) control interface. """ @abstractmethod - def new_move(self, run_immediately: bool = False) -> Move: + def new_move(self, run_immediately: bool = False, animation: Optional[Animation] = None) -> Move: """ create a new Move instance, to define a sequence of movements. :param run_immediately: run immediately if True, otherwise the move will not execute until run it. + :param animation: if animation is not none, it will execute while run it. """ pass @@ -146,7 +217,7 @@ def run(self, move: Move, stop_at_first: bool = True) -> None: def save_move(self, name: str, description: str, move: Move) -> None: """ define a move that you can call it anytime with the name only. - **remember** only save the important move + **remember** only save the important one :param name: move name :param description: describe the move, in less than 100 words :param move: the Move instance. @@ -154,10 +225,9 @@ def save_move(self, name: str, description: str, move: Move) -> None: pass @abstractmethod - def set_matrix_rotation(self, rotation: Literal[0, 90, 180, 360] = 0) -> None: + def delete_move(self, name: str) -> None: """ - Rotates the LED matrix - :param rotation: 0 to 90, 180, 360 degrees + delete move by name """ pass @@ -170,6 +240,14 @@ def run_move(self, name: str) -> None: """ pass + @abstractmethod + def set_matrix_rotation(self, rotation: Literal[0, 90, 180, 360] = 0) -> None: + """ + Rotates the LED matrix + :param rotation: 0 to 90, 180, 360 degrees + """ + pass + @abstractmethod def on_charging( self, @@ -191,105 +269,91 @@ def on_not_charging( pass -class Color(NamedTuple): - """ - tuple of RGB colors - """ - r: int - g: int - b: int - - -class Animation(ABC): - """ - to define an animation by sequence of frame. - the animation will be played on Sphero Bolt 8*8 led matrix. - """ +class LedMatrix(ABC): @abstractmethod - def frame(self, matrix: List[List[Union[str, Color]]]) -> Self: + def play_animation( + self, + animation: Animation, + ) -> None: """ - define a frame of the Bolt LED matrix. - :param matrix: 8 * 8 array, each element is either an RGB Color tuple or a defined palette color name. - :return: + create a new animation instance, to define a sequence of frames. + :param animation: the animation instance """ pass @abstractmethod - def scroll_matrix_text(self, text: str, color_name: str, fps: int, wait: bool) -> Self: + def pause_animation(self) -> None: """ - Scrolls text on the matrix, with specified color. - text max 25 characters - Fps 1 to 30 - wait: if the programs wait until completion + pause the playing animation """ pass @abstractmethod - def set_matrix_character(self, character: str, color_name: str): + def resume_animation(self): """ - Sets a character on the matrix with color specified + resume the playing animation """ pass - -class LedMatrix(ABC): - @abstractmethod - def new_animation(self, fps: int = 1, transition: bool = True) -> Animation: + def clear_matrix(self): """ - create a new animation instance, to define a sequence of frames. - :param fps: - :param transition: - :return: + clear the matrix. """ pass @abstractmethod - def play_animation(self, animation: Animation, loop: int = 0) -> None: - pass - - @abstractmethod - def save_expression(self, name: str, description: str, animation: Animation) -> None: - """ - save animation as an expression, that you can play it when every you feel it. + def scroll_matrix_text(self, text: str, color: str = 'ffffff', fps: int = 1, wait: bool = True) -> Self: """ - pass + Scrolls text on the matrix, with specified color. + *this is a better way to print character on matrix*, with it, you do not need to write matrix frame yourself. - @abstractmethod - def play_expression(self, name: str, loop: int = 0) -> None: - """ - :param name: name of the defined expression animation. - :param loop: how many times the animation is played. zero means play forever. + :param text: max 25 characters, only allow char byte in 0~256 + :param color: color of the char + :param fps: 1 to 30 + :param wait: if the programs wait until completion """ pass @abstractmethod - def save_palette(self, color_name: str, color: Color) -> None: + def set_matrix_character(self, character: str, color: str): """ - save the color to the palette - :param color_name: such as 'red', 'green', 'blue' - :param color: RGB Color tuple + Sets a character on the matrix with color specified + :param character: output character + :param color: 6 digit hex RGB color, e.g. "ffffff", '00ff00' """ pass - @abstractmethod - def pause_animation(self) -> None: - """ - pause the playing animation - """ - pass - @abstractmethod - def resume_animation(self): +class SpheroBoltGPT(ABC): + """ + the sphero bolt robot api + """ + body: Ball + """your ball body""" + + face: LedMatrix + """your ball face""" + + def save_expression( + self, + name: str, + desc: str, + builder: Callable[[Ball, LedMatrix], None] + ) -> None: """ - resume the playing animation + create a named expression that express your feelings and can call it by name later. + :param name: name of the expression + :param desc: desc of the expression + :param builder: define the movement and the animation combined that express your feeling. + :return: """ pass - @abstractmethod - def clear_matrix(self): + def run(self, expression_name: str) -> None: """ - clear the matrix. + run a defined expression + :param expression_name: saved expression name """ pass diff --git a/ghostos/prototypes/spherogpt/bolt/sphero_edu_api_patch.py b/ghostos/prototypes/spherogpt/bolt/sphero_edu_api_patch.py new file mode 100644 index 00000000..deffe137 --- /dev/null +++ b/ghostos/prototypes/spherogpt/bolt/sphero_edu_api_patch.py @@ -0,0 +1,29 @@ +from spherov2 import scanner +from spherov2.sphero_edu import SpheroEduAPI as API, EventType as SpheroEventType, Color +import struct +from spherov2.commands.sensor import Sensor, CollisionDetected + +__all__ = ["SpheroEduAPI", "SpheroEventType", "Color"] + + +class SpheroEduAPI(API): + + def get_animation_id(self) -> int: + _id = self.__animation_index - 1 + if _id < 0: + return 0 + return _id + + +def __collision_detected_notify_helper(listener, packet): + """ + 解决 Spherov2 解码 bolt 的 bug? + """ + unpacked = struct.unpack('>3hB3hBH', packet.data) + listener(CollisionDetected(acceleration_x=unpacked[0] / 4096, acceleration_y=unpacked[1] / 4096, + acceleration_z=unpacked[2] / 4096, x_axis=bool(unpacked[3] & 1), + y_axis=bool(unpacked[3] & 2), power_x=unpacked[4], power_y=unpacked[5], + power_z=unpacked[6], speed=unpacked[7], time=unpacked[8] / 1000)) + + +Sensor.collision_detected_notify = (24, 18, 0xff), __collision_detected_notify_helper diff --git a/ghostos/prototypes/spherogpt/bolt/test_main.py b/ghostos/prototypes/spherogpt/bolt/test_main.py new file mode 100644 index 00000000..71addaa7 --- /dev/null +++ b/ghostos/prototypes/spherogpt/bolt/test_main.py @@ -0,0 +1,41 @@ +from ghostos.prototypes.spherogpt.bolt.runtime import SpheroBoltRuntime +from ghostos.prototypes.spherogpt.bolt.runtime_impl import SpheroBoltRuntimeImpl +from ghostos.prototypes.spherogpt.bolt.shell import Ball, RollFunc +from ghostos.prototypes.spherogpt.bolt.ball_impl import SpheroBoltBallAPIProvider +from ghostos.framework.eventbuses import MemEventBusImpl, EventBus +from ghostos.framework.workspaces import BasicWorkspace +from ghostos.framework.storage import MemStorage +from ghostos.contracts.logger import get_console_logger +from ghostos.contracts.workspace import Workspace +from ghostos.container import Container + +if __name__ == "__main__": + eventbus = MemEventBusImpl() + logger = get_console_logger() + _runtime = SpheroBoltRuntimeImpl(task_id="task_id", eventbus=eventbus, logger=logger) + container = Container() + storage = MemStorage() + _workspace = BasicWorkspace(storage) + container.set(EventBus, eventbus) + container.set(SpheroBoltRuntime, _runtime) + container.set(Workspace, _workspace) + container.register(SpheroBoltBallAPIProvider()) + container.bootstrap() + _runtime.bootstrap() + + ball = container.get(Ball) + + # test command + + move = ball.new_move() + curve = RollFunc( + heading=0, + speed=90, + duration=6, + code=""" + self.speed = 90 + self.heading = int((passed % 6) * 60) # Change heading to create a circular path + """ + ) + move.roll_by_func(curve) + ball.run(move) diff --git a/ghostos/prototypes/streamlitapp/widgets/messages.py b/ghostos/prototypes/streamlitapp/widgets/messages.py index 8c7a2414..8bda3e78 100644 --- a/ghostos/prototypes/streamlitapp/widgets/messages.py +++ b/ghostos/prototypes/streamlitapp/widgets/messages.py @@ -155,18 +155,13 @@ def render_message_caller(callers: Iterable[Caller], debug: bool, in_expander: b def _render_message_caller(callers: Iterable[Caller]): from ghostos.ghosts.moss_agent import MossAction for caller in callers: - if caller.name == MossAction.Argument.name: - try: - data = json.loads(caller.arguments) - arguments = MossAction.Argument(**data) - except json.JSONDecodeError: - arguments = MossAction.Argument(code=caller.arguments) - + if caller.name == MossAction.DEFAULT_NAME: st.caption(f"function call: {caller.name}") - st.code(arguments.code) + code = MossAction.unmarshal_arguments(caller.arguments) + st.code(code) else: st.caption(f"function call: {caller.name}") - st.json(caller.arguments) + st.write(caller.arguments) def render_message_item(msg: Message, debug: bool): diff --git a/tests/python/test_class.py b/tests/python/test_class.py index 422f605f..a5bbe6e2 100644 --- a/tests/python/test_class.py +++ b/tests/python/test_class.py @@ -276,3 +276,22 @@ def __init__(self, val: List[str]): assert b.bar == ["a", "b"] # be updated assert Bar.bar == ["a", "b"] + + +def test_class_eval(): + class Foo: + def __init__(self, code: str): + self.code = code + self.foo = 1 + + def run(self): + code = "\n".join([line.lstrip() for line in self.code.splitlines()]) + exec(code) + + f = Foo( + "print(self)\n" + "print(self.foo)\n" + "self.foo = 2\n" + ) + f.run() + assert f.foo == 2 From 46d9700eb1603fa0c244591dacf409f0431909d7 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 9 Dec 2024 14:33:28 +0800 Subject: [PATCH 127/148] dev: save development of sphero gpt tests --- ...mmand_version.py => raw_api_test_agent.py} | 0 ...hell_agent.py => sphero_bolt_gpt_agent.py} | 3 ++ ghostos/core/moss/abcd.py | 9 ++++ ghostos/core/moss/impl.py | 7 ++++ ghostos/prototypes/spherogpt/bolt/__init__.py | 2 +- .../prototypes/spherogpt/bolt/ball_impl.py | 41 +++++++++++++++---- .../bolt/{shell.py => bolt_shell.py} | 29 +++++++------ .../spherogpt/bolt/led_matrix_impl.py | 6 +-- .../prototypes/spherogpt/bolt/movements.py | 3 +- ghostos/prototypes/spherogpt/bolt/runtime.py | 8 +++- .../prototypes/spherogpt/bolt/runtime_impl.py | 11 ++++- .../prototypes/spherogpt/bolt/test_main.py | 2 +- 12 files changed, 90 insertions(+), 31 deletions(-) rename examples/sphero/{sphero_bolt_gpt_command_version.py => raw_api_test_agent.py} (100%) rename examples/sphero/{bolt_gpt_shell_agent.py => sphero_bolt_gpt_agent.py} (83%) rename ghostos/prototypes/spherogpt/bolt/{shell.py => bolt_shell.py} (96%) diff --git a/examples/sphero/sphero_bolt_gpt_command_version.py b/examples/sphero/raw_api_test_agent.py similarity index 100% rename from examples/sphero/sphero_bolt_gpt_command_version.py rename to examples/sphero/raw_api_test_agent.py diff --git a/examples/sphero/bolt_gpt_shell_agent.py b/examples/sphero/sphero_bolt_gpt_agent.py similarity index 83% rename from examples/sphero/bolt_gpt_shell_agent.py rename to examples/sphero/sphero_bolt_gpt_agent.py index e141c0fd..9eabb5e0 100644 --- a/examples/sphero/bolt_gpt_shell_agent.py +++ b/examples/sphero/sphero_bolt_gpt_agent.py @@ -50,6 +50,9 @@ def __moss_agent_providers__(agent): instructions=""" 1. chat with user kindly. 2. follow the order and turn your actions to code with your ball body. +3. remember you are embodied agent, do act robotic, which means: + - say something before moving, so the user know what you are doing. unless you are told to be quiet. + - use saved movement and animation to show your feeling when you are communicating with user. """, moss_module=__name__ ) diff --git a/ghostos/core/moss/abcd.py b/ghostos/core/moss/abcd.py index bf4de24a..0462479f 100644 --- a/ghostos/core/moss/abcd.py +++ b/ghostos/core/moss/abcd.py @@ -79,6 +79,9 @@ class Moss(ABC): T = TypeVar('T') + executing_code: Optional[str] + """the code that execute the moss instance.""" + @abstractmethod def fetch(self, abstract: Type[T]) -> Optional[T]: """ @@ -465,6 +468,7 @@ def execute( :exception: any exception will be raised, handle them outside """ from ghostos.core.moss.lifecycle import __moss_exec__ + self.update_executing_code(code) if self.__executing__: raise RuntimeError(f"Moss already executing") try: @@ -490,6 +494,11 @@ def execute( finally: self.__executing__ = False + @abstractmethod + def update_executing_code(self, code: Optional[str] = None) -> None: + pass + + @abstractmethod def close(self) -> None: """ 方便垃圾回收. diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index b4cecf7d..6a1d4b25 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -184,6 +184,7 @@ def new_moss_stub(cls: Type[Moss], container: Container, pycontext: PyContext, p # cls 必须不包含参数. stub = MossStub(pycontext, container, pprint) + stub.executing_code = None # assert stub.instance_count > 0 for attr_name in dir(cls): if not attr_name.startswith("_") and not hasattr(stub, attr_name): @@ -370,6 +371,12 @@ def _parse_pycontext_code(code: str, exclude_hide_code: bool = True) -> str: return "\n".join(results) + def update_executing_code(self, code: Optional[str] = None) -> None: + if code is None: + return + self._pycontext.execute_code = code + self._moss.executing_code = code + def close(self) -> None: if self._closed: return diff --git a/ghostos/prototypes/spherogpt/bolt/__init__.py b/ghostos/prototypes/spherogpt/bolt/__init__.py index c247fc40..ee7c7da3 100644 --- a/ghostos/prototypes/spherogpt/bolt/__init__.py +++ b/ghostos/prototypes/spherogpt/bolt/__init__.py @@ -2,7 +2,7 @@ from ghostos.prototypes.spherogpt.bolt.runtime_impl import ConvoLevelSpheroBoltRuntimeProvider from ghostos.prototypes.spherogpt.bolt.led_matrix_impl import SpheroBoltLedMatrixProvider -from ghostos.prototypes.spherogpt.bolt.shell import ( +from ghostos.prototypes.spherogpt.bolt.bolt_shell import ( RollFunc, Ball, Move, diff --git a/ghostos/prototypes/spherogpt/bolt/ball_impl.py b/ghostos/prototypes/spherogpt/bolt/ball_impl.py index de20ed05..3afa37c8 100644 --- a/ghostos/prototypes/spherogpt/bolt/ball_impl.py +++ b/ghostos/prototypes/spherogpt/bolt/ball_impl.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, Callable, Self, Dict +from typing import Literal, Optional, Callable, Self, Dict, Tuple from spherov2.commands.io import FrameRotationOptions @@ -9,9 +9,8 @@ from ghostos.prompter import Prompter from ghostos.container import Container, Provider from pydantic import BaseModel, Field -from ghostos.prototypes.spherogpt.bolt.shell import Ball, Move, RollFunc, Animation +from ghostos.prototypes.spherogpt.bolt.bolt_shell import Ball, Move, RollFunc, Animation from ghostos.prototypes.spherogpt.bolt.runtime import SpheroBoltRuntime, BoltBallMovement -from ghostos.prototypes.spherogpt.bolt.led_matrix_impl import PauseAnimation, PlayAnimation from ghostos.prototypes.spherogpt.bolt.movements import ( GroupMovement, RunAPIMovement, @@ -26,6 +25,7 @@ class SavedMove(BaseModel): name: str = Field(description="move name") description: str = Field(description="move description") move_meta: ModelEntityMeta = Field(description="move meta") + generated_code: str = Field(default="", description="the code creating this move") @classmethod def new(cls, name: str, description: str, move: BoltBallMovement) -> Self: @@ -67,17 +67,20 @@ def __init__( run_immediately: bool, animation: Optional[Animation] = None, event_desc: Optional[str] = None, + buffer: Optional[GroupMovement] = None, ): self._runtime = runtime self._run_immediately = run_immediately - self._animation = animation self._move_added: int = 0 - self.buffer: GroupMovement = GroupMovement(desc="", event_desc=event_desc) + if buffer is None: + buffer = GroupMovement(desc="", event_desc=event_desc, animation=animation) + if animation is not None: + buffer.animation = animation + self.buffer: GroupMovement = buffer def _add_move(self, movement: BoltBallMovement): if self._run_immediately: movement.stop_at_first = self._move_added == 0 - movement.animation = self._animation self._runtime.add_movement(movement) self.buffer.add_child(movement) @@ -177,8 +180,10 @@ def __init__( self, runtime: SpheroBoltRuntime, memory_cache: FileStorage, + executing_code: Optional[str] = None, ): self._runtime = runtime + self._executing_code = executing_code self._memory_cache_storage = memory_cache self._memory_cache_file = MovesMemoryCache.filename(self._runtime.get_task_id()) if self._memory_cache_storage.exists(self._memory_cache_file): @@ -192,13 +197,19 @@ def _save_cache(self): content = self._memory_cache.to_content() self._memory_cache_storage.put(self._memory_cache_file, content.encode()) - def new_move(self, run_immediately: bool = False, animation: Optional[Animation] = None) -> Move: + def new_move( + self, + animation: Optional[Animation] = None, + run_immediately: bool = False, + ) -> Move: return MoveAdapter(self._runtime, run_immediately, animation=animation) def run(self, move: Move, stop_at_first: bool = True) -> None: if not isinstance(move, MoveAdapter): raise TypeError(f"move instance must be created by this api new_move()") movement = move.buffer + if movement.animation is not None: + self._runtime.add_animation(movement.animation) movement.stop_at_first = stop_at_first self._runtime.add_movement(movement) @@ -206,6 +217,7 @@ def save_move(self, name: str, description: str, move: Move) -> None: if not isinstance(move, MoveAdapter): raise TypeError(f"move instance must be created by this api new_move()") saved_move = SavedMove.new(name=name, description=description, move=move.buffer) + saved_move.generated_code = self._executing_code or "" self._memory_cache.add_saved(saved_move) self._save_cache() @@ -235,6 +247,18 @@ def run_move(self, name: str) -> None: got.stop_at_first = True self._runtime.add_movement(got) + def read_move(self, name: str) -> Tuple[Move, str]: + saved = self._memory_cache.moves.get(name, None) + if saved is None: + raise NotImplementedError(f"move {name} not implemented") + got = self._memory_cache.get_move(name) + move = MoveAdapter( + self._runtime, + run_immediately=False, + buffer=got, + ) + return move, saved.generated_code + def on_charging(self, log: str = "feeling at charging") -> None: self._runtime.set_charging_callback(log) @@ -252,7 +276,8 @@ def self_prompt(self, container: Container) -> str: return f""" your saved moves, from name to description are below: {saved_content} -you can run the saved move by it's name + +you can run the saved move by it's name. """ def get_title(self) -> str: diff --git a/ghostos/prototypes/spherogpt/bolt/shell.py b/ghostos/prototypes/spherogpt/bolt/bolt_shell.py similarity index 96% rename from ghostos/prototypes/spherogpt/bolt/shell.py rename to ghostos/prototypes/spherogpt/bolt/bolt_shell.py index 5b341a55..2ddf2e3e 100644 --- a/ghostos/prototypes/spherogpt/bolt/shell.py +++ b/ghostos/prototypes/spherogpt/bolt/bolt_shell.py @@ -196,11 +196,15 @@ class Ball(ABC): """ @abstractmethod - def new_move(self, run_immediately: bool = False, animation: Optional[Animation] = None) -> Move: + def new_move( + self, + animation: Optional[Animation] = None, + run_immediately: bool = False + ) -> Move: """ create a new Move instance, to define a sequence of movements. :param run_immediately: run immediately if True, otherwise the move will not execute until run it. - :param animation: if animation is not none, it will execute while run it. + :param animation: if animation is not none, it will be played while run the move. """ pass @@ -224,6 +228,16 @@ def save_move(self, name: str, description: str, move: Move) -> None: """ pass + @abstractmethod + def read_move(self, name: str) -> Tuple[Move, str]: + """ + read a saved move with the code that generated it. + print the code to see details. + :param name: move name + :return: (move instance, the code that generated it.) + """ + pass + @abstractmethod def delete_move(self, name: str) -> None: """ @@ -271,17 +285,6 @@ def on_not_charging( class LedMatrix(ABC): - @abstractmethod - def play_animation( - self, - animation: Animation, - ) -> None: - """ - create a new animation instance, to define a sequence of frames. - :param animation: the animation instance - """ - pass - @abstractmethod def pause_animation(self) -> None: """ diff --git a/ghostos/prototypes/spherogpt/bolt/led_matrix_impl.py b/ghostos/prototypes/spherogpt/bolt/led_matrix_impl.py index 8ca5323b..d4f632fc 100644 --- a/ghostos/prototypes/spherogpt/bolt/led_matrix_impl.py +++ b/ghostos/prototypes/spherogpt/bolt/led_matrix_impl.py @@ -2,7 +2,7 @@ from .sphero_edu_api_patch import SpheroEduAPI -from ghostos.prototypes.spherogpt.bolt.shell import LedMatrix, Animation +from ghostos.prototypes.spherogpt.bolt.bolt_shell import LedMatrix, Animation from ghostos.prototypes.spherogpt.bolt.runtime import BoltLedMatrixCommand, SpheroBoltRuntime from ghostos.prototypes.spherogpt.bolt.sphero_edu_api_patch import Color from ghostos.container import Container, Provider @@ -67,7 +67,7 @@ class PlayAnimation(BoltLedMatrixCommand): def start(self, api: SpheroEduAPI) -> None: frames = self.animation.frames - fps = self.animation.fps + fps = int(self.animation.fps) palette = [] for color in self.animation.palette: rgb = parse_str_to_color(color) @@ -77,7 +77,7 @@ def start(self, api: SpheroEduAPI) -> None: frames, fps=fps, palette=palette, - transition=self.animation.transition, + transition=bool(self.animation.transition), ) aid = api.get_animation_id() api.play_matrix_animation(aid, self.animation.loop) diff --git a/ghostos/prototypes/spherogpt/bolt/movements.py b/ghostos/prototypes/spherogpt/bolt/movements.py index dd79daeb..ec689e07 100644 --- a/ghostos/prototypes/spherogpt/bolt/movements.py +++ b/ghostos/prototypes/spherogpt/bolt/movements.py @@ -4,7 +4,7 @@ from .runtime import BoltBallMovement from .sphero_edu_api_patch import SpheroEduAPI -from .shell import RollFunc, Animation +from .bolt_shell import RollFunc, Animation class RunAPIMovement(BoltBallMovement): @@ -46,6 +46,7 @@ def on_event(self, event_type: str) -> Optional[Self]: class GroupMovement(BoltBallMovement): + animation: Optional[Animation] = Field(None) children: List[ModelEntityMeta] = Field(default_factory=list) event_desc: Optional[str] = Field(default=None) event_moves: Dict[str, ModelEntityMeta] = Field(default_factory=dict) diff --git a/ghostos/prototypes/spherogpt/bolt/runtime.py b/ghostos/prototypes/spherogpt/bolt/runtime.py index e5d261fc..4c270243 100644 --- a/ghostos/prototypes/spherogpt/bolt/runtime.py +++ b/ghostos/prototypes/spherogpt/bolt/runtime.py @@ -4,15 +4,15 @@ from spherov2.sphero_edu import SpheroEduAPI from pydantic import BaseModel, Field -from ghostos.prototypes.spherogpt.bolt.shell import Animation +from ghostos.prototypes.spherogpt.bolt.bolt_shell import Animation _STOPPED = bool class BoltBallMovement(BaseModel, ABC): + animation: Optional[Animation] = Field(None) desc: str = Field("", description="description of the command") stop_at_first: bool = Field(default=False, description="stop the world at first") - animation: Optional[Animation] = Field(default=None) @abstractmethod def start(self, api: SpheroEduAPI) -> None: @@ -55,6 +55,10 @@ def get_task_id(self) -> str: def add_movement(self, command: BoltBallMovement): pass + @abstractmethod + def add_animation(self, animation: Animation) -> None: + pass + @abstractmethod def set_charging_callback(self, event: str): pass diff --git a/ghostos/prototypes/spherogpt/bolt/runtime_impl.py b/ghostos/prototypes/spherogpt/bolt/runtime_impl.py index 7e37516a..0aed21ce 100644 --- a/ghostos/prototypes/spherogpt/bolt/runtime_impl.py +++ b/ghostos/prototypes/spherogpt/bolt/runtime_impl.py @@ -9,6 +9,8 @@ from ghostos.helpers import Timeleft from threading import Thread, Event from collections import deque + +from .bolt_shell import Animation from .sphero_edu_api_patch import SpheroEduAPI, Color, SpheroEventType, scanner from .runtime import SpheroBoltRuntime, BoltLedMatrixCommand, BoltBallMovement from .led_matrix_impl import PlayAnimation @@ -121,7 +123,7 @@ def _run_bolt_loop(self, _bolt): while not self._stopped.is_set(): if len(self._animation_queue) > 0: self._current_animation = None - self._current_movement_timeleft = None + self._current_animation_timeleft = None animation_command: Optional[BoltLedMatrixCommand] = self._animation_queue.popleft() # animation command execute immediately self._set_current_animation(animation_command, api) @@ -138,7 +140,8 @@ def _run_bolt_loop(self, _bolt): time.sleep(0.5) continue else: - stopped = self._current_movement.run_frame(api, self._current_movement_timeleft.passed()) + passed = self._current_movement_timeleft.passed() + stopped = self._current_movement.run_frame(api, passed) if stopped: self._clear_current_movement(api) @@ -230,6 +233,10 @@ def add_movement(self, move: BoltBallMovement): else: self._move_queue.append(move) + def add_animation(self, animation: Animation) -> None: + pa = PlayAnimation(animation=animation) + self.add_matrix_command(pa) + def add_matrix_command(self, command: BoltLedMatrixCommand): self._animation_queue.append(command) diff --git a/ghostos/prototypes/spherogpt/bolt/test_main.py b/ghostos/prototypes/spherogpt/bolt/test_main.py index 71addaa7..8237cb03 100644 --- a/ghostos/prototypes/spherogpt/bolt/test_main.py +++ b/ghostos/prototypes/spherogpt/bolt/test_main.py @@ -1,6 +1,6 @@ from ghostos.prototypes.spherogpt.bolt.runtime import SpheroBoltRuntime from ghostos.prototypes.spherogpt.bolt.runtime_impl import SpheroBoltRuntimeImpl -from ghostos.prototypes.spherogpt.bolt.shell import Ball, RollFunc +from ghostos.prototypes.spherogpt.bolt.bolt_shell import Ball, RollFunc from ghostos.prototypes.spherogpt.bolt.ball_impl import SpheroBoltBallAPIProvider from ghostos.framework.eventbuses import MemEventBusImpl, EventBus from ghostos.framework.workspaces import BasicWorkspace From 3c91d4580a704b259419f91a981eb39b8c50423c Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 9 Dec 2024 15:08:04 +0800 Subject: [PATCH 128/148] fix: fix some spherogpt test bugs --- examples/sphero/sphero_bolt_gpt_agent.py | 2 +- ghostos/core/moss/impl.py | 11 +- ghostos/framework/ghostos/taskflow_impl.py | 4 +- .../prototypes/spherogpt/bolt/ball_impl.py | 60 ++++++-- .../prototypes/spherogpt/bolt/bolt_shell.py | 28 +--- .../prototypes/spherogpt/bolt/movements.py | 3 + ghostos/prototypes/spherogpt/bolt/runtime.py | 5 +- .../prototypes/spherogpt/bolt/runtime_impl.py | 128 +++++++++++------- .../spherogpt/bolt/sphero_edu_api_patch.py | 25 ++-- 9 files changed, 159 insertions(+), 107 deletions(-) diff --git a/examples/sphero/sphero_bolt_gpt_agent.py b/examples/sphero/sphero_bolt_gpt_agent.py index 9eabb5e0..c6013019 100644 --- a/examples/sphero/sphero_bolt_gpt_agent.py +++ b/examples/sphero/sphero_bolt_gpt_agent.py @@ -52,7 +52,7 @@ def __moss_agent_providers__(agent): 2. follow the order and turn your actions to code with your ball body. 3. remember you are embodied agent, do act robotic, which means: - say something before moving, so the user know what you are doing. unless you are told to be quiet. - - use saved movement and animation to show your feeling when you are communicating with user. + - use saved movement and animation to express your feeling when you are talking with user. """, moss_module=__name__ ) diff --git a/ghostos/core/moss/impl.py b/ghostos/core/moss/impl.py index 6a1d4b25..e47e2df8 100644 --- a/ghostos/core/moss/impl.py +++ b/ghostos/core/moss/impl.py @@ -226,10 +226,10 @@ def __init__( self._closed: bool = False self._injected = set() self._moss: Moss = self._compile_moss() + self._initialize_moss() MossRuntime.instance_count += 1 def _compile_moss(self) -> Moss: - from .lifecycle import __moss_compiled__ moss_type = self.moss_type() if not issubclass(moss_type, Moss): raise TypeError(f"Moss type {moss_type} is not subclass of {generate_module_and_attr_name(Moss)}") @@ -237,6 +237,13 @@ def _compile_moss(self) -> Moss: # 创建 stub. pycontext = self._pycontext moss = new_moss_stub(moss_type, self._container, pycontext, self.pprint) + return moss + + def _initialize_moss(self) -> None: + from .lifecycle import __moss_compiled__ + moss = self._moss + pycontext = self._pycontext + moss_type = self.moss_type() def inject(attr_name: str, injected: Any) -> Any: if isinstance(injected, Injection): @@ -274,7 +281,7 @@ def inject(attr_name: str, injected: Any) -> Any: if __moss_compiled__.__name__ in self._compiled.__dict__: fn = self._compiled.__dict__[__moss_compiled__.__name__] fn(moss) - return moss + self._moss = moss def container(self) -> Container: return self._container diff --git a/ghostos/framework/ghostos/taskflow_impl.py b/ghostos/framework/ghostos/taskflow_impl.py index abe681d6..66523167 100644 --- a/ghostos/framework/ghostos/taskflow_impl.py +++ b/ghostos/framework/ghostos/taskflow_impl.py @@ -147,8 +147,8 @@ def run(self, session: Session) -> Union[Operator, None]: if self.sync: return fire_session_event(session, event) else: - msg = Role.SYSTEM.new(content=f"issue observation at turn {task.turns}") - session.thread.append(msg) + # msg = Role.SYSTEM.new(content=f"issue observation at turn {task.turns}") + # session.thread.append(msg) event.reason = f"receive observation at turn {task.turns}" session.fire_events(event) session.task.state = TaskState.WAITING.value diff --git a/ghostos/prototypes/spherogpt/bolt/ball_impl.py b/ghostos/prototypes/spherogpt/bolt/ball_impl.py index 3afa37c8..3493eab1 100644 --- a/ghostos/prototypes/spherogpt/bolt/ball_impl.py +++ b/ghostos/prototypes/spherogpt/bolt/ball_impl.py @@ -8,7 +8,9 @@ from ghostos.helpers import yaml_pretty_dump from ghostos.prompter import Prompter from ghostos.container import Container, Provider +from ghostos.core.moss import Injection, MossRuntime from pydantic import BaseModel, Field +from ghostos.prototypes.spherogpt.bolt.sphero_edu_api_patch import SpheroEventType from ghostos.prototypes.spherogpt.bolt.bolt_shell import Ball, Move, RollFunc, Animation from ghostos.prototypes.spherogpt.bolt.runtime import SpheroBoltRuntime, BoltBallMovement from ghostos.prototypes.spherogpt.bolt.movements import ( @@ -66,14 +68,14 @@ def __init__( runtime: SpheroBoltRuntime, run_immediately: bool, animation: Optional[Animation] = None, - event_desc: Optional[str] = None, + event_desc: str = "", buffer: Optional[GroupMovement] = None, ): self._runtime = runtime self._run_immediately = run_immediately self._move_added: int = 0 if buffer is None: - buffer = GroupMovement(desc="", event_desc=event_desc, animation=animation) + buffer = GroupMovement(desc="move", event_desc=event_desc or "", animation=animation) if animation is not None: buffer.animation = animation self.buffer: GroupMovement = buffer @@ -87,12 +89,17 @@ def _add_move(self, movement: BoltBallMovement): self._move_added += 1 def roll(self, heading: int, speed: int, duration: float) -> Self: - self._add_move(RunAPIMovement( - desc="roll", - method="roll", + roll_fn = RollFunc( + heading=heading, + speed=speed, duration=duration, - args=[heading, speed, duration], - )) + code="", + ) + move = CurveRollMovement( + desc="roll", + curve=roll_fn, + ) + self._add_move(move) return self def spin(self, angle: int, duration: float) -> Self: @@ -147,8 +154,13 @@ def set_compass_direction(self, direction: int = 0) -> Self: )) return self - def on_collision(self, log: str = "feeling collision", callback: Optional[Callable[[Self], None]] = None) -> None: - self._add_event_callback("on_collision", log, callback) + def on_collision( + self, + callback: Optional[Callable[[Self], None]] = None, + *, + log: str = "feeling collision", + ) -> None: + self._add_event_callback(SpheroEventType.on_collision.name, log, callback) def _add_event_callback( self, @@ -165,16 +177,26 @@ def _add_event_callback( callback(sub_move) event_move = sub_move.buffer event_move.stop_at_first = True - self.buffer.event_moves[event_name] = event_move + self.buffer.add_event_move(event_name, event_move) - def on_freefall(self, log: str = "feeling freefall", callback: Optional[Callable[[Self], None]] = None) -> None: - self._add_event_callback("on_freefall", log, callback) + def on_freefall( + self, + log: str = "feeling freefall", + *, + callback: Optional[Callable[[Self], None]] = None, + ) -> None: + self._add_event_callback(SpheroEventType.on_freefall.name, log, callback) - def on_landing(self, log: str = "feeling landing", callback: Optional[Callable[[Self], None]] = None) -> Self: - self._add_event_callback("on_landing", log, callback) + def on_landing( + self, + callback: Optional[Callable[[Self], None]] = None, + *, + log: str = "feeling landing", + ) -> None: + self._add_event_callback(SpheroEventType.on_landing.name, log, callback) -class BallImpl(Ball, Prompter): +class BallImpl(Ball, Injection, Prompter): def __init__( self, @@ -197,8 +219,16 @@ def _save_cache(self): content = self._memory_cache.to_content() self._memory_cache_storage.put(self._memory_cache_file, content.encode()) + def on_inject(self, runtime: MossRuntime, property_name: str) -> Self: + self._executing_code = runtime.moss().executing_code + return self + + def on_destroy(self) -> None: + return None + def new_move( self, + *, animation: Optional[Animation] = None, run_immediately: bool = False, ) -> Move: diff --git a/ghostos/prototypes/spherogpt/bolt/bolt_shell.py b/ghostos/prototypes/spherogpt/bolt/bolt_shell.py index 2ddf2e3e..55cf497d 100644 --- a/ghostos/prototypes/spherogpt/bolt/bolt_shell.py +++ b/ghostos/prototypes/spherogpt/bolt/bolt_shell.py @@ -7,7 +7,7 @@ class RollFunc(BaseModel): """ to define a curve rolling frame by frame. """ - heading: int = Field(0, description="Heading angle of the sphero bolt in degrees from -180 ~ 180", ge=-180, le=180) + heading: int = Field(0, description="Heading angle of the sphero bolt in degrees from -180 ~ 180", ge=-360, le=360) speed: int = Field(90, description="speed of the sphero bolt rolling", ge=0, le=255) duration: float = Field(1, description="duration of the rolling, if 0, means forever") code: str = Field( @@ -101,7 +101,6 @@ def set_compass_direction(self, direction: int = 0) -> Self: @abstractmethod def on_collision( self, - log: str = "feeling collision", callback: Optional[Callable[[Self], None]] = None, ) -> None: """ @@ -114,7 +113,6 @@ def on_collision( @abstractmethod def on_freefall( self, - log: str = "feeling freefall", callback: Optional[Callable[[Self], None]] = None, ) -> None: """ @@ -125,9 +123,8 @@ def on_freefall( @abstractmethod def on_landing( self, - log: str = "feeling landing", callback: Optional[Callable[[Self], None]] = None, - ) -> Self: + ) -> None: """ when the bolt feeling landing. default is stop. """ @@ -198,6 +195,7 @@ class Ball(ABC): @abstractmethod def new_move( self, + *, animation: Optional[Animation] = None, run_immediately: bool = False ) -> Move: @@ -262,26 +260,6 @@ def set_matrix_rotation(self, rotation: Literal[0, 90, 180, 360] = 0) -> None: """ pass - @abstractmethod - def on_charging( - self, - log: str = "feeling at charging", - ) -> None: - """ - when the bolt feeling start charging - """ - pass - - @abstractmethod - def on_not_charging( - self, - log: str = "feeling stop charging", - ) -> None: - """ - when the bolt feeling stop charging - """ - pass - class LedMatrix(ABC): diff --git a/ghostos/prototypes/spherogpt/bolt/movements.py b/ghostos/prototypes/spherogpt/bolt/movements.py index ec689e07..4fe766b0 100644 --- a/ghostos/prototypes/spherogpt/bolt/movements.py +++ b/ghostos/prototypes/spherogpt/bolt/movements.py @@ -92,3 +92,6 @@ def on_event(self, event_type: str) -> Optional[Self]: meta = self.event_moves[event_type] return from_entity_model_meta(meta) return None + + def add_event_move(self, event_type: str, move: BoltBallMovement): + self.event_moves[event_type] = to_entity_model_meta(move) diff --git a/ghostos/prototypes/spherogpt/bolt/runtime.py b/ghostos/prototypes/spherogpt/bolt/runtime.py index 4c270243..e3e673ab 100644 --- a/ghostos/prototypes/spherogpt/bolt/runtime.py +++ b/ghostos/prototypes/spherogpt/bolt/runtime.py @@ -1,8 +1,8 @@ from abc import ABC, abstractmethod from typing import Optional, Self from ghostos.entity import ModelEntity -from spherov2.sphero_edu import SpheroEduAPI from pydantic import BaseModel, Field +from .sphero_edu_api_patch import SpheroEduAPI from ghostos.prototypes.spherogpt.bolt.bolt_shell import Animation @@ -28,7 +28,8 @@ def succeed_log(self, passed: float) -> str: return f"done `{self.desc}` after {round(passed, 4)} seconds" def interrupt_log(self, reason: str, passed: float) -> str: - return f"interrupt `{self.desc}` running because `{reason}` after {round(passed, 4)} seconds" + desc = self.desc or str(type(self)) + return f"interrupt `{desc}` running because `{reason}` after {round(passed, 4)} seconds" @abstractmethod def on_event(self, event_type: str) -> Optional[Self]: diff --git a/ghostos/prototypes/spherogpt/bolt/runtime_impl.py b/ghostos/prototypes/spherogpt/bolt/runtime_impl.py index 0aed21ce..79907174 100644 --- a/ghostos/prototypes/spherogpt/bolt/runtime_impl.py +++ b/ghostos/prototypes/spherogpt/bolt/runtime_impl.py @@ -7,7 +7,7 @@ from ghostos.container import Container, BootstrapProvider, INSTANCE from ghostos.abcd import Conversation from ghostos.helpers import Timeleft -from threading import Thread, Event +from threading import Thread, Event, Lock from collections import deque from .bolt_shell import Animation @@ -33,8 +33,8 @@ def __init__( self._eventbus = eventbus self._logger = logger self._stopped = Event() + self._bootstrapped: Event = Event() self._closed = False - self._bootstrapped = False self._error: Optional[str] = None self._main_thread = Thread(target=self._main_thread) self._move_queue = deque() @@ -44,6 +44,7 @@ def __init__( self._clear_matrix_timeleft: Optional[Timeleft] = None self._current_movement: Optional[BoltBallMovement] = None self._current_movement_timeleft: Optional[Timeleft] = None + self._movement_mutex = Lock() self._charging_callback: str = "feeling at charging" self._breathing: bool = False self._moving: bool = False @@ -58,11 +59,13 @@ def _reset_all_state(self): self._move_queue.clear() def bootstrap(self): - if self._bootstrapped: + if self._bootstrapped.is_set(): self._logger.error(f"SpheroBolt Runtime already bootstrapped") return - self._bootstrapped = True self._main_thread.start() + self._bootstrapped.wait(10) + if not self._bootstrapped.is_set(): + raise RuntimeError(f'SpheroBolt Runtime bootstrap failed') def _main_thread(self): connected_error = 0 @@ -74,6 +77,8 @@ def _main_thread(self): connected_error = 0 # run the loop until errors. try: + if not self._bootstrapped.is_set(): + self._bootstrapped.set() self._run_bolt_loop(_bolt) except Exception as exc: self._logger.exception(exc) @@ -146,26 +151,44 @@ def _run_bolt_loop(self, _bolt): self._clear_current_movement(api) def _init_sphero_edu_api(self, api): - events = [ - SpheroEventType.on_landing, - SpheroEventType.on_freefall, - SpheroEventType.on_collision, - ] - for event in events: - listener = self._get_listener(event, api) - api.register_event(event, listener) - - def _get_listener(self, event_type: SpheroEventType, api: SpheroEduAPI) -> Callable[[], None]: - def callback(): + api.register_event(SpheroEventType.on_landing, self._on_landing) + api.register_event(SpheroEventType.on_freefall, self._on_freefall) + api.register_event(SpheroEventType.on_collision, self._on_collision) + api.register_event(SpheroEventType.on_charging, self._on_charging) + api.register_event(SpheroEventType.on_not_charging, self._on_off_charging) + + def _on_collision(self, api: SpheroEduAPI, *args, **kwargs): + print("+++++++++++ on colo", SpheroEventType.on_collision.name, self._current_movement) + self._on_event_handler(api, SpheroEventType.on_collision.name) + + def _on_event_handler(self, api: SpheroEduAPI, event_name: str): + api.stop_roll() + try: if self._current_movement is not None: - self._clear_current_movement(api, event_type.name) - move = self._current_movement.on_event(event_type.name) + move = self._current_movement.on_event(event_name) + self._clear_current_movement(api, event_name, notify=True) if move is not None: + self._send_event(GhostOSEventTypes.NOTIFY, move.event_desc) self._set_current_movement(move, api) - else: - self._default_on_event(event_type, api) + except Exception as e: + self._logger.exception(e) + api.stop_roll() - return callback + def _on_landing(self, api: SpheroEduAPI, *args, **kwargs): + self._on_event_handler(api, SpheroEventType.on_landing.name) + + def _on_freefall(self, api: SpheroEduAPI): + self._on_event_handler(api, SpheroEventType.on_freefall.name) + + def _on_charging(self, api: SpheroEduAPI): + api.stop_roll() + self._clear_current_movement(api, SpheroEventType.on_charging.name, notify=False) + self._send_event(GhostOSEventTypes.NOTIFY, self._charging_callback) + + def _on_off_charging(self, api: SpheroEduAPI): + api.stop_roll() + self._clear_current_movement(api, SpheroEventType.on_not_charging.name, notify=False) + self._send_event(GhostOSEventTypes.NOTIFY, self._off_charging_callback) def _default_on_event(self, event_type: SpheroEventType, api: SpheroEduAPI): api.stop_roll() @@ -180,37 +203,43 @@ def _set_current_movement(self, movement: BoltBallMovement, api: SpheroEduAPI): if movement is None: return - self._current_movement = movement - self._current_movement_timeleft = Timeleft(0) - self._moving = True - # always clear matrix at first. - if movement.animation is not None: - api.clear_matrix() - pa = PlayAnimation(animation=movement.animation) - self.add_matrix_command(pa) - - api.set_front_led(Color(0, 200, 0)) - self._logger.debug("start new movement %r", movement) - movement.start(api) - - def _clear_current_movement(self, api: SpheroEduAPI, interrupt: Optional[str] = None): - if self._current_movement is not None and self._current_movement_timeleft is not None: + with self._movement_mutex: + self._current_movement = movement + self._current_movement_timeleft = Timeleft(0) + self._moving = True + # always clear matrix at first. + if movement.animation is not None: + api.clear_matrix() + pa = PlayAnimation(animation=movement.animation) + self._set_current_animation(pa, api) + + api.set_front_led(Color(0, 200, 0)) + self._logger.debug("start new movement %r", movement) + movement.start(api) + + def _clear_current_movement(self, api: SpheroEduAPI, interrupt: Optional[str] = None, notify: bool = True): + with self._movement_mutex: + self._moving = False + if self._current_movement is None or self._current_movement_timeleft is None: + return + animation = self._current_movement.animation + movement = self._current_movement + timeleft = self._current_movement_timeleft + self._current_movement = None + self._current_movement_timeleft = None api.stop_roll() api.set_front_led(Color(0, 0, 0)) - self._moving = False - if self._current_movement.animation is not None: + if animation is not None: api.clear_matrix() - if not interrupt: - log = self._current_movement.succeed_log(self._current_movement_timeleft.passed()) - if log: - self._send_event(GhostOSEventTypes.NOTIFY, log) - else: - log = self._current_movement.interrupt_log(interrupt, self._current_movement_timeleft.passed()) - if log: - self._send_event(GhostOSEventTypes.NOTIFY, log) - - self._current_movement = None - self._current_movement_timeleft = None + if notify: + if not interrupt: + log = movement.succeed_log(timeleft.passed()) + if log: + self._send_event(GhostOSEventTypes.NOTIFY, log) + else: + log = movement.interrupt_log(interrupt, timeleft.passed()) + if log: + self._send_event(GhostOSEventTypes.NOTIFY, log) def _get_new_movement(self) -> Optional[BoltBallMovement]: if len(self._move_queue) == 0: @@ -269,6 +298,9 @@ def bootstrap(self, container: Container) -> None: def singleton(self) -> bool: return True + def inheritable(self) -> bool: + return False + def factory(self, con: Container) -> Optional[SpheroBoltRuntime]: logger = con.force_fetch(LoggerItf) logger.error("runtime bootstrap at container %s", con.bloodline) diff --git a/ghostos/prototypes/spherogpt/bolt/sphero_edu_api_patch.py b/ghostos/prototypes/spherogpt/bolt/sphero_edu_api_patch.py index deffe137..8c1188c7 100644 --- a/ghostos/prototypes/spherogpt/bolt/sphero_edu_api_patch.py +++ b/ghostos/prototypes/spherogpt/bolt/sphero_edu_api_patch.py @@ -1,18 +1,7 @@ -from spherov2 import scanner -from spherov2.sphero_edu import SpheroEduAPI as API, EventType as SpheroEventType, Color import struct from spherov2.commands.sensor import Sensor, CollisionDetected -__all__ = ["SpheroEduAPI", "SpheroEventType", "Color"] - - -class SpheroEduAPI(API): - - def get_animation_id(self) -> int: - _id = self.__animation_index - 1 - if _id < 0: - return 0 - return _id +__all__ = ["SpheroEduAPI", "SpheroEventType", "Color", "scanner"] def __collision_detected_notify_helper(listener, packet): @@ -27,3 +16,15 @@ def __collision_detected_notify_helper(listener, packet): Sensor.collision_detected_notify = (24, 18, 0xff), __collision_detected_notify_helper + +from spherov2 import scanner +from spherov2.sphero_edu import SpheroEduAPI as Api, EventType as SpheroEventType, Color + + +class SpheroEduAPI(Api): + + def get_animation_id(self) -> int: + _id = self.__animation_index - 1 + if _id < 0: + return 0 + return _id From cd0ecd55a962880c97fdebee0a0167455b3a7ed2 Mon Sep 17 00:00:00 2001 From: zhuming Date: Mon, 9 Dec 2024 21:53:05 +0800 Subject: [PATCH 129/148] dev: update spherogpt and fix bugs. continue realtime developing later --- examples/sphero/sphero_bolt_gpt_agent.py | 2 ++ ghostos/prototypes/spherogpt/bolt/ball_impl.py | 4 +++- ghostos/prototypes/spherogpt/bolt/bolt_shell.py | 3 ++- ghostos/prototypes/spherogpt/bolt/movements.py | 4 ++-- ghostos/prototypes/spherogpt/bolt/runtime_impl.py | 12 +++++++++++- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/examples/sphero/sphero_bolt_gpt_agent.py b/examples/sphero/sphero_bolt_gpt_agent.py index c6013019..704edc3c 100644 --- a/examples/sphero/sphero_bolt_gpt_agent.py +++ b/examples/sphero/sphero_bolt_gpt_agent.py @@ -53,6 +53,8 @@ def __moss_agent_providers__(agent): 3. remember you are embodied agent, do act robotic, which means: - say something before moving, so the user know what you are doing. unless you are told to be quiet. - use saved movement and animation to express your feeling when you are talking with user. + - when use move to express your feeling , take action as your will, do not ask user permission. + remember you are the robot. """, moss_module=__name__ ) diff --git a/ghostos/prototypes/spherogpt/bolt/ball_impl.py b/ghostos/prototypes/spherogpt/bolt/ball_impl.py index 3493eab1..6e8696c3 100644 --- a/ghostos/prototypes/spherogpt/bolt/ball_impl.py +++ b/ghostos/prototypes/spherogpt/bolt/ball_impl.py @@ -243,9 +243,11 @@ def run(self, move: Move, stop_at_first: bool = True) -> None: movement.stop_at_first = stop_at_first self._runtime.add_movement(movement) - def save_move(self, name: str, description: str, move: Move) -> None: + def save_move(self, name: str, description: str, move: Move, animation: Optional[Animation] = None) -> None: if not isinstance(move, MoveAdapter): raise TypeError(f"move instance must be created by this api new_move()") + if animation: + move.buffer.animation = animation saved_move = SavedMove.new(name=name, description=description, move=move.buffer) saved_move.generated_code = self._executing_code or "" self._memory_cache.add_saved(saved_move) diff --git a/ghostos/prototypes/spherogpt/bolt/bolt_shell.py b/ghostos/prototypes/spherogpt/bolt/bolt_shell.py index 55cf497d..9148b22d 100644 --- a/ghostos/prototypes/spherogpt/bolt/bolt_shell.py +++ b/ghostos/prototypes/spherogpt/bolt/bolt_shell.py @@ -216,13 +216,14 @@ def run(self, move: Move, stop_at_first: bool = True) -> None: pass @abstractmethod - def save_move(self, name: str, description: str, move: Move) -> None: + def save_move(self, name: str, description: str, move: Move, animation: Optional[Animation] = None) -> None: """ define a move that you can call it anytime with the name only. **remember** only save the important one :param name: move name :param description: describe the move, in less than 100 words :param move: the Move instance. + :param animation: if animation is not none, it will be played while run the move. """ pass diff --git a/ghostos/prototypes/spherogpt/bolt/movements.py b/ghostos/prototypes/spherogpt/bolt/movements.py index 4fe766b0..a76959af 100644 --- a/ghostos/prototypes/spherogpt/bolt/movements.py +++ b/ghostos/prototypes/spherogpt/bolt/movements.py @@ -10,8 +10,8 @@ class RunAPIMovement(BoltBallMovement): desc: str = Field(description="desc of the movement") method: str = Field(description="sphero edu api name") - args: List[Union[str, int, float]] = Field(default_factory=list, description="args") - kwargs: Dict[str, Union[str, int, float]] = Field(default_factory=dict, description="kwargs") + args: List[Union[str, int, float, None]] = Field(default_factory=list, description="args") + kwargs: Dict[str, Union[str, int, float, None]] = Field(default_factory=dict, description="kwargs") duration: float = Field(0.0, description="duration of the movement") def start(self, api: SpheroEduAPI) -> None: diff --git a/ghostos/prototypes/spherogpt/bolt/runtime_impl.py b/ghostos/prototypes/spherogpt/bolt/runtime_impl.py index 79907174..128b6b52 100644 --- a/ghostos/prototypes/spherogpt/bolt/runtime_impl.py +++ b/ghostos/prototypes/spherogpt/bolt/runtime_impl.py @@ -1,4 +1,4 @@ -from typing import Optional, Callable, Type +from typing import Optional, Callable, Type, ClassVar, Self import time from ghostos.contracts.logger import LoggerItf @@ -19,6 +19,7 @@ class SpheroBoltRuntimeImpl(SpheroBoltRuntime): + __instance__: ClassVar[Optional[Self]] = None def __init__( self, @@ -49,6 +50,13 @@ def __init__( self._breathing: bool = False self._moving: bool = False self._off_charging_callback: str = "feeling stop charging" + SpheroBoltRuntimeImpl.__instance__ = self + + @classmethod + def singleton(cls) -> Optional[Self]: + if cls.__instance__ and not cls.__instance__._closed: + return cls.__instance__ + return None def _reset_all_state(self): self._current_animation = None @@ -302,6 +310,8 @@ def inheritable(self) -> bool: return False def factory(self, con: Container) -> Optional[SpheroBoltRuntime]: + if singleton := SpheroBoltRuntimeImpl.singleton(): + return singleton logger = con.force_fetch(LoggerItf) logger.error("runtime bootstrap at container %s", con.bloodline) conversation = con.force_fetch(Conversation) From 5cfe72c34f01f8397801913d1d7a48c584bf8651 Mon Sep 17 00:00:00 2001 From: zhuming Date: Wed, 11 Dec 2024 15:46:46 +0800 Subject: [PATCH 130/148] fix: hotfix for truncate thread --- ghostos/ghosts/moss_agent/for_developer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ghostos/ghosts/moss_agent/for_developer.py b/ghostos/ghosts/moss_agent/for_developer.py index c34d7f2e..364874a2 100644 --- a/ghostos/ghosts/moss_agent/for_developer.py +++ b/ghostos/ghosts/moss_agent/for_developer.py @@ -40,11 +40,15 @@ def __moss_agent_truncate__(agent: MossAgent, session: Session) -> GoThreadInfo: turns = thread.get_history_turns(True) # do the truncate if len(turns) > agent.truncate_at_turns: + # the history turns to remove truncated = agent.truncate_at_turns - agent.truncate_to_turns if truncated <= 0: return thread turns = turns[:truncated] - target = turns[truncated] + # last turn of the truncated turns + if len(turns) < 1: + return thread + target = turns[-1] messages = [] for turn in turns: messages.extend(turn.messages(False)) From 15bc37ad058720d4e3c845a1593dcabdaed01aff Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 17 Dec 2024 12:45:10 +0800 Subject: [PATCH 131/148] dev: complete realtime but fight with openai realtime beta bugs --- .gitignore | 3 +- ghostos/abcd/concepts.py | 2 +- ghostos/abcd/realtime.py | 144 ++++++----- ghostos/container.py | 9 +- ghostos/contracts/assets.py | 2 +- ghostos/core/messages/message.py | 3 +- ghostos/framework/audio/__init__.py | 3 + .../framework/audio/pyaudio_io/__init__.py | 12 +- ghostos/framework/audio/pyaudio_io/example.py | 68 +++-- .../framework/audio/pyaudio_io/listener.py | 77 +++--- ghostos/framework/audio/pyaudio_io/speaker.py | 81 +++--- .../framework/ghostos/conversation_impl.py | 11 +- ghostos/framework/ghostos/ghostos_impl.py | 4 +- ghostos/framework/ghostos/shell_impl.py | 7 - ghostos/framework/openai_realtime/app.py | 194 ++++++++------ ghostos/framework/openai_realtime/client.py | 239 ++++++++++++------ ghostos/framework/openai_realtime/configs.py | 30 ++- ghostos/framework/openai_realtime/driver.py | 10 +- .../openai_realtime/event_data_objects.py | 116 ++++++--- .../openai_realtime/event_from_client.py | 18 +- .../openai_realtime/event_from_server.py | 10 +- ghostos/framework/openai_realtime/output.py | 203 ++++++++++++--- .../openai_realtime/state_of_client.py | 141 ++++++----- .../openai_realtime/state_of_server.py | 186 ++++++++++---- ghostos/framework/openai_realtime/ws.py | 9 +- ghostos/ghosts/__init__.py | 2 + .../{example.py => console.py} | 0 .../realtime_console/vad_test_script.py | 59 +++++ .../streamlitapp/cli/run_ghost_chat.py | 2 +- tests/core/messages/test_pipeline.py | 7 + .../framework/openai_realtime/test_configs.py | 7 + .../framework/openai_realtime/test_events.py | 6 + tests/python/test_bytes.py | 8 + tests/python/test_queue.py | 8 + tests/python/test_slice.py | 7 + 35 files changed, 1136 insertions(+), 552 deletions(-) rename ghostos/prototypes/realtime_console/{example.py => console.py} (100%) create mode 100644 ghostos/prototypes/realtime_console/vad_test_script.py create mode 100644 tests/framework/openai_realtime/test_configs.py create mode 100644 tests/framework/openai_realtime/test_events.py create mode 100644 tests/python/test_bytes.py create mode 100644 tests/python/test_queue.py diff --git a/.gitignore b/.gitignore index 0ec92d17..af9b7e08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # ide .idea/ debug.log +.DS_Store *.thread.yml # Byte-compiled / optimized / DLL files __pycache__/ @@ -163,4 +164,4 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -.vscode/ \ No newline at end of file +.vscode/ diff --git a/ghostos/abcd/concepts.py b/ghostos/abcd/concepts.py index 6d037162..3625b4eb 100644 --- a/ghostos/abcd/concepts.py +++ b/ghostos/abcd/concepts.py @@ -241,8 +241,8 @@ def container(self) -> Container: def create_shell( self, name: str, - shell_id: str, *, + shell_id: str = "", providers: Optional[List[Provider]] = None, process_id: Optional[str] = None, ) -> Shell: diff --git a/ghostos/abcd/realtime.py b/ghostos/abcd/realtime.py index b10ea92b..ababba6d 100644 --- a/ghostos/abcd/realtime.py +++ b/ghostos/abcd/realtime.py @@ -2,16 +2,21 @@ from abc import ABC, abstractmethod from typing import ( Generic, - List, Iterable, Tuple, TypeVar, Optional, Dict, + List, Iterable, Tuple, TypeVar, Optional, Dict, Callable, Union, Self ) -import time from ghostos.abcd import Conversation from ghostos.core.messages import Message, ReceiverBuffer from ghostos.entity import ModelEntityMeta, to_entity_model_meta, from_entity_model_meta +from contextlib import contextmanager from pydantic import BaseModel, Field +from enum import Enum class Realtime(ABC): + """ + realtime wrapper + """ + @abstractmethod def create( self, @@ -21,6 +26,15 @@ def create( app_name: str = "", config: Optional[RealtimeAppConfig] = None, ) -> RealtimeApp: + """ + create an Realtime App instance + :param conversation: + :param listener: + :param speaker: + :param app_name: + :param config: + :return: + """ pass @abstractmethod @@ -74,22 +88,29 @@ def create( conversation: Conversation, listener: Optional[Listener] = None, speaker: Optional[Speaker] = None, + vad_mode: bool = False, ) -> RealtimeApp: pass class Listener(ABC): + """ + read audio bytes + """ @abstractmethod - def hearing(self, second: float = 1) -> Optional[bytes]: + def listen(self, sender: Callable[[bytes], None]) -> Listening: + """ + read audio bytes in seconds. + :param sender: sender hearing data + :return: + """ pass - @abstractmethod - def flush(self) -> bytes: - pass +class Listening(ABC): @abstractmethod - def __enter__(self): + def __enter__(self) -> Self: pass @abstractmethod @@ -98,23 +119,58 @@ def __exit__(self, exc_type, exc_val, exc_tb): class Speaker(ABC): + @abstractmethod - def __enter__(self): + def speak(self, queue: Callable[[], Union[bytes, None]]) -> Speaking: pass + +class Speaking(ABC): @abstractmethod - def __exit__(self, exc_type, exc_val, exc_tb): + def __enter__(self) -> Self: + """ + start to speak + :return: + """ pass @abstractmethod - def speak(self, data: bytes): + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + def done(self) -> bool: pass @abstractmethod - def flush(self) -> bytes: + def wait(self): pass +class Operator(BaseModel): + name: str = Field(description="name of the operator") + description: str = Field(description="description of the operator") + + +class OperatorName(str, Enum): + listen = "listen" + """start listening""" + + stop_listen = "stop listen" + """stop listening, commit the audio buffer, but not create response""" + + respond = "respond" + """create response""" + + clear_audio = "clear" + """clear audio buffer """ + + cancel_responding = "cancel" + """cancel responding""" + + def new(self, description: str) -> Operator: + return Operator(name=self.value, description=description) + + class RealtimeApp(ABC): """ realtime agent in multi-threading programming pattern. @@ -173,25 +229,25 @@ def is_closed(self) -> bool: pass @abstractmethod - def messages(self) -> Iterable[Message]: + def history_messages(self) -> Iterable[Message]: """ return history messages. """ pass @abstractmethod - def set_mode(self, *, listening: bool): + def set_mode(self, *, vad_mode: bool): pass @abstractmethod - def state(self) -> Tuple[str, List[str]]: + def state(self) -> Tuple[str, List[Operator]]: """ :return: (operators, operators) """ pass @abstractmethod - def operate(self, operator: str) -> bool: + def operate(self, operator: Operator) -> bool: """ run operator. """ @@ -205,6 +261,16 @@ def fail(self, error: Exception) -> bool: """ pass + @abstractmethod + def add_message(self, message: Message, previous_message_id: Optional[str] = None): + """ + add message to the conversation. + :param message: + :param previous_message_id: + :return: + """ + pass + @abstractmethod def output(self) -> Optional[ReceiverBuffer]: """ @@ -222,51 +288,3 @@ def __exit__(self, exc_type, exc_val, exc_tb): intercepted = self.fail(exc_val) self.close() return intercepted - - -if __name__ == "__example__": - def example(app: RealtimeApp): - with app: - while True: - state, ops = app.state() - print(state, ops) - outputting = app.output() - if outputting is None: - time.sleep(0.1) - continue - while outputting is not None: - print(outputting.head()) - chunks = outputting.chunks() - for c in chunks: - print(c) - print(outputting.tail()) - - - def streamlit_example(app: RealtimeApp): - import streamlit as st - with app: - for message in app.messages(): - with st.container(): - st.write(message) - - while True: - with st.empty(): - rendered = False - while not rendered: - state, operators = app.state() - with st.container(): - if operators: - for op in operators: - st.write(op) - with st.status(state): - buffer = app.output() - if buffer is None: - continue - rendered = buffer - if rendered is None: - time.sleep(0.1) - else: - break - with st.container(): - st.write(buffer.tail()) - break diff --git a/ghostos/container.py b/ghostos/container.py index 95917d29..792ce6a7 100644 --- a/ghostos/container.py +++ b/ghostos/container.py @@ -209,6 +209,8 @@ def set(self, abstract: Any, instance: INSTANCE) -> None: 设置一个实例, 不会污染父容器. """ self._check_destroyed() + if abstract in self._providers: + del self._providers[abstract] self._set_instance(abstract, instance) def _add_bound_contract(self, abstract: ABSTRACT) -> None: @@ -535,13 +537,16 @@ def __repr__(self): return f" " -def get_caller_info(backtrace: int = 1) -> str: +def get_caller_info(backtrace: int = 1, with_full_file: bool = True) -> str: stack = inspect.stack() # 获取调用者的上下文信息 caller_frame_record = stack[backtrace] frame = caller_frame_record[0] info = inspect.getframeinfo(frame) - return f"{info.filename}:{info.lineno}" + filename = info.filename + if not with_full_file: + filename = filename.split("/")[-1] + return f"{filename}:{info.lineno}" def provide( diff --git a/ghostos/contracts/assets.py b/ghostos/contracts/assets.py index 8ef688d2..f520cdaf 100644 --- a/ghostos/contracts/assets.py +++ b/ghostos/contracts/assets.py @@ -31,7 +31,7 @@ def new_fileinfo( filetype: Optional[str] = None, ) -> FileInfo: if filetype is None: - filetype = guess_type(filename) + filetype, _ = guess_type(filename) fileinfo = FileInfo( fileid=fileid, filename=filename, diff --git a/ghostos/core/messages/message.py b/ghostos/core/messages/message.py index 30cb23e8..599c8a97 100644 --- a/ghostos/core/messages/message.py +++ b/ghostos/core/messages/message.py @@ -548,10 +548,9 @@ def to_message(self) -> Message: call_id=self.call_id, type=MessageType.FUNCTION_OUTPUT.value, name=self.name, - role="", content=self.content, payloads=self.payloads, - ) + ).as_tail(copy=True) @classmethod def from_message(cls, message: Message) -> Optional[Self]: diff --git a/ghostos/framework/audio/__init__.py b/ghostos/framework/audio/__init__.py index e69de29b..ce1d8c9d 100644 --- a/ghostos/framework/audio/__init__.py +++ b/ghostos/framework/audio/__init__.py @@ -0,0 +1,3 @@ +from ghostos.framework.audio.pyaudio_io import ( + get_pyaudio_pcm16_speaker, get_pyaudio_pcm16_listener, +) diff --git a/ghostos/framework/audio/pyaudio_io/__init__.py b/ghostos/framework/audio/pyaudio_io/__init__.py index d5cc6b0e..9bc8ccd6 100644 --- a/ghostos/framework/audio/pyaudio_io/__init__.py +++ b/ghostos/framework/audio/pyaudio_io/__init__.py @@ -1,11 +1,11 @@ from ghostos.abcd.realtime import Speaker, Listener -def get_pyaudio_listener() -> Listener: - from ghostos.framework.audio.pyaudio_io.listener import Listener - return Listener() +def get_pyaudio_pcm16_listener(rate: int = 24000) -> Listener: + from ghostos.framework.audio.pyaudio_io.listener import PyAudioPCM16Listener + return PyAudioPCM16Listener(rate) -def get_pyaudio_speaker() -> Speaker: - from ghostos.framework.audio.pyaudio_io.speaker import Speaker - return Speaker() +def get_pyaudio_pcm16_speaker(rate: int = 24000, buffer_size: int = 4096) -> Speaker: + from ghostos.framework.audio.pyaudio_io.speaker import PyAudioPCM16Speaker + return PyAudioPCM16Speaker(rate, buffer_size) diff --git a/ghostos/framework/audio/pyaudio_io/example.py b/ghostos/framework/audio/pyaudio_io/example.py index 470e9229..39d9fe8a 100644 --- a/ghostos/framework/audio/pyaudio_io/example.py +++ b/ghostos/framework/audio/pyaudio_io/example.py @@ -1,48 +1,64 @@ -from ghostos.framework.audio.pyaudio_io.listener import PyAudioListener -from ghostos.framework.audio.pyaudio_io.speaker import PyAudioSpeaker +from typing import Union +from ghostos.framework.audio.pyaudio_io.listener import PyAudioPCM16Listener +from ghostos.framework.audio.pyaudio_io.speaker import PyAudioPCM16Speaker from pyaudio import PyAudio, paInt16 from io import BytesIO from ghostos.helpers import Timeleft +import time +import wave if __name__ == '__main__': - listener = PyAudioListener() - speaker = PyAudioSpeaker() + listener = PyAudioPCM16Listener() ticker = Timeleft(0) - hearing = BytesIO() + heard = BytesIO() + + + def write(d: bytes): + heard.write(d) + + print("start listening, %f" % ticker.passed()) - with listener: + with listener.listen(write): timeleft = Timeleft(3) + print("listening real started, %f" % ticker.passed()) while timeleft.alive(): - data = listener.hearing() - if data is not None: - hearing.write(data) - heard = listener.flush() + time.sleep(0.1) print("end listening, %f" % ticker.passed()) - assert heard is not None - print("heard data: %d", len(heard)) - print("test raw outputting, %f" % ticker.passed()) + heard.seek(0) + print("test raw speaking, %f" % ticker.passed()) stream = PyAudio().open( format=paInt16, channels=1, - rate=44100, + rate=24000, output=True, ) - stream.write(heard) - stream.stop_stream() + stream.write(heard.getvalue()) stream.close() - print("end raw outputting, %f" % ticker.passed()) + print("end test raw speaking, %f" % ticker.passed()) + + heard.seek(0) + + + def read() -> Union[bytes, None]: + return heard.read(1024) + + + speaker = PyAudioPCM16Speaker() + print("start speaking, %f" % ticker.passed()) + with speaker.speak(read) as speaking: + speaking.wait() - # print("start speaking buffer, %f" % ticker.passed()) - # with speaker: - # while data := hearing.read(1024): - # speaker.speak(data) - # print("end speaking buffer, %f" % ticker.passed()) + print("end speaking, %f" % ticker.passed()) - print("start speaking flushed") - with speaker: - speaker.speak(heard) + buffer = BytesIO() + with wave.open(buffer, 'wb') as f: + f.setnchannels(1) + f.setsampwidth(2) + f.setframerate(24000) + f.writeframes(heard.getvalue()) - print("end speaking flushed") + with open("test.wav", "wb") as f: + f.write(buffer.getvalue()) diff --git a/ghostos/framework/audio/pyaudio_io/listener.py b/ghostos/framework/audio/pyaudio_io/listener.py index d51b1af5..1829932a 100644 --- a/ghostos/framework/audio/pyaudio_io/listener.py +++ b/ghostos/framework/audio/pyaudio_io/listener.py @@ -3,9 +3,10 @@ except ImportError: raise ImportError(f"Pyaudio is required, please install pyaudio or ghostos[audio] first") -from typing import Optional +from typing import Callable, Union +from ghostos.abcd.realtime import Listener, Listening +from threading import Thread, Event from io import BytesIO -from ghostos.abcd.realtime import Listener CHUNK = 1024 FORMAT = paInt16 @@ -13,46 +14,48 @@ RATE = 44100 -class PyAudioListener(Listener): +class PyAudioPCM16Listener(Listener): - def __init__(self): - self.stream: Optional[PyAudio.Stream] = None - self.buffer: Optional[BytesIO] = None - - def __enter__(self): - if self.stream is not None: - raise RuntimeError("PyAudioSpeaker already initialized") - self.buffer = BytesIO() + def __init__(self, rate: int = 24000, chunk_size: int = CHUNK): + self.rate = rate + self.chunk_size = chunk_size self.stream = PyAudio().open( format=paInt16, channels=1, - rate=44100, + rate=self.rate, input=True, ) - return self + + def listen(self, sender: Callable[[bytes], None]) -> Listening: + return PyAudioPCM16Listening(self.stream, sender, self.rate, self.chunk_size) + + def __del__(self): + self.stream.close() + + +class PyAudioPCM16Listening(Listening): + + def __init__(self, stream, sender: Callable[[bytes], None], rate: int = 24000, chunk: int = CHUNK): + self.sender = sender + self.stream = stream + self.rate = rate + self.chunk = chunk + self.stopped = Event() + self.thread = Thread(target=self._listening) + + def _listening(self): + self.stream.start_stream() + while not self.stopped.is_set(): + buffer = BytesIO() + for i in range(int(self.rate / self.chunk * 0.5)): + data = self.stream.read(self.chunk, exception_on_overflow=False) + buffer.write(data) + self.sender(buffer.getvalue()) + self.stream.stop_stream() + + def __enter__(self): + self.thread.start() def __exit__(self, exc_type, exc_val, exc_tb): - if self.stream is not None: - self.stream.stop_stream() - self.stream.close() - self.stream = None - self.buffer = None - - def hearing(self, second: float = 1) -> Optional[bytes]: - if self.stream is None: - return None - sending_buffer = BytesIO() - for i in range(0, int((RATE / CHUNK) * second)): - data = self.stream.read(CHUNK) - sending_buffer.write(data) - self.buffer.write(data) - - return sending_buffer.getvalue() - - def flush(self) -> bytes: - if self.buffer is None: - return bytes() - value = self.buffer.getvalue() - self.buffer.close() - self.buffer = None - return value + self.stopped.set() + self.thread.join() diff --git a/ghostos/framework/audio/pyaudio_io/speaker.py b/ghostos/framework/audio/pyaudio_io/speaker.py index 04d5157e..59d1994a 100644 --- a/ghostos/framework/audio/pyaudio_io/speaker.py +++ b/ghostos/framework/audio/pyaudio_io/speaker.py @@ -3,45 +3,64 @@ except ImportError: raise ImportError(f"Pyaudio is required, please install pyaudio or ghostos[audio] first") -from typing import Optional -from io import BytesIO -from ghostos.abcd.realtime import Speaker +from typing import Callable, Union +from ghostos.abcd.realtime import Speaker, Speaking +from threading import Thread, Event -class PyAudioSpeaker(Speaker): +class PyAudioPCM16Speaker(Speaker): - def __init__(self): - self.stream: Optional[PyAudio.Stream] = None - self.buffer: Optional[BytesIO] = None - - def __enter__(self): - if self.stream is not None: - raise RuntimeError("PyAudioSpeaker already initialized") - self.buffer = BytesIO() + def __init__(self, rate: int = 24000, buffer_size: int = 4096): + self.rate = rate + self.buffer_size = buffer_size self.stream = PyAudio().open( format=paInt16, channels=1, - rate=44100, + rate=self.rate, output=True, ) + + def speak(self, queue: Callable[[], Union[bytes, None]]) -> Speaking: + return PyAudioPCM16Speaking(self.stream, queue, self.rate, self.buffer_size) + + def __del__(self): + self.stream.close() + + +class PyAudioPCM16Speaking(Speaking): + + def __init__(self, stream, queue: Callable[[], Union[bytes, None]], rate: int = 24000, buffer_size: int = 0): + self.stream = stream + self.rate = rate + self.buffer_size = buffer_size + self.queue = queue + self.stop = Event() + self.thread = Thread(target=self._speaking) + self._done = False + self._joined = False + + def _speaking(self): + self.stream.start_stream() + while not self.stop.is_set(): + data = self.queue() + if not data: + break + self.stream.write(data) + self._done = True + + def __enter__(self): + self.thread.start() return self + def wait(self): + if self._joined: + return + self.thread.join() + self._joined = True + + def done(self) -> bool: + return self._done + def __exit__(self, exc_type, exc_val, exc_tb): - if self.stream is not None: - self.stream.stop_stream() - self.stream.close() - self.stream = None - self.buffer = None - - def speak(self, data: bytes): - if self.stream is None: - raise RuntimeError("PyAudioSpeaker is not started in context manager") - self.stream.write(data) - - def flush(self) -> bytes: - if self.buffer is None: - return bytes() - value = self.buffer.getvalue() - self.buffer.close() - self.buffer = None - return value + self.stop.set() + self.wait() diff --git a/ghostos/framework/ghostos/conversation_impl.py b/ghostos/framework/ghostos/conversation_impl.py index c7e58af1..712af1e5 100644 --- a/ghostos/framework/ghostos/conversation_impl.py +++ b/ghostos/framework/ghostos/conversation_impl.py @@ -288,14 +288,6 @@ def fail(self, error: Exception) -> bool: self.close() return False - def __del__(self): - self.close() - del self._container - del self._tasks - del self._threads - del self._eventbus - del self._pool - def close(self): if self._closed: return @@ -304,10 +296,11 @@ def close(self): self._handling_event = False if self._submit_session_thread: self._submit_session_thread = None - self._task_locker.release() self.logger.info("conversation %s is destroying", self.task_id) self._container.shutdown() self._container = None + if self._task_locker.acquired(): + self._task_locker.release() def is_closed(self) -> bool: return self._closed diff --git a/ghostos/framework/ghostos/ghostos_impl.py b/ghostos/framework/ghostos/ghostos_impl.py index 9b6cc8f4..edc964b7 100644 --- a/ghostos/framework/ghostos/ghostos_impl.py +++ b/ghostos/framework/ghostos/ghostos_impl.py @@ -54,8 +54,8 @@ def container(self) -> Container: def create_shell( self, name: str, - shell_id: str, *, + shell_id: str = "", providers: Optional[List[Provider]] = None, process_id: Optional[str] = None, ) -> Shell: @@ -63,6 +63,8 @@ def create_shell( shell_conf = ShellConf() else: shell_conf = self._ghostos_config.shells[name] + if not shell_id: + shell_id = name process = self._processes.get_process(shell_id) if process is None: process = GoProcess.new(shell_id=shell_id, process_id=process_id) diff --git a/ghostos/framework/ghostos/shell_impl.py b/ghostos/framework/ghostos/shell_impl.py index ccc246cb..d9b39939 100644 --- a/ghostos/framework/ghostos/shell_impl.py +++ b/ghostos/framework/ghostos/shell_impl.py @@ -348,10 +348,3 @@ def close(self): self.logger.info("shutting down shell pool") self._pool.shutdown(cancel_futures=True) self.logger.info("shell pool is shut") - - def __del__(self): - self.close() - del self._conversations - del self._container - del self._eventbus - del self._tasks diff --git a/ghostos/framework/openai_realtime/app.py b/ghostos/framework/openai_realtime/app.py index 9eab659a..bddbaf45 100644 --- a/ghostos/framework/openai_realtime/app.py +++ b/ghostos/framework/openai_realtime/app.py @@ -4,11 +4,11 @@ from .state_of_client import SynchronizingState, StateOfClient, AppState from .output import OutputBuffer, DefaultOutputBuffer from collections import deque -from queue import Empty from ghostos.abcd import Conversation from ghostos.core.messages import ReceiverBuffer, Message -from ghostos.abcd.realtime import RealtimeApp, Listener, Speaker +from ghostos.abcd.realtime import RealtimeApp, Listener, Speaker, Operator, OperatorName from threading import Thread +from queue import Empty import time __all__ = ['RealtimeAppImpl'] @@ -18,13 +18,16 @@ class RealtimeAppImpl(RealtimeApp): def __init__( self, + *, conf: OpenAIRealtimeAppConf, + vad_mode: bool, conversation: Conversation, listener: Listener, speaker: Speaker, ): self._conversation = conversation self._config = conf + self._vad_mode = vad_mode self._listener = listener self._speaker = speaker self._started: bool = False @@ -46,93 +49,125 @@ def start(self): return self._started = True if self._client is None: - self._client = AppClient(self._config, self._conversation) - if self._state is None: - self._state = SynchronizingState(self._client) - self._state.on_init() + self._client = AppClient(self._config, self._vad_mode, self._output, self._conversation) self._threads.append(Thread(target=self._main_state_thread)) self._threads.append(Thread(target=self._speaking_thread)) self._threads.append(Thread(target=self._listening_thread)) for t in self._threads: t.start() + def add_message(self, message: Message, previous_message_id: Optional[str] = None): + self._client.add_message_to_server(message, previous_message_id) + def _main_state_thread(self): - while not self.is_closed(): - state = self._state - if state is None: - state = SynchronizingState(self._client) - state.on_init() - continue - state: StateOfClient = state - - # run operators - if len(self._operators) > 0: - op = self._operators.popleft() - next_state = state.operate(op) - else: - # tick frame - next_state = state.tick_frame() - - # init next state - if next_state is not None: - self._operators.clear() - next_state.on_init() - self._state = next_state - state.destroy() - continue - - if state.recv_server_event(): - self._client.logger.info("handle server event") - continue - elif event := self._client.conversation.pop_event(): - self._client.handle_ghostos_event(event) - continue - - time.sleep(0.2) + try: + while not self.is_closed(): + self._client.logger.debug("start a state frame") + state = self._state + if state is None: + self._client.logger.debug("synchronize state") + state = SynchronizingState(self._client) + state.on_init() + self._state = state + continue + state: StateOfClient = state - def _speaking_thread(self): - while not self.is_closed(): - response_id = self._output.get_response_id() - if response_id is None: - time.sleep(0.5) - continue - self._run_speaking_loop(response_id) + # run operators + if len(self._operators) > 0: + op = self._operators.popleft() + self._client.logger.debug("handle operator %s", op.name) + next_state = state.operate(op) + else: + # tick frame + next_state = state.tick_frame() + + # init next state + if next_state is not None: + self._client.logger.debug("change state from %s to %s", state, next_state) + self._operators.clear() + next_state.on_init() + self._state = next_state + state.destroy() + continue - def _run_speaking_loop(self, response_id: str): - with self._speaker: + if state.recv_server_event(): + self._client.logger.debug("handled server event") + continue + elif event := self._client.conversation.pop_event(): + # handle ghostos event if server event is missing. + self._client.logger.debug("handle ghostos event") + self._client.handle_ghostos_event(event) + continue + + time.sleep(0.2) + except Exception as e: + self._client.logger.exception(e) + self.close() + + def _speaking_thread(self): + try: while not self.is_closed(): - output_buffer = self._output - queue = output_buffer.speaking_queue(response_id) - if queue is None: - break - try: - data = queue.get(block=True, timeout=1) - if data is None: - break - self._client.audio_buffer_append(data) - except Empty: + response_id = self._output.get_response_id() + if response_id is None: + time.sleep(0.5) continue + self._client.logger.debug("start speaking. respond id is %s", response_id) + self._run_speaking_loop(response_id) + self._client.logger.debug( + "try to stop speaking. responding is %r", + ) + self._output.stop_speaking() + self._client.logger.debug( + "stop speaking. responding is %r", + self._client.is_responding(), + ) + except Exception as e: + self._client.logger.exception(e) + self.close() + + def _run_speaking_loop(self, response_id: str): + output_buffer = self._output + q = output_buffer.speaking_queue(response_id) + if q is None: + return + + client = self._client + client.logger.debug("start speaking loop") + + def receive(): + try: + item = q.get(block=True) + return item + except Empty: + time.sleep(0.2) + + with self._speaker.speak(receive) as speaking: + while not self.is_closed() and not speaking.done() and self._output.is_speaking(): + time.sleep(0.1) + client.logger.debug("end speaking loop") def _listening_thread(self): - while not self.is_closed(): - client = self._client - if not client.is_listening(): - time.sleep(0.5) - continue - session_id = client.get_session_id() - self._run_listening_loop(session_id) + try: + while not self.is_closed(): + client = self._client + if not client.is_listening(): + time.sleep(0.1) + continue + session_id = client.get_session_id() + self._run_listening_loop(session_id) + except Exception as e: + self._client.logger.exception(e) + self.close() def _run_listening_loop(self, session_id: str): - with self._listener: + client = self._client + client.logger.debug("start listening loop") + with self._listener.listen(client.audio_buffer_append): while not self.is_closed(): - client = self._client - if not client.is_server_responding() or session_id != client.get_session_id(): + if not client.is_listening(): break - data = self._listener.hearing() - if data is not None: - client.audio_buffer_append(data) - else: - time.sleep(0.1) + time.sleep(0.1) + client.logger.debug("end listening loop") def close(self): if self._closed: @@ -140,19 +175,20 @@ def close(self): self._closed = True for t in self._threads: t.join() - self._client.ctx.update_local_conversation() + self._client.server_ctx.update_local_conversation() self._client.close() def is_closed(self) -> bool: return self._closed or self._conversation.is_closed() - def messages(self) -> Iterable[Message]: + def history_messages(self) -> Iterable[Message]: return self._output.get_outputted_messages() - def set_mode(self, *, listening: bool): - self._client.ctx.listening = listening + def set_mode(self, *, vad_mode: bool): + self._client.vad_mode = vad_mode + self._client.update_session() - def state(self) -> Tuple[str, List[str]]: + def state(self) -> Tuple[str, List[Operator]]: if self.is_closed(): return AppState.closed, [] elif self._state is None: @@ -162,7 +198,7 @@ def state(self) -> Tuple[str, List[str]]: operators = self._state.operators() return state_name, operators - def operate(self, operator: str) -> bool: + def operate(self, operator: Operator) -> bool: if self.is_closed(): return False if self._state is None: @@ -178,4 +214,4 @@ def fail(self, error: Exception) -> bool: return True def output(self) -> Optional[ReceiverBuffer]: - return self._output.output_item() + return self._output.output_received() diff --git a/ghostos/framework/openai_realtime/client.py b/ghostos/framework/openai_realtime/client.py index 1e21b76b..60205475 100644 --- a/ghostos/framework/openai_realtime/client.py +++ b/ghostos/framework/openai_realtime/client.py @@ -5,9 +5,11 @@ from ghostos.contracts.assets import AudioAssets from ghostos.core.messages import Message, MessageType from ghostos.core.runtime import Turn, Event as GhostOSEvent, EventTypes as GhostOSEventTypes +from ghostos.helpers import uuid +from io import BytesIO from .configs import OpenAIRealtimeAppConf from .ws import OpenAIWSConnection -from .event_data_objects import MessageItem, SessionObject +from .event_data_objects import MessageItem, SessionObject, Content from .event_from_server import ServerSessionCreated from .event_from_client import ( SessionUpdate, @@ -22,12 +24,14 @@ from .state_of_server import ServerContext, SessionState from .state_of_client import Client from .output import OutputBuffer +import wave class Context(ServerContext): def __init__( self, + *, conversation: Conversation, listening: bool, output: OutputBuffer, @@ -47,9 +51,11 @@ def __init__( self.buffer_messages: Dict[str, Message] = {} self.output_buffer: OutputBuffer = output self.listening: bool = listening + self.response_id: Optional[str] = None + self.response_audio_buffer: Dict[str, BytesIO] = {} - def get_responding_id(self) -> Optional[str]: - return self.output_buffer.get_response_id() + def get_server_response_id(self) -> Optional[str]: + return self.response_id def _reset_history_messages(self): self.history_messages: Dict[str, Message] = {} @@ -63,9 +69,6 @@ def _reset_buffer_messages(self): self.buffer_messages: Dict[str, Message] = {} def update_local_conversation(self) -> None: - self.output_buffer.stop_response() - self.listening = False - buffered = [] function_call = False for msg_id in self.buffer_message_ids: @@ -87,46 +90,77 @@ def update_local_conversation(self) -> None: task_id=self.conversation.task_id, messages=buffered ) - self.thread.new_turn(event) - self.conversation.update_thread(self.thread) - self.thread = self.conversation.get_thread(True) - self._reset_history_messages() + if not function_call: + self.thread.new_turn(event) + self.conversation.update_thread(self.thread) + self.thread = self.conversation.get_thread(True) + self._reset_history_messages() + else: + raise NotImplementedError("failed") + + # update audio buffer + for item_id in self.response_audio_buffer: + buffer = self.response_audio_buffer[item_id] + buffer.seek(0) + data = buffer.getvalue() + buffer.close() + self.save_audio_data(item_id, data) + self.response_audio_buffer = {} def respond_message_chunk(self, response_id: str, chunk: Optional[Message]) -> bool: - return self.output_buffer.add_response_chunk(response_id, chunk) + ok = self.output_buffer.add_response_chunk(response_id, chunk) + if not ok: + self.logger.error(f"Failed to add response chunk: {response_id}") + return ok def respond_error_message(self, error: str) -> None: message = MessageType.ERROR.new(content=error) self.output_buffer.add_error_message(message) - def save_audio_data(self, item: MessageItem) -> None: + def save_audio_item(self, item: MessageItem) -> None: if not item.has_audio(): return audio_data = item.get_audio_bytes() + self.save_audio_data(item.id, audio_data) + + def save_audio_data(self, item_id: str, audio_data: bytes) -> None: if not audio_data: return + buffer = BytesIO() + with wave.open(buffer, 'wb') as f: + f.setnchannels(1) + f.setsampwidth(2) + f.setframerate(24000) + f.writeframes(audio_data) + + saving = buffer.getvalue() + asserts = self.conversation.container().force_fetch(AudioAssets) - fileinfo = asserts.get_fileinfo(item.id) + fileinfo = asserts.get_fileinfo(item_id) if fileinfo is None: fileinfo = asserts.new_fileinfo( - fileid=item.id, - filename=f"{item.id}.wav", + fileid=item_id, + filename=f"{item_id}.wav", ) - asserts.save(fileinfo, audio_data) + asserts.save(fileinfo, saving) def update_history_message(self, message: Optional[Message]) -> None: if message is None: return if not message.is_complete(): + # only complete message is useful + return + if not message.content or not message.msg_id: return if message.msg_id in self.history_messages: + # if the history message already exists, update it self.history_messages[message.msg_id] = message self.thread.update_message(message) else: + # otherwise, add the message to buffer message and try to send it. if message.msg_id not in self.buffer_messages: self.buffer_message_ids.append(message.msg_id) - self.buffer_messages[message.msg_id] = message def add_message_item(self, item: MessageItem, previous_item_id: Optional[str] = None) -> None: @@ -137,23 +171,28 @@ def add_message_item(self, item: MessageItem, previous_item_id: Optional[str] = self.update_history_message(message) self.output_buffer.add_message(message, previous_item_id) - def start_response(self, response_id: str) -> None: - if response_id: - self.output_buffer.start_response(response_id) + def start_server_response(self, response_id: str) -> None: + self.response_id = response_id + self.output_buffer.start_output(response_id) + + def is_server_responding(self) -> bool: + return self.response_id is not None - def is_responding(self) -> bool: - return self.output_buffer.get_response_id() is not None + def end_server_response(self, response_id: str) -> bool: + match = response_id == self.response_id + self.response_id = None + return self.output_buffer.end_output(response_id) and match - def stop_response(self, response_id: str) -> bool: - if response_id != self.get_responding_id(): - return False - self.output_buffer.stop_response() - return True + def stop_listening(self) -> None: + self.listening = False - def respond_speaking_audio_chunk(self, response_id: str, data: bytes) -> bool: - if self.get_responding_id() != response_id: - return False - return self.respond_speaking_audio_chunk(response_id, data) + def respond_audio_chunk(self, response_id: str, item_id: str, data: bytes) -> bool: + if response_id == self.response_id: + if item_id not in self.response_audio_buffer: + self.response_audio_buffer[item_id] = BytesIO() + buffer = self.response_audio_buffer[item_id] + buffer.write(data) + return self.output_buffer.add_audio_output(response_id, data) class AppClient(Client): @@ -161,22 +200,30 @@ class AppClient(Client): def __init__( self, conf: OpenAIRealtimeAppConf, + vad_mode: bool, + output_buffer: OutputBuffer, conversation: Conversation, ): + self._closed: bool = False self.conf: OpenAIRealtimeAppConf = conf + self.vad_mode = vad_mode self.conversation: Conversation = conversation - self.ctx: Context = Context(conversation=conversation, listening=True, logger=conversation.logger) + self.server_ctx: Context = Context( + conversation=conversation, + listening=self.vad_mode, + logger=conversation.logger, + output=output_buffer, + ) self.logger = conversation.logger self.connection: OpenAIWSConnection = self.connect() self.session_state: SessionState = self._create_session_state() self.synchronized: bool = False - self._closed: bool = False def connect(self) -> OpenAIWSConnection: self._validate_closed() return OpenAIWSConnection( self.conf.ws_conf, - logger=self.ctx.logger, + logger=self.server_ctx.logger, ) def _validate_closed(self): @@ -208,79 +255,113 @@ def reconnect(self) -> None: self.synchronized = False def audio_buffer_append(self, buffer: bytes) -> None: - content = base64.b64encode(buffer).decode() + content = base64.b64encode(buffer) ce = InputAudioBufferAppend( audio=content ) self._send_client_event(ce) def is_listening(self) -> bool: - return self.ctx.listening and not self.ctx.is_responding() + if not self.synchronized: + return False + if self.server_ctx.is_server_responding(): + return False + if self.server_ctx.output_buffer.is_speaking(): + return False + return self.server_ctx.listening def _create_session_state(self) -> SessionState: e = self.connection.recv(timeout=self.conf.session_created_timeout, timeout_error=True) se = ServerSessionCreated(**e) - return SessionState(self.ctx, se) + return SessionState(self.server_ctx, se) + + def update_session(self): + session_obj = self.get_session_obj(self.vad_mode) + ce = SessionUpdate( + session=session_obj.model_dump(exclude_none=True), + ) + ce.session.instructions += "\n*ALWAYS RESPOND WITH AUDIO*" + self.logger.debug("update session: %s", repr(ce)) + self._send_client_event(ce) def synchronize_server_session(self): if self.synchronized: return - previous_item_id = "" count = 0 - ce = SessionUpdate( - session=self.get_session_obj(), - ) - self._send_client_event(ce) + self.update_session() + self._hack_openai_realtime_beta() + previous_item_id = None + for msg_id in self.server_ctx.history_message_order: + message = self.server_ctx.history_messages[msg_id] + if not message.content: + continue - for msg_id in self.ctx.history_message_order: - message = self.ctx.history_messages[msg_id] - self._send_message_to_server(message, previous_item_id) - previous_item_id = message.msg_id + ok = self.add_message_to_server(message, previous_item_id) + if ok: + previous_item_id = message.msg_id self.logger.debug("Synchronizing server session with item %s", msg_id) count += 1 self.logger.info("Synchronizing server session done with item %d", count) + self.synchronized = True + + def _hack_openai_realtime_beta(self): + ce = ConversationItemCreate( + item=MessageItem( + role="user", + type="message", + content=[ + Content( + type="input_audio", + audio="EgAXABMAFwAUABgAHQAeAB8AIAAiACMAJgAoACYAJgAmACUAJgAkACIAHQAYABkAHAAcABsAGgAaAB0AHgAeAB4AHAAeAB4AHwAhACEAIAAeAB8AIwAiABwAGQAbABoAGgAYABYAFgATABMAFwAZABcAFgAXABsAIQAhACAAIAAfAB8AIQAhAB0AFwATABIAFAAUABAAEwAQABEAFQAWABgAFwAXABkAHAAeAB4AHQAbABkAHAAbABkAFQAOAAwADgAQAA8ACwAJAAkACwAPAA8ADQALAAoACgAMAA0ACgAIAAgACAAIAAYABQAFAAMABAAFAAYABAABAAIAAwAGAAYABwAGAAIABAADAAYABQAAAP3/+//8//7/+v/3//b/9f/4//n/+v/5//T/9f/6//r/+v/4//b/8//1//f/+f/4//P/8f/w//D/7v/q/+b/5v/n/+n/6v/p/+j/5f/l/+f/6f/r/+b/5//r/+r/6f/n/+b/5f/j/+T/5f/l/+T/4P/g/+D/4P/h/+D/4v/i/+H/4//n/+P/4P/f/97/4P/e/97/3P/b/9v/2P/a/9n/1f/S/9T/2v/b/9r/2P/Z/9n/2f/Z/9z/3P/b/9n/2v/d/9v/2//Y/9f/2f/Y/9z/3f/a/9v/3f/i/+z/7f/s/+//8f/x//L/9P/0//X/9P/2//b/9//3//j/+P/4//n/+P/6//r/+v/6//v/+//7//v/+//8//v//P/8//z//P/8//3//P/9//3//f/8//7//f/9//3//v/+//7//v/+//7//v////7//v/+//7////+//////////7//////////////wAA////////AAAAAAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAAD//wAAAAAAAP//AAAAAP//AAD//wAA//8AAP//AAAAAAAA//8AAAAAAAD//wAAAAD//wAA//8AAAAA/////wAA//8AAP//AAAAAP//AAD//wAA/////wAA/////wAA/////wAA//8AAP////8AAP//AAAAAP////////////8AAP////8AAAAA///////////////////////////+//////////7////+/wAA/v///////v///////////////v////////////7////+///////+/////v/+/////v////7//v////7////+//7////+/////v/+/////v/////////////////////////////////////////+/wAA//////////////////////////////////////////8AAP///////////v///////////wAA//8AAAAAAAD/////AAD/////AAAAAAAAAAAAAAAAAAAAAP////8AAAAAAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgABAAEAAAABAAAAAQAAAAAAAQABAAAAAQABAAEAAgADAAMAAgACAAIAAgABAAIAAgACAAIAAgABAAIAAgACAAIAAgACAAIAAwACAAMAAwADAAIAAQABAAEAAQABAAAAAAABAAEAAQABAAEAAQABAAIAAQACAAIAAgABAAEAAgACAAEAAQABAAEAAQACAAEAAQABAAAAAAABAAIAAgABAAIAAwACAAIAAwADAAMAAwADAAIAAwACAAIAAwACAAIAAwACAAEAAgACAAIAAgACAAIAAgACAAIAAwADAAIAAgACAAEAAgABAAEAAQABAAIAAgADAAMAAwACAAMAAgADAAQAAwACAAMAAwADAAQABAAEAAUABAAEAAQABQAEAAUABQAFAAQABQAEAAMABQAFAAUABQAFAAUABAAEAAQABAAFAAUAAwAEAAUABQAFAAYABgAFAAcABgAFAAYABgAGAAUABgAGAAcABwAIAAcABAAFAAQAAwADAAMAAgADAAMAAwADAAMABAAFAAYABgAGAAUABQAFAAQABAADAAMAAwADAAEAAQACAAIAAQABAAIAAgABAAEAAQACAAIAAwAEAAMAAgACAAMAAgACAAIAAQACAAEAAgABAAEAAQABAAIAAAAAAAAA/v/+//7//v/9//z//f/8//7//////////v8AAAAA/////wAA//8AAAAAAAAAAP7///////z//f/8//r/+//7//r/+f/6//n/+P/6//r/+v/6//r/+//6//v//P/7//v/+//7//r/+v/5//j/9//3//f/+P/5//j/+v/5//f/+f/3//f/9//3//b/9f/0//T/9P/z//T/9f/0//P/9f/1//X/9P/z//P/9P/1//X/9f/1//b/9f/0//P/9P/0//L/8v/y//L/8f/y//P/8v/y//P/8//y//D/7//u/+7/7P/t/+3/7v/t/+7/7f/s/+z/7P/s/+7/8f/y//P/8//1//b/9P/1//X/8v/w/+3/6//r/+r/6//s/+z/7P/s/+3/7P/t/+//7//w//L/8f/x//D/8f/y//H/8f/x//D/8P/x//H/8v/v/+7/7v/v/+7/7f/u/+//7v/t/+7/7f/t/+3/6v/r/+3/7v/v/+//8f/w/+7/7//x/+//7f/t/+z/7P/t/+z/7v/u/+7/7v/w//D/7//u/+7/8v/y//D/7//y//D/8P/y//H/8v/x/+//7//w/+//8f/y//L/8v/x//H/8f/v/+7/7v/w/+7/7f/u/+3/6v/s/+//7//w//L/8v/x//L/8f/y//H/8P/y//H/7//u/+v/6v/r/+v/6v/t/+7/7P/u/+7/7f/w/+7/7f/v/+7/7P/r/+v/6//q/+v/6f/o/+r/6v/r/+3/7v/r/+n/6v/q/+f/6f/m/+X/5f/l/+b/5v/o/+r/6//r/+z/7f/r/+v/7v/t//H/8f/x//T/9P/y//P/9P/0//T/9P/z//b/9f/z//L/8//y//D/7f/r/+v/7P/s/+3/7//w//P/9P/2//n/+f/5//v/+f/5//n/9v/4//r/+//7//3//P/9//v/+v/8//7//v/+//3//P////7///8AAAMABAAFAAcABAADAAMAAwAFAAUABwAIAAgABwADAAIAAQACAAIAAwABAAIAAQABAAAAAwAGAAoADAANAAwACQAHAAgABwAKAA8ADQANAA0ADQAMAAsACwAMAA4ADgAPABEAEQARABUAFgAVABYAFgAXABQAEQASABEAEQASABIAEAAPABEAEAASABYAGQAZABkAGQAVABYAFgAXABoAGQAaABkAFwAVABUAFAAVABcAGAAYABgAFwAXABcAGQAaAB0AHgAeAB8AHwAcAB8AHQAdAB4AHwAeABwAHQAcAB0AHQAbABsAGwAbABwAGgAYABkAGQAbABsAGwAbABoAGwAaABkAFwAaABoAGwAbABkAGAAZABgAFwAYABUAFQAWABUAEgATABQAFAATABMAFAATABIAEwATABIAEgAVABQAFAATABIAFQAUABYAGQAYABgAGAAWABMAEgAVABUAEwATABMAEAAQABAADwAMAAoACgAJAAwACwAMAAwADAANAAsADAALAAsACgAKAAoACAAGAAQAAQABAAMABQADAAUABQAFAAcABgAGAAYABQAEAAUAAwACAAMAAgACAAQAAwADAAUABQABAAEAAgAEAAMAAwADAAMAAQABAAIAAAAAAAEA///+//v/+v/3//X/9P/z/wUABgAFAAUABQAFAAQABQAFAAcABwAFAAYACAAGAAUABAAFAAUABQAEAAMAAgAAAAAAAAAAAAIAAgAAAAEAAQABAAAAAAAAAAEAAAAAAAAAAAABAAMAAgABAAEAAQABAAAAAAACAAAA//8AAAAA///9//7//f/9//z//P/8//r//P/7//v/+//7//r/+//9//z//f/+//7//v/9//3//f/9//r/+P/6//n/9//2//b/9f/0//T/9f/2//b/+P/7//f/9//4//n/+f/5//j/+P/5//v/+//6//v/+v/5//n/+P/0//L/8v/x//L/8f/y//H/8P/w/+//8P/y//D/8v/y//P/9P/z//L/8P/y//L/8P/y//P/9P/0//X/8//z//L/8f/x//H/8v/w//D/8P/t/+7/7v/s/+z/7f/t/+r/7P/s/+3/7//v//P/8//z//P/9P/z//L/9P/y//P/8v/x//H/7v/t/+z/6//t/+7/7f/v/+7/7f/v/+7/7v/v//D/8v/y//H/9P/0//P/8v/w/+//7//w/+//7//w//D/8v/y//L/8P/w//D/8f/x//H/8P/w//D/8P/y//P/8v/y//D/7v/v/+//8v/z//P/9P/y//P/8v/z//T/8//z//T/9f/1//P/9P/2//X/9f/2//f/9v/4//j/9//2//f/9//5//r/+v/6//r/+v/7//r/+P/5//n/9//3//b/9v/0//b/+P/7//v//P/9//7//f/9//7///8AAAAAAQABAAAAAAD///7//P/7//3/+//9//3//P/9//3//P8AAAAAAQABAP///v/+//3///8AAAAAAAAAAAEAAgACAAQABQAGAAUABQADAAMAAwAAAP//AAD///7/AQACAAIAAwACAAIAAAABAAIAAQAAAAMABAACAAMAAwAEAAIAAQABAP///f/+//////8AAAAAAAABAAIAAgADAAQAAwAEAAMAAgADAAIAAQACAAMAAgAFAAIAAAADAAEAAQADAAIAAAAAAAAAAgADAAMABQAFAAcABwAGAAcABQAFAAQAAgACAAEAAQABAAEAAQADAAEAAQAAAAAAAAADAAQABQAGAAcABQAFAAYABwALAAsACgAKAAkACAAHAAYABgAHAAYABgAIAAkACQAKAAsACwALAAwADQANAA4ADwAPAA0ADAAKAAoACQAJAAoADQALAA0ADQAMAAwADAAOAA4ADgAPABEAEAARABIAEgARAA8ADgANAA4ADAAJAAgACAAIAAoACwALAA0ADAAMAA4ADwAOABAAFAAVABcAFgAVABQAEgASABMAEgASABAAEQARABEAEAARABAADwARABMAFQAWABcAFwAXABYAFQAUABMAEQARAA8ADwAOAA4ADgAQABEAEgATABMAEwATABEAEAARABAADwAQABAAEAAOAA0ACwAJAAUAAwAEAAQABQAFAAcACAAMAA4AEAAPAA8AEQAQABAAEAAOAA0ADAALAAoACAAIAAkACAAIAAgACAAHAAYABQAFAAYABgAHAAkACQAIAAgABQAEAAMAAwABAAAAAAAAAAEAAAAAAAEAAQABAAEAAgABAAEAAAAAAP///v8AAAAA/v8AAAAA//////3//f/7//r/+//7//z/+//9//3//P/7//v/+f/6//n/+P/3//b/9//2//T/8v/y//P/8f/x//L/8v/1//b/9P/0//T/8v/z//T/9f/0//T/9//2//T/9P/z//L/8v/y//P/8v/0//T/8//z//H/8f/v//H/8f/x//L/9P/2//b/9//2//P/8//y//L/8v/x/+//7v/u/+3/7f/u/+//8v/z//T/9P/1//X/9P/1//X/9P/0//T/9P/z//L/8P/w//D/7//u/+7/8P/w//D/8f/x//L/8v/y//H/8//y//H/8f/v/+//7v/u/+7/7P/r/+3/7//u/+7/7f/u//H/8P/w//L/8//0//T/9P/1//P/8//z//H/8P/v/+3/7P/s/+v/6//r/+3/7f/v/+//8P/y//L/8v/0//T/8//1//T/8//z//P/8v/y//H/8f/x/+//8f/w//H/8P/x//H/8f/x//L/9P/1//X/9f/0//T/8//y//L/8f/y//H/7//v//D/7v/u/+3/7f/u/+//7//x//P/8//2//f/+P/5//r/+f/4//n/+v/2//T/8//y//H/8f/x//H/8f/y//T/9P/0//b/9v/3//j/+P/6//j/+f/5//n/+P/4//j/+P/3//f/+f/4//n/+v/6//v//P/+//7//v////7//v/+//7//v/+//7/AAABAAIAAgABAAEAAAAAAAAA///+//7//f/8//3///8AAAIABAAFAAcABwAHAAcABgAGAAYABgAFAAQABQAEAAUABAADAAUABQAGAAgABwAIAAgACQAJAAgABwAHAAcACAAHAAkACQAIAAkACgALAAsACwALAAwADAALAAsACgAHAAcABwAGAAUABwAHAAcABwAIAAcACgAIAAcACAAKAAkACQALAAsACwALAAwADAALAAwADAAKAAoACgAIAAcABwAGAAYABgAHAAcACAAIAAoACQAJAAgACAAHAAYABwAHAAcABgAHAAgACAAJAAgACQAIAAkACQAJAAcACAAIAAgABwAHAAcACAAHAAYABgAFAAMABAAEAAUABAAGAAYABgAFAAYABgAGAAYABgAFAAcABgAFAAcABgAFAAQAAgABAAEAAQABAAEAAQAEAAUABAAFAAQABAAEAAMABAAEAAMABQAHAAYABwAJAAcABwAJAAYABAADAAIAAgAAAAAAAAAAAAAAAAACAAEABAAEAAQABQAHAAcACAAHAAUABgAGAAYABwAGAAUABAADAAUABQAEAAUABQAFAAIAAgACAAEAAQAAAAEAAwADAAQAAgADAAMAAwACAAIAAgACAAEAAQACAAAAAwAFAAQABAAGAAcABwAHAAcABgAGAAYABgAFAAUABQAFAAUABgAGAAgABwAIAAcABgAFAAQAAgABAAAAAQAAAAAAAgAAAAAAAAABAAEAAQACAAMABAAEAAMAAwAEAAUABAAFAAQABAAEAAIAAAAAAAAAAAAAAAAAAAD//////f/+/wAA//8AAAAAAAABAAIAAwAEAAMABAAGAAQAAwAGAAYABQAGAAUAAwADAAMABAADAAIAAQABAAEAAQAAAAAAAgABAAEAAQAAAAAAAAD+//7//f/9//z//P/8//7//v/+////AAAAAAEAAQABAAAAAAABAAAAAQABAAAA//8AAAAA//8AAAAAAAABAAEAAgABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAD+//////8AAAAA///////////+//3//v/+////AAAAAAEAAgAEAAQABAAFAAcACAAHAAYABwAGAAUABAAEAAQABAABAAAAAAD+/wAAAQAAAAEAAAAAAAAA////////AAD9////AAD+/wAAAAD///7//v/+//3//v//////AQAAAAEAAgAAAAEAAQABAAMABAAFAAUAAwABAAIAAAAAAP///f/8//v/+f/6//v/+f/4//n/+v/7//z/+//8//z//v/8//7////+//3//f/8//v//P/8//z/+v/7//3//f/9//z//f/7//v//f/8//z/+v/3//j/9//3//f/9f/0//T/8//1//T/9P/2//b/9v/1//b/9v/0//X/9//1//b/9f/z//P/8v/x//L/8P/w//D/8f/w/+//8P/w/+//7//u/+7/7//s/+z/7v/t/+//7v/s/+//7f/r/+3/7P/u/+//7v/t/+//7f/s/+z/7P/r/+z/6v/r/+v/6v/r/+r/6v/q/+v/6//q/+v/6//s/+3/7v/w//D/8f/z//L/8f/y//P/8//z//P/8v/0//T/8//0//X/9v/2//X/9f/1//T/8//z//T/8f/y//P/8v/y//P/8//z//L/8v/z//T/8//y//L/8v/y//T/9P/0//P/9P/2//f/+P/4//r/+v/8//z//f/8//3//P/8//3/+v/5//n/+v/7//v/+//7//z//v8AAAMABQAGAAcABgAIAAYABgAFAAUABAACAAIAAAAAAAAAAAAAAAAAAAAAAAEAAAADAAMABQAGAAcABwAIAAkACgAJAAgACAAIAAcACAAGAAYABwAHAAgABwAHAAoADAANAA8ADgARABEAEQATABEAEAAOAA8ADwAPAA0ADAALAAkACQAIAAkACQAJAAsADAAOABAAEAARABIAEQARABAAEgATABEAEQARABIAEwASABMAEwARABIAEgASABEAEwARABEAEgARABMAEgARABEAEAAPABAAEAAQAA8ADgAOAA8AEAAPABIAEwATABQAFQAUABUAFQAVABcAFgAYABcAFwAWABQAFAASABEAEQAQAA0ADQAOAA0ADwAOAA8ADwAOABEAEQARABMAEwATABMAEQASABEAEAAPAA4ADgAMAAwADAALAAoACgALAAsADAAKAA0ADwANAA0ADgAOAA0ADAAMAAwACwALAAsACgALAAsACgALAA0ADQAMAA0ADgAOABAAEQASABMAEwATABIAEgAPAA0ACwAJAAcABwAGAAYABgAGAAcACAAIAAoADAANAA0ADAALAAwADQAMAAwACwAJAAgABwAGAAYAAwACAAMAAgADAAMAAwAFAAcABwAIAAkACQALAAsACgAKAAoACQAJAAcABwAIAAcACAAHAAcACAAIAAgACAAGAAYACAAJAAoACQAKAAkACQAIAAgABQAGAAQAAwABAAAA/f/8//z/+//8//3//P/+//7//f/+////AAABAAEAAgAAAAEAAwACAAEAAgABAP///v////7//v/+/////////wAA/v8AAAAAAAABAAAA/v////7//v/8//v/+//6//v/+f/5//r/+v/5//j/+P/4//j/+v/7//v/+//9//3//f/+///////+/////f/8//v/+f/5//n/+f/5//n/+f/4//b/9v/2//j/+P/5//v//P/9//z/+//7//r/+v/4//f/9//3//b/9P/1//T/8//z//T/9P/0//f/9//2//b/9//3//f/9v/2//b/9f/0//T/9f/4//f/+P/4//j/+//9//v/+v/7//r/9//2//b/9//3//n/9//3//f/9v/2//b/9v/2//X/9P/3//j/+P/4//f/9//2//X/9P/2//b/9v/2//b/9f/2//j/9//2//b/9v/3//b/+P/4//f/+P/4//j/+P/3//f/+P/4//j/+P/5//j/9v/3//f/9P/3//j/9v/4//r/+//6//v/+//6//n/+f/6//n/+v/6//n/+f/5//r/+//7//z//v////7///////7//v/9//z////9//v//P/7//r/+f/3//f/9//2//f/9//1//j/+v/7//r/+//9//3///8AAAAAAAAAAAAAAAAAAP///v/7//r/+//5//j/+P/5//j/9//6//v/+v/7//3//f/+/////f///wAA//8AAP/////8//z/+v/5//n/+v/7//v//P/9//3///8AAAAAAAAAAAAA//8AAAAAAAAAAAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD//wAAAAAAAAAA///+//7//P/7//z/+//8//z//v//////AAAAAAEAAgADAAIAAAABAAAAAAD//wAAAAAAAAAAAAAAAAAAAAABAAEAAgACAAAAAQACAAEAAgACAAEAAgACAAEAAgAEAAQABQAFAAYABQAGAAMAAgABAAEAAAD+//3///8AAAAAAAAAAAEAAgABAAIABQAFAAMABQAFAAQABQAFAAIAAQABAAAA///+/wAAAgACAAIAAwAFAAYABQAHAAcABwAIAAcABQAFAAMAAwABAP//AAABAAAA///+////AAAAAAEAAwAEAAQABAAEAAQABAAFAAMAAgABAAEAAgAAAAEAAgACAAMAAgABAAEAAQAAAP///f/9////AAABAAMAAgADAAQABAAGAAQABAADAAIAAwACAAIAAwABAAEAAQAAAAAAAgABAAAABAAFAAYABwAGAAYABgAGAAYABgAGAAYACAAFAAYABgAFAAUABgAGAAUABQAHAAYABQAFAAUABQAEAAUABgAGAAUABQAEAAUABAAEAAMABAAFAAQAAwAFAAMAAwAEAAQABAACAAMAAwABAAIAAgABAAMAAgADAAMABAAEAAUABgAFAAUABgAHAAUACAAHAAcABwAGAAcACAAHAAoACAAHAAcABwAHAAcABwAHAAcABwAGAAgACAAHAAgACQAIAAoADQAMAAoADAANAA0ADAALAAgACQAJAAgACAAHAAoACQAIAAoACgAJAAsACwAKAAwADQANAA0ADAAKAAkACQAIAAkABQAFAAYABgAGAAcABgAFAAQABgAGAAYABgAJAAkACgALAAoACgAJAAkACQAJAAcABgAGAAYABQAGAAYABgAIAAkABgAFAAYABgAHAAYABgAHAAkACgAJAAkACgAKAAoACgAJAAgABwAGAAYAAwADAAQABAAEAAUABQAEAAUABAAEAAUABQAEAAQAAwACAAIAAgAAAAAA///+////AAABAAEAAwAEAAQABAAEAAMAAgACAAMAAQAAAAAAAAD///7///////3//v////7//v8AAAEAAQACAAIAAgACAAMAAwAEAAEAAAD/////AAD+//3//P/9//3//f/9//3//f/+/wAAAAABAAEAAQACAAMABAADAAMAAgAAAAAA///+//7//v/9//7////+//7//v/+//////////7///8AAAAA/v/9//7//v/+/wAA/////wAAAAAAAAAA//8AAP///v/8//3//P/8/////v/9/////f/7//3//f/9//7//f///////v///////v////7//v/9//z/+//6//r/+v/7//j/+P/4//j/+f/5//v/+//8//3//f/8//3//f/9/////v/+/////v/9//7//f/8//z//f/+//r/+P/4//j/9//5//f/9v/3//n/+v/8//z//P/6//r/+//6//v/+v/8//z/+P/4//j/+f/3//f/+f/4//f/+f/7//v/+v/7//z//P/6//r//P/7//v/+//6//n/9//2//f/9P/z//T/8P/v//H/9P/y//T/9//2//n//P/7//v//P/8//z//P/4//b/9v/2//T/9P/y//P/8P/w//H/8P/w//H/8//0//X/9v/3//n/+v/8//v/+//6//z//f/6//r/+v/5//f/+f/5//n/+v/7//v/+//9//7///8AAAIAAwADAAMAAQD///7//v/9//3//v/8//7//v///wEAAAAAAAEAAAAAAAAAAQACAAQAAgADAAMAAQAEAAQAAgADAAQAAwABAAIAAQAAAAIAAgABAAEAAAAAAAEAAAACAAIAAgAEAAMABAAEAAMABAACAAIAAAABAAMAAgACAAIAAQABAAIAAgADAAYABgAHAAYABQAHAAcABgAGAAQABAADAAMAAwADAAMAAwAFAAQAAgAEAAQAAwAFAAQABAAFAAUAAwADAAUABAAFAAQABQAEAAMAAwADAAIAAAAAAAAA//8AAP//AAABAAEAAwAEAAUABgAFAAUAAwAEAAMAAwACAAEAAQAAAAEAAQABAAIAAQAAAAEAAAAAAAMAAwABAAEAAgAAAP//AAAAAP3//v////z/+//6//n/+//9//3//f/8//z//f/+/wAA/v/8//3//P/9//z//P/8//v/+//6//v//P/7//r/+v/7//r/+//7//r/+f/5//n/9//4//j/9v/2//j/+P/3//n/+v/6//r/+f/5//n/+f/6//v/+v/6//z/+v/5//r//P/+////AAAAAAEAAAAAAP3//v////z/+//6//r/+//6//z/+//8//z//P/9//3//v////7///8AAP///v/9//3//P/7//v/+f/6//z//f/8//7//v/+/////v/+//7////+//7//v/////////9//3//f/7//v/+v/8//z//P/8//z//v///wAAAAAAAAIAAQAAAAAA///+//3//f/9//v/+//6//n/+f/5//v/+v/6//v/+//6//v/+//7//z//f/8//3//P/9//z/+//5//n/9//3//f/9//1//P/8f/w//D/8v/y//P/9f/2//T/9f/4//f/9f/1//T/8v/w//D/7v/u/+7/6v/p/+r/6v/q/+z/7P/s/+z/7f/t/+3/7P/t/+r/6v/p/+f/5//n/+f/6P/n/+f/5//n/+f/5v/k/+P/4//h/+L/4f/g/+D/4v/h/+P/4//j/+H/4f/j/+H/4P/g/+H/4f/g/+D/4f/g/+D/3//g/9//3f/e/+D/3v/f/+L/4v/j/+P/4v/i/+L/4v/i/+D/4f/g/+D/3//f/9//3//f/9//4f/i/+P/5P/l/+X/4//k/+T/4v/k/+T/5f/l/+b/5//o/+n/6f/r/+z/7f/u//D/7//u/+7/8f/w//D/8v/z//P/9P/z//X/9f/0//X/9v/2//j/+v/6//v//f////3//v8AAP7//v///wAAAAAAAAAAAQD//wAAAAABAAAAAAACAAIABAAGAAkACgALAAsACwALAAsADQANAAoACQAKAAkACgAJAAoACwAMAAwADwASABIAFQAXABgAFgAXABcAFQAUABUAFAASABMAEwATABMAFQAWABcAFgAVABYAFwAVABYAFwAXABgAFwAXABgAGQAYABgAGgAbABwAHgAeABwAGwAcABsAGQAYABcAFwAYABgAGgAbABwAHQAeAB8AHQAdAB8AHAAcABsAGwAZABoAGgAaABoAGQAYABoAGQAYABkAGQAbABsAGwAbABwAGwAdAB0AGgAaABoAGwAbABwAHAAcABsAGgAaABkAGQAYABYAFAAUABUAFAAUABUAFQAXABgAGAAZABcAGAAYABcAFwAWABUAEwAUABQAFQAWABcAFwAYABcAFwAZABkAGgAcAB0AHgAbABwAGwAZABkAGgAaABkAGgAZABkAGQAZAB0AGgAXABgAGQAZABkAHAAdAB8AHwAfACAAIAAgACAAHwAgAB4AHQAeABwAHgAeABwAHgAeAB4AHgAdABwAHgAdABsAGwAbABgAGgAaABoAGwAbAB0AHgAeAB4AHwAhAB8AHgAcABoAGgAZABkAGgAbABoAGQAaABoAGgAaABwAHAAaABkAFwAWABQAEgARABEADwAPABAAEAAPABAAEQARABIAEgARABEAEQASABEAEQAQAA4ADQAMAAsACQAIAAgABwAFAAUABQAFAAUABQAFAAUABQAGAAYABgAFAAQABAADAAEAAAD///3/+//6//j/+f/3//j/+f/5//j/+//7//v//P/7//v/+v/5//f/9v/2//T/8//z//L/8v/x//D/8P/w//D/7v/v/+//8P/v/+7/7P/s/+v/6f/q/+j/6P/n/+X/5f/m/+X/5P/m/+b/5v/m/+T/5P/i/+P/4//i/+T/4v/i/+L/4f/h/9//4P/h/+D/4f/g/+L/4v/h/+L/3//g/+L/4v/g/97/3v/d/9z/3P/b/9r/2v/c/93/3v/e/9//3//f/+H/4//h/+L/4//h/+H/4P/e/97/3//c/9//4P/f/+H/4v/k/+T/5f/l/+X/4//k/+P/4//i/+L/4//j/+T/5P/l/+T/5v/m/+X/5P/k/+L/4//k/+b/5v/m/+j/6f/o/+n/6//q/+z/7f/u/+//7//u/+7/7v/v/+//8P/w/+//7//u/+7/7//v//D/8f/y//L/9P/1//f/+P/3//f/9//5//j/9//3//f/9//4//n/+P/4//j/+//5//r/+//8//z//P/9//3//v8AAAAAAAAAAAAA///9//7//v/+//3//v/+//7//v/+//7//v/+/////f/9//7//v/+//////////7//v///wAAAAAAAAIAAgACAAMAAwAEAAQABAAEAAYABgAFAAQABAAEAAIAAgADAAUABAAEAAQABAAFAAYABAAGAAkABwAJAAoABwAIAAgABwAJAAgACQAIAAcABgAHAAkACQAJAAkACgAMAAsADAALAAwACwALAA0ADAAOAA8AEAASABMAEwARABEAEQAPABAAEAANAA0ADQAMAA4ADwAPABAAEQARABAAEQAUABQAFAATABUAFwAUABMAFAAUABQAFAATABMAFAAUABQAFQAWABcAGQAaABoAGgAZABsAHQAcABsAGwAbAB0AHQAcAB0AHAAbABsAGgAaABsAGgAWABgAGAAWABYAFgAVABYAFwAYABgAGQAaABsAHAAbABkAGAAXABYAFQASABIAEgASABMAFAAVABQAFgAXABgAGQAaABwAHAAcAB4AHQAbABoAGgAaABgAGgAaABoAGQAaABkAGgAbABkAGAAZABgAFwAYABgAFwAZABkAGAAYABkAGgAaABoAGgAbABoAGAAZABcAFwAWABUAFQATABIAEQASABMAEwASABQAEwATABMAFAAVABMAEgASABAADwAPAA4ADwAPAA0ADgANAAwADQALAAsADAAMAA0ADQANAA8ADAAMAA0ADAALAAsACgAIAAoACQAIAAkABwAHAAYABwAHAAcABwAIAAsACQAJAAwACwALAAsACQAIAAgACAAIAAgABwAHAAgABwAGAAcACAAKAAwACwANAA4ADQANAA0ACwAMAAsADAAMAAwADAANAA0ACwALAAoACQAIAAcABgAHAAkACAAKAAwACQAJAAoACQAHAAYABgAEAAQABAAEAAQABQAEAAQABQACAAIAAQAAAP////8AAP7//f/+//z//f/8//r/+//5//r/9//1//X/8//y//H/8f/w//D/7v/v/+//7v/u/+7/7v/t/+7/7P/s/+z/6//q/+r/5//l/+X/5f/j/+H/4f/h/+H/3//f/9//3v/e/97/3//e/93/3v/c/9v/3P/Z/9r/2f/Z/9r/2v/a/9v/3f/d/97/4P/h/+L/4P/g/+D/3//e/9//4P/f/97/3v/f/+D/4P/i/+P/4//i/+T/5P/l/+X/5v/l/+X/5v/m/+b/6P/p/+j/6P/n/+n/6P/m/+X/5v/m/+b/5//o/+j/6P/p/+j/6P/p/+n/6P/o/+j/6P/m/+X/5v/m/+T/5f/m/+T/5P/k/+X/4v/j/+P/4//j/+L/4f/h/+L/4f/j/+H/4v/j/+P/4//k/+X/5f/k/+T/4//i/+H/4P/f/+H/4f/g/+L/4f/h/+P/5P/k/+X/5//q/+n/6f/o/+n/5//n/+f/5v/n/+j/5//o/+j/6f/o/+j/6f/q/+z/7P/r/+z/7v/t/+3/7f/v/+//7//v/+//7v/u/+//7//x//P/8//z//P/9P/0//T/9f/1//X/9f/2//f/9//1//j/+v/6//z//P/7//r/+f/4//n/+//8//v/+v////7//f///wAAAAABAAAAAgAEAAIABAADAAIAAwADAAEAAwADAAIAAwABAAIAAwABAAAAAAABAAIAAAABAAEAAAAAAAAAAAD//////P/7//v/+//7//j/+f/6//r/+P/5//n/+f/4//f/+f/4//f/+P/4//j/+P/4//n/+P/4//r/+v/4//j/+P/2//X/9P/z//H/8v/y//L/8//0//L/8//1//X/9v/2//X/9//4//j/+f/7//3//v/9/////v/+/wAAAAABAAIAAwAFAAYABgAHAAkACAAJAAwACgAIAAoACQAIAAkACwANAA4ADgARABEAEwAUABYAFgAWABcAFwAXABcAFwAXABcAFwAXABgAFgAYABkAGQAbABsAHAAeAB8AIAAhACAAIAAiACEAIgAhACEAIQAgACEAIQAhACEAIgAhACEAIQAgACEAIgAiACIAIgAjACMAJQAlACUAJwAlACQAIwAjACQAIgAiACAAIAAeAB0AHQAbABsAHAAcAB0AHAAdAB0AHAAcAB0AGwAbABwAGQAXABcAFAASABAADgANAAsACQAJAAoABwAGAAcABQAEAAQABAAGAAQAAwAEAAEAAAAAAAAA//////7//f/6//r/+//5//v/+//5//v/+//7//z//P/8//3//v/9//z//P/8//7//f/8//3//f/8//7//f/+/wAA//8AAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAABAAQAAwAEAAYABwAJAAoACgALAA0ACwAMAAwACgAIAAcACAAIAAgACgAIAAgACgAKAAwADAAMAA4ADwAOAA4ADwAPABAADQAPAA4ADAANAAsACQAIAAcABwAGAAYABQAGAAcABgAHAAkACAAKAAoADAAMAAsADQAMAAkACAAIAAcABgAGAAQAAwADAAMAAgACAAMAAwACAAMAAwADAAQAAwAFAAUABAAEAAMAAgABAAIAAgAAAAAA//8AAAAAAAAAAAEAAQABAAAAAAAAAAIAAAABAAIAAgABAAEAAwABAAEAAQAAAAIAAgADAAIAAwABAAQABAAEAAQABgAGAAUABgAHAAYABwAHAAYABwAGAAUABgAEAAMABQADAAQAAwAFAAYABwAIAAkACAAHAAcABwAGAAcACAAHAAcABwAIAAcABgAGAAUABAAFAAMAAwADAAUABwAJAAkACAAHAAgACAAIAAcACAAFAAUABQADAAAAAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP///v/9//7//f/7//v/+//6//n/+P/4//n/+P/4//j/+f/5//f/9//3//b/9//3//f/9v/1//T/8//y//H/8f/y//H/8v/w//D/7//w/+7/7//w/+//8P/v/+7/8P/v/+7/7f/s/+3/6//q/+v/6//p/+n/6f/p/+n/6f/p/+v/6v/r/+3/6//s/+3/7f/t/+r/6f/q/+j/6P/o/+j/6P/m/+j/5//o/+n/6f/p/+j/6P/q/+z/7f/v//D/8f/x//D/8P/u/+z/6//q/+v/6v/p/+j/6f/n/+f/6P/p/+v/7P/r/+3/7v/v//D/8f/x//P/8v/y//L/8P/x//D/7//w//H/8f/x//H/8f/0//T/9P/0//T/9P/0//L/8//1//X/9P/0//P/8v/z//H/8//0//P/9P/0//X/9v/0//P/9v/3//X/9v/4//f/9//3//X/9v/3//b/9//1//X/9P/1//b/9f/2//X/9v/1//X/9P/1//b/9//3//f/9f/2//f/9v/3//b/9//3//f/+f/4//j/+f/5//n/+P/6//r/+//6//z/+//6//z///8AAAAAAAABAAIAAgABAAIAAwAEAAQABQAFAAUABwAIAAgACQALAAwADAANAA8AEQAQABAAEQARABIAEwATABQAFQATABQAFAATABUAFQAUABYAFwAWABcAFQAVABYAFwAXABkAGgAYABoAGgAZABoAGwAaABoAGgAaABsAGQAZABkAGQAYABgAFwAXABcAFwAYABkAGQAZABgAGAAYABkAGgAZABoAGgAYABgAFwAXABYAGAAXABgAGAAXABYAFQAWABMAFAAVABUAFgAUABUAFQAVABUAFQAUABQAFQAVABUAGAAXABcAFwAWABcAFAAVABUAFAAVABYAFQAWABYAFQAVABQAFAAUABUAEwASABMAEwATABMAEwATABQAFAAUABYAFgAWABYAFQAUABEAEAAPAA8AEQARABIAEgATABUAFgAXABYAFwAXABgAGQAXABgAGAAYABcAGAAVABQAFQAUABUAFQAUABUAFAAUABYAFgAXABcAGAAYABkAFwAXABUAFAATABIAEgASABIAEgASABAAEQASABMAFAASABAADwAOAAwADQANAAwACgAJAAwADwANAAsACwAKAAsACQAHAAkABgAFAAMAAgAAAP/////+//z/+//7//v/+v/5//v/+v/6//7//f/9//3//f/9//z/+v/5//j/9f/1//T/8f/x//D/7//w//H/8v/z//P/8//z//L/8f/w//H/8v/z//T/8//y//H/7v/u/+z/6//s/+v/6//t/+7/7//v/+//8f/y//H/8f/x//H/8P/w/+//7f/s/+z/6f/p/+r/6v/q/+v/6//p/+3/7P/t/+z/7P/u/+7/8P/w//L/8v/x/+//7f/t/+7/7v/w//L/8v/x//P/8//w//D/7v/s/+r/6//r/+z/7f/v/+7/7//x//P/8v/x//H/8P/u/+3/7v/v/+3/7f/s/+r/6v/r/+z/6//q/+n/6P/k/+P/5P/l/+X/6P/r//D/8P/x/+//7//v/+z/6//t/+z/7P/r/+n/5//m/+b/5//m/+j/5//m/+T/5f/o/+j/5//n/+f/5//p/+j/6//t/+3/7P/q/+j/5v/j/+P/5f/n/+v/7v/w//D/7//v/+//8P/y//T/9//6//v//P/9//r/+f/5//n/+f/8//3//P/8//r/+f/5//v//v8BAAIAAgACAAAA/////wAAAgAEAAcACAAIAAgACAAIAAgABwAHAAcABgAGAAoADQAMAA8ADwAKAAUAAgAAAAAAAgAFAAYABwAHAAUAAgAAAAIABQAFAAcACAAEAAIAAAD9//3////+//7//f/7//r/+f/3//b/9v/0//X/9v/2//f/9v/4//X/8f/w/+//7//y//X/+f/6//n/+P/1//P/8P/x//L/8f/w/+7/7v/s/+3/8f/0//f/+v/7//r/+v/8//v/+v/7//r/+v/5//b/9v/1//b/9//5//f/9f/0//X/9P/z//L/8//0//X/9//6//z//f/6//n/+P/6//r/+v/7//r/+f/6//7/AQAFAAcABAABAAAA//8AAAMABgAGAAMAAQD+//3/AAAFAAoADAAKAAQAAAD+/wAAAwAIAA0ADwAMAAgABAABAAEAAgADAAYABQACAAAA/v/+////AgAFAAQAAwADAAAA//8AAAEAAwAFAAQABAADAAQABAAEAAYACgALAAwACgAIAAUAAwADAAYABQAGAAUAAgABAP////8AAAEABAAGAAcABwAGAAUAAgAEAAMABAAHAAYABwAFAAQABAAEAAUABAAGAAQAAwACAAEAAwAEAAUACAAKAAkACAAJAAoACgAJAAkABwAHAAMAAAAAAAAAAAABAAQABgAKAAkACAALAAwADgAOAA4ACwALAAkACAAKAAoADAAOAA4AEAARABIAEAAPAA4ADQAPABEAEwARABAAEAASAA8ADAAMAAoACQAHAAkACQAKAAkABgAFAAgACQAIAAgACgAJAAcABgAIAAkACAAGAAcACAAIAAYABAACAAAA//8AAAQABgAGAAcACAAKAAgABQACAAIAAAAAAP//AAABAP//+v/5//z/AQACAAAAAAD///7//f///wAABAAGAAQAAAAAAAEABQAHAAgABQAAAPv/+v/7//v/+//+/wAAAAD//wAABAAEAAMAAgACAAMABgAIAAkADQAPAA8ADwAPABMAFQATABAADQAMAAoACQAIAAQABQAHAAYABwAKAA4ADwAOAAsACgAKAAgACQAKAAoACgAJAAoADAAJAAcACgAOABIAFQAUABEADwATABUAFQAYABcAGQAZABUAGgAeABwAFwAVABUAFgAVABUAFQAUABEADQAOABIAEwAVABYAHQAgABwAGAATABEADgAPABUAGwAbABYAEwASABgAHgAiACIAIQAeABYADwAQABIAFQAYAB4AIQAgABoAEwANAAoACAALAA8ADgAKAAoABAABAAIADQAYABcAEAAJAAUACQALAA8AFQAVAA4ABwAGAAgADgAOAAcAAwAEAAYACwAMAA4ADQAIAAAA+/8AAAYACwAKAAsACwAGAAEAAAACAAEAAAAAAP7//f/9//r/+v///wYADAALAAUABAABAP7//v8BAAUABgAHAAMA/f/7//z//v/9//7/AgADAAMABAABAAAAAQAAAAAA///9//z//v/+//r/+v/8//7//f/7//n/+P/1//T/+v8AAPr/+f/4//T/9P/+/wkABwD9//j/8f/r//f/BgAEAP7//P/0/+//+v8DAAAA9//x/+3/6v/p/+7/8//x/+f/4v/n/+7/8f/0//P/7f/t/+j/3P/f/+j/5v/j//H/AwAKAAwA///m/9P/zv/Z//b/FgAlABgA+v/Z/8j/1v/5/xEAEgACAOP/vP+m/7f/3f8AABAADAD//+z/2f/Q/87/0P/a/+j/8P/v/+v/6//r/+j/7v8AABEACQDg/7n/sP+8/8//6/8QACIAEQDw/+P/7P/5//3//v/1/9//0f/U/97/4f/f/9n/zf/F/8j/2//z/wIAAQD1/+v/5P/g/+L/5P/h/9f/0//R/9D/2P/l/+7/7//u/+j/3//b/9//7P/+/wUABgAHAAgABQAAAPv/6//V/8X/uv+4/8L/2P/q/+7/7P/p/+T/5f/n/+3/9//6//b/9f/1//P/7f/f/8v/wf/D/8n/zP/O/9D/0P/O/8//0f/T/93/6P/u/+//7//t/+b/2P/P/8n/x//L/9P/1//T/83/y//F/7v/u//D/8r/0v/h//L/+f/3//T/7f/h/9r/z//C/8L/zf/X/+T/8v/3/+v/3v/b/9P/zf/a/+7/9f/y/+7/6f/f/9f/1//X/9v/5P/v//v/AAACAAcABwACAPv/8//s/+//+P/7//3/AAABAAIABAAGAAIA+//1//X/9v/2//f///8FAAkADAAKAAEA+P/y//b/AQANABsAJAAeAAwAAAABABEAKAA4ADgALAAdAAwAAwAIABkAJwAlAB4AHAAdAB8AKAAqACEAEQADAAAAAQAVADQASQBOAEMALwAWAAQAAAAJABwANQBHAEYAOAAlABsAGwAbAB0AIAAjACgAKgAiABsAHAAjACoAMQA2AC0AFgAFAAMAEwAyAFMAZQBbAD0AGwD///z/EwAzAEkASwA+ACcAFwAWAB8AJwAwADwARgBMAE8ASQA8ACgAEwARAB8AMwBIAFUAUAA9ACYAGAAXACIANQA+AEAAOQApAB0AFwAYACAALAA4AD4AOwAvACYAIwAkAC0AOAA/ADgAKwAlACEAIwAsADYAPgA/ADYAJgAbABMADgAOABkAJAAnACoALAAkABcAEAAQABQAGAAZABgAFgAaAB8AHQAbAB4AHAAWABAADQATABoAHAAcABsAGwAdACEAHwAbABcAFgATAAsACgAMAAsADAAOABAAEQAQAA4ADgANAAkABgAKAA0ACwAHAAQAAAD//wEABwALAAwACQADAPz/+f/3//r/AQADAAIAAAD///r/8v/r/+n/6//w//T/9//7//3//v/+//r/9//5//z/+//8/////P/+//3//P/7//n/+v/8//3/+//4//X/9f/3//n/9//2//X/9P/1//b/8f/s/+3/7//y//j/+//8//j/9f/0//b/+P/7//3/+v/1//L/8v/x//H/8f/w/+//7//w//D/8//3//f/8//y//P/9f/0//X/9f/1//b/9f/0//b/+f/8///////8//n/9//4//n/+////wEAAQD///z/+v/3//X/9P/0//P/9P/3//X/8P/s/+v/6//u//D/8v/x/+//6//n/+3/8f/w/+//7P/q/+j/6P/o/+7/8f/2//f/8//w/+v/6P/o/+v/6P/r/+//7v/t/+7/7v/t/+7/6v/n/+b/5v/p/+v/6//v/+7/7f/s/+z/7P/s/+z/6//t//D/6//r/+3/7P/t//H/9f/0//H/7v/u/+7/7v/v/+//7//w//D/8v/2//f/9f/y//D/7f/r/+3/7//x//P/9v/5//j/9P/4//v//v/9//f/9v/4//T/8v/y/+7/7P/t//L/9//3//X/9v/5//X/8P/z//n/+f/4//b/+P/8//z/+//6//b/8v/0//j//P/9//j/9v/2//b/9v/6//z/+//6//v/+f/6//v//v///////P/8//r/9v/3//f/8v/z//P/9P/1//D/9f/2//X/9//4//X/8//3//7//v/8//z/+//6//n/+//6//j/9//2//j/+f/6//z/+P/2//b/+P/4//v/+f/2//b/9v/2//j/+//7//r//P/6//n/9f/2//b/8v/0//j//P8AAP///f/7//r/9//2//T/9v/3//b/9//7//z//f/+//z/+//+/wIAAwAAAPz/+v/4//f/+v///wEA///9//v//P/9//3///////7//P/+/wAAAAD///v/9//2//b/9P/z//L/8P/w//P/+f/5//r/+f/6//n/+f/6//v//P/+//7//P/8//3//f/6//v//P/9//7//v/7//n/+f/8//7//v/8//z//f/8//r/+f/2//b/9//4//j/9v/1//X/8//w//H/8//0//f/9//5//z//f/+//7//v/+//3//P/6//r/+f/6//j/9v/3//b/9//4//z//v8AAAIAAgADAAMAAwACAAEAAQAAAAAAAQABAP//+v/6//v//P8AAAMABwAFAAAA///+//v//f/+/wEABQAFAAcABQABAAAA/v/+/wAAAgAGAAgABgAEAAEAAAABAAQAAgABAAIAAAAAAAIAAgADAAUABAADAAMABgAIAAgACAAGAAcACAAKAAoADAAMAAsABwAFAAYACAAJAAsACwAJAAgABgAHAAgABwAIAAkACwAMAAsACAAFAAQABgAJAAsACwAKAAcABQACAAIAAwACAAQABwAJAA0ADgANAAsACwAJAAsADAAPABAAEQARABIAEgASABMADwANAAwACgAJAAcABwAHAAgACgALAAoACgAIAAkACwAKAAsACwAKAAsADQAPAA8ADQAMAAgABQAHAAcACQALAAsACwAKAAoACwAMAA0ADgAOAA4ADgAOAA0ACwAJAAgACAAJAAkACQAIAAUAAgACAAUACQAJAA0ADgANAAwADAAKAAoACgAIAAgACgANAA0ADgAOAA8ADAAJAAgABwAHAAcACwAPABAAEAANAAoACQAJAAoADAAMAA0ADAALAAgABwAGAAMABQAHAAcACAAMAAwACgAKAAoACAAKAAkACQALAAgACQAKAAcABwAHAAcAAwADAAUABQAGAAoACwALAAwADAANAAwADgAPABAAEAAQABAADgALAAkACAAGAAYABAACAAIAAwAFAAoACwAHAAgACAAHAAkACgANAA0ACwAMAAsACQAKAAoACQAIAAcACAAHAAUABwAHAAcABgAHAAkACwALAAsADAAMAAoACgAIAAcACgAMAAoACQAHAAYABAAEAAcABwAHAAgACAAIAAgACAAKAAsACgALAAgABgAGAAQABQAFAAIABAAEAAIABgAGAAUABAACAAEAAAABAAEAAgADAAIAAgABAAEAAQABAAAAAAAAAAAAAQACAAIAAgADAAIAAQAAAAAAAAD//////P/8//v/+//6//n/+//7//v//P/9//z//f/9//7/AAD//wAAAAD+//3//v/9//3//v/8//r/+P/3//j/+v/7//z//v/+//z//P/8//r//P/8//3//f/9//7//f/9//3//P/4//f/9//3//f/9v/0//P/8f/x//L/8f/0//f/+P/8//3/+//5//n/+f/6//3//f/+//3/+//6//j/9v/3//n/+P/6//r//f/8//z/+//7//r/+//+//3//v/9//z//P/8//z//v/+//3//P/7//b/9f/z/+//7P/s/+3/7//w//P/8//z//L/8//0//P/9v/3//b/9f/3//n/9v/5//f/9//3//b/9v/3//X/9P/0//P/9P/0//X/9P/0//T/9P/1//f/+P/7//z/+//8//r/+f/4//f/9//2//b/9//2//b/9v/1//P/8f/u//D/7//v//D/8f/x//L/8f/u/+//8P/y//L/9P/2//b/8//0//X/9f/2//b/8//x//H/8v/1//b/9v/3//X/8//x//H/8f/y//H/8f/x//L/9f/z//P/8//0//T/9v/3//f/+P/3//b/+P/5//j/+v/8//3//P/7//v//f/7//v//P/6//r//P/+////AAAAAAEAAgABAAAAAAAAAAAAAAAAAAAA///+//7//v///wAAAAAAAAEAAQAAAAAAAQABAAEAAgADAAEAAAAAAAAAAAABAAAAAAABAAIAAAAAAAAAAAAAAAEAAgACAAIAAgAAAP7//f/9//z//P/+//3///////7//v/+//3//v/9//7//v/+//3//v////7//v////7//v/+//3//P/8//v//P/9//7//v/9////AQAAAAAAAQABAAAA/////wAA///////////+////AAD+////AAAAAAAAAAADAAQABgAJAAoACgAJAAoACQAHAAkACQAIAAkACQAHAAQAAwABAAAAAAABAAMABQADAAQABAABAAAAAAABAAIAAwACAAMAAQAAAP7////9//z//P/9//z//f/8//v/+v/5//r/+f/8//z//f///wAA/f/+//7//f/7//7//f/9////AAAAAAAAAgABAAEAAQAAAAAAAQAAAAMABAAHAAcACAAKAAoACgAJAAkACwAMAA0ADwAQABIAEwAUABMAFAATABQAFQAWABYAFgAYABYAFAASABIAEwAUABYAFgAWABUAFQAUABYAFwAXABUAFQAUABMAEwATABQAFwAYABcAGQAZABgAGAAXABcAGAAYABgAFwAWABYAFAARABEAEQASABUAFwAXABcAGAAXABYAFgAXABcAFgAXABcAFwATABEAEAAOAAwACgAHAAUABAADAAMABAADAAMAAgACAAIAAgAFAAYABwAIAAoADQAQABQAGAAZAB0AHgAdABwAGwAbAB4AHgAeAB0AHQAaABgAGAAYABYAFQAUABMADwALAAkABwAFAAUABQAEAAQAAgAAAP/////9//3//f/8//7//f/+/wAAAQACAAQABAACAAMAAQAAAAEAAwAEAAUABAACAAMAAQABAAAA/v/9//j/9v/y//H/8P/t/+z/7P/s/+r/6P/p/+v/6//s/+//8P/w//L/9P/2//X/8v/0//P/8//1//f/+v/7//z//P/7//r/9//2//T/9P/z//D/7//s/+n/6P/l/+T/5f/l/+b/5//l/+T/5//o/+n/6v/t/+7/8P/v/+//8f/y//P/9v/2//f/+f/6//r/+//9//3//v8AAAAAAgAFAAcABwAKAAoACAAIAAkACQAIAAgACAAFAAYABAADAAQABAADAAAA/v/8//n/9f/z//D/7f/q/+v/6f/n/+T/5f/n/+j/6f/r/+7/8f/z//T/9//4//v//f/+//7///////7////////////+//3/+//9//z//f/8//v/+//3//T/8v/w//D/7P/q/+r/5//j/+H/4v/j/+P/4//j/+T/5v/p/+r/7P/s/+z/7//u/+3/7f/s/+z/7f/u/+//8v/z//T/9//6//z///8AAAIABAAEAAUABgAFAAQAAgD+//r/9f/x/+3/6f/l/+P/4v/f/9//4P/h/+T/5//t//H/8//3//v//f///wEAAAAAAAAA/f/6//n/9v/1//X/8//x//L/7//v//D/7v/t/+3/7f/v//D/8v/0//T/9f/4//j/9//4//r/+v/8//3///8AAAAA/v////z/+//8//3/+//7//3//v8AAP//AAD///7///8AAAAA//8AAP///f///wAAAAAAAAAAAwAGAAQABgAIAAgACAAJAAwADgAPABAAEQAUABMAFAAWABcAGQAaABsAHAAdABsAHAAbABoAGAAZABcAFQAUABEADwAOAAsACgAJAAgABwAGAAgABwAJAAsACwAPABIAFAAWABgAGQAZABoAHQAfAB8AHwAfAB4AHgAdABoAGAAXABgAFQAVABMAEwASABAAEAASABAAEAAQABAAEQAQABIAEgASABMAEQAOAA4ADQAMAAsADgAOAA4ADwAPAA4ADgALAAkABwAGAAUABAAEAAMAAQACAAMAAgACAAAAAAAAAAEAAgADAAYACAALAAsADQARABIAEwAUABQAFAAWABUAFQATABAAEAAOAA4ADQAKAAYAAwAAAP3/+//8//n/9v/2//f/9//1//X/9v/0//P/8v/w//H/8v/y//T/9v/3//r///8AAAQABgAJAAoACwALAAwADAAOAAwADAANAA4ADAANAA0ACgAIAAcABAAAAP//+v/5//b/9f/1//b/+P/5//r//P/8//3//P/8//z//f/9//7/AAABAAQABgAHAAkACgAGAAcACQAIAAcACAAJAAkACgALAAsADAANAAwADwAPAA8AEAAQABEADwAOAA4ACwAJAAkACQAJAAoACwAMAA0ADgAPABAAEAASABEADwARABEAEQAOAA0ADgANAAwADAANAA0ACwAMAAwACwAKAAsACwALAAwADQAPAA4ADAAJAAgABQADAAIAAwACAAIABQAGAAcACAAMAAwADQAOAA8ADgAOAAwACwANAAsACwAHAAMAAwABAP//AAACAAIAAwAFAAoACgANAA8ADQALAAwACgAIAAgABgAGAAUABgAEAAMAAwADAAMABAAFAAcACQAMAA8AEgAUABQAEgARABAADQAIAAUAAwABAP//+//9//3//P/7//z//v////////8AAAAA/v/+//3//f/9//3//P///wAAAAADAAcACQAMAA4ADgAQAA8ADAALAAkABQABAAAA/v/7//j/9v/0//P/8//0//P/8//z//X/9//3//n/+v/6//n/+v/5//n/+P/3//X/9f/0//T/8//1//T/9P/1//T/9v/3//f/9v/2//f/9v/2//X/9v/1//T/8//0//P/9P/0//T/9v/5//r/+v/7//v/+v/6//j/9f/w/+z/6f/l/9//2v/W/9X/1//X/9b/2f/b/9v/3f/i/+D/4f/l/+b/5v/m/+b/6f/q/+n/6f/p/+j/5f/m/+X/5P/k/+X/5//p/+3/7//v/+7/7v/w/+7/6//p/+j/5v/k/+L/4P/d/9v/3P/c/9r/3P/e/97/4P/k/+P/5P/l/+b/5v/l/+P/3//c/9j/1v/V/9P/0P/R/9P/0v/Q/9H/1f/X/9r/3//i/+X/6P/p/+n/6v/r/+z/6v/p/+j/5//n/+j/6f/o/+n/7P/u/+7/7P/s/+r/6P/m/+H/4P/b/9n/2f/Z/9n/3f/f/+D/4v/k/+f/5//o/+z/7P/t/+3/8P/x/+7/7v/v/+//7v/t/+r/6//r/+n/7v/x//L/9P/2//r/+//8//7//v/+//z/+f/3//P/9P/y/+//7v/x//P/9v/3//v///8DAAcABwAJAAsACgAJAAYABgACAAAA/f/5//b/9P/x/+7/7v/v/+7/7v/v//H/8v/0//b/+P/5//r/+f/5//j/+P/7//z//f///wAAAQADAAUACAAIAAkACgALAAwACgAJAAYAAgD////////8//3/AAD///3//v8AAP7//f/8//n/+f/2//X/9P/1//T/9P/0//P/9v/3//r//f8AAAEABAAFAAcACgAKAAgACgAHAAUABgAFAAIA///5//j/9//x/+//8P/w//L/9v/5//7///8DAAQABAAEAAUABgAGAAUABAADAAEA/v/9//v/+f/6//r/+//3//f/+P/4//b/9//3//n/+v/7//3/AAAAAAQABwAIAAcACQAHAAcACAAKAAsACwAPABMAEwAVABcAFwAWABUAEgAPAA8ADQAMAAoACgAGAAcABwAFAAcACgALAA4AEQATABUAFgAWABcAGAAZABsAHgAeAB8AIgAgACAAIQAhACAAHwAfAB8AIAAgACAAHwAcABsAGwAXABUAEwATABIAEAAQABMAFQAVABUAFQAWABcAFwAYABwAHAAbAB4AIQAiACMAJQAmACUAKAAmACMAJAAhACAAIAAfAB4AHQAdABwAGgAZABcAFgATABIAEwATABUAGAAaABwAHQAhACMAIQAhACIAIgAjACMAJAAmACYAJAAjACMAJAAjACQAJAAlACYAJQAkACQAIwAhACMAIgAgACAAHwAdAB8AHgAaABkAFgAVABMAEQAQAA4ADAALAA0ADwAPABEAEwAWABQAFwAWABUAFgAZABkAHAAdABwAHwAgACAAIgAjACAAHwAfACAAHQAaABkAFAAQAA4ACwAKAAkACgAKAAoADQANAAwADQAMAAsACQAIAAcACAAIAAkACwAMAA4ADQAMAAkABwAFAAUABAAFAAUABQAEAAQABQAGAAgACAALAA4ADAAMAA8ADwAKAAoACQAJAAcABgAHAAkACwAMAA0ADgANAAwADAAMAAsACgAIAAYABQAEAA==", + ) + ] + ) + ) + self._send_client_event(ce) def cancel_responding(self) -> bool: - if self.ctx.is_responding(): + # cancel local response first. + self.server_ctx.output_buffer.stop_output() + if self.server_ctx.is_server_responding(): ce = ResponseCancel() self._send_client_event(ce) - response_id = self.ctx.get_responding_id() - self.ctx.stop_response(response_id) return True return False def start_listening(self) -> bool: - if not self.ctx.listening: - self.ctx.listening = True - self.cancel_responding() + if not self.server_ctx.listening: + self.server_ctx.listening = True + self.server_ctx.output_buffer.stop_output() + if self.server_ctx.is_server_responding(): + self.cancel_responding() return True return False def stop_listening(self) -> bool: - if self.ctx.listening: + if self.server_ctx.listening: # stop listening - self.ctx.listening = False + self.server_ctx.listening = False return True return False def commit_audio_input(self) -> bool: - if self.ctx.listening: - self.ctx.listening = False + if self.server_ctx.listening: + self.server_ctx.listening = False ce = InputAudioBufferCommit() self._send_client_event(ce) return True return False def clear_audio_input(self) -> bool: - if self.ctx.listening: - self.ctx.listening = False + if self.server_ctx.listening: + self.server_ctx.listening = False ce = InputAudioBufferClear() self._send_client_event(ce) return True return False - def get_session_obj(self) -> SessionObject: - session_obj = self.conf.session + def get_session_obj(self, vad_mode: bool) -> SessionObject: + session_obj = self.conf.get_session_obj(vad_mode) session_obj.instructions = self.conversation.get_instructions() tools = [] for fn in self.conversation.get_functions(): @@ -289,7 +370,7 @@ def get_session_obj(self) -> SessionObject: return session_obj def create_response(self) -> bool: - session_obj = self.get_session_obj() + session_obj = self.get_session_obj(self.vad_mode) ce = ResponseCreate( response=session_obj ) @@ -297,28 +378,40 @@ def create_response(self) -> bool: return True def is_server_responding(self) -> bool: - return self.ctx.is_responding() + return self.server_ctx.is_server_responding() + + def is_speaking(self) -> bool: + return self.server_ctx.output_buffer.is_speaking() def receive_server_event(self) -> bool: - data = self.connection.recv(timeout=None) + self.logger.debug("start to receive server event") + data = self.connection.recv(timeout=0.1) if data: + self.logger.debug("got received server event") self.session_state.recv(data) return True return False def handle_ghostos_event(self, event: GhostOSEvent): + # send message to server, let the realtime server handle the new message items. for msg in Turn.iter_event_message(event): - self._send_message_to_server(msg) - - def _send_message_to_server(self, message: Message, previous_item_id: Optional[str] = None) -> None: - ce = ConversationItemCreate( - previous_item_id=previous_item_id, - item=MessageItem.from_message(message), - ) - self._send_client_event(ce) + self.add_message_to_server(msg) + + def add_message_to_server(self, message: Message, previous_item_id: Optional[str] = None) -> bool: + item = MessageItem.from_message(message) + if item is not None: + ce = ConversationItemCreate( + previous_item_id=previous_item_id, + item=item, + ) + self._send_client_event(ce) + return True + return False def _send_client_event(self, event: ClientEvent): - self.connection.send(event.to_dict()) + data = event.to_event_dict() + self.logger.debug("send client event type %s, data is %r", type(event), data) + self.connection.send(data) def respond_error_message(self, error: str) -> None: - self.ctx.respond_error_message(error) + self.server_ctx.respond_error_message(error) diff --git a/ghostos/framework/openai_realtime/configs.py b/ghostos/framework/openai_realtime/configs.py index 65019ce8..269168fc 100644 --- a/ghostos/framework/openai_realtime/configs.py +++ b/ghostos/framework/openai_realtime/configs.py @@ -1,9 +1,9 @@ -from typing import Optional, ClassVar +from typing import ClassVar from pydantic import Field from ghostos.abcd.realtime import RealtimeAppConfig from ghostos.contracts.configs import YamlConfig -from .ws import OpenAIWebsocketsConf -from .event_data_objects import SessionObject +from ghostos.framework.openai_realtime.ws import OpenAIWebsocketsConf +from ghostos.framework.openai_realtime.event_data_objects import SessionObject __all__ = ['OPENAI_REALTIME_DRIVER_NAME', 'OpenAIRealtimeAppConf'] @@ -11,24 +11,30 @@ class OpenAIRealtimeAppConf(YamlConfig, RealtimeAppConfig): + """ + configuration + """ relative_path: ClassVar[str] = "openai_realtime_config.yml" - name: str = Field( - description="Name of the agent", - ) - description: str = Field( - description="Description of the agent", - ) ws_conf: OpenAIWebsocketsConf = Field( default_factory=OpenAIWebsocketsConf, description="OpenAI Websockets configuration", ) - session: Optional[SessionObject] = Field( - default=None, + session: SessionObject = Field( + default_factory=SessionObject, description="basic session settings, if None, use openai default session", ) + session_created_timeout: int = Field(10, description="session created timeout") - session_created_timeout: int = Field(10) + def get_session_obj(self, vad_mode: bool) -> SessionObject: + """ + get session object + :return: + """ + session = self.session.model_copy(deep=True) + if not vad_mode: + session.turn_detection = None + return session def driver_name(self) -> str: return OPENAI_REALTIME_DRIVER_NAME diff --git a/ghostos/framework/openai_realtime/driver.py b/ghostos/framework/openai_realtime/driver.py index a2e5a592..9e87ad0a 100644 --- a/ghostos/framework/openai_realtime/driver.py +++ b/ghostos/framework/openai_realtime/driver.py @@ -19,10 +19,12 @@ def create( conversation: Conversation, listener: Optional[Listener] = None, speaker: Optional[Speaker] = None, + vad_mode: bool = False, ) -> RealtimeApp: return RealtimeAppImpl( - config, - conversation, - listener, - speaker, + conf=config, + vad_mode=vad_mode, + conversation=conversation, + listener=listener, + speaker=speaker, ) diff --git a/ghostos/framework/openai_realtime/event_data_objects.py b/ghostos/framework/openai_realtime/event_data_objects.py index 3c8bca92..7ad4292d 100644 --- a/ghostos/framework/openai_realtime/event_data_objects.py +++ b/ghostos/framework/openai_realtime/event_data_objects.py @@ -3,12 +3,13 @@ import base64 from pydantic import BaseModel, Field -from typing import Optional, Literal, List, Dict, Union +from typing import Optional, Literal, List, Union from io import BytesIO from ghostos.core.messages import ( MessageType, Message, AudioMessage, FunctionCallMessage, FunctionCallOutputMessage, Caller, Role, ) +from ghostos.helpers import md5 class RateLimit(BaseModel): @@ -34,9 +35,9 @@ class Usage(BaseModel): class Error(BaseModel): type: str = Field("") - code: str = Field("") - message: str = Field("") - param: str = Field("") + code: Optional[str] = Field(None) + message: Optional[str] = Field(None) + param: Optional[str] = Field(None) class ResponseStatusDetails(BaseModel): @@ -62,16 +63,16 @@ class Content(BaseModel): and message items of role assistant support text content. """ type: Literal["input_text", "input_audio", "text", "audio"] = Field() - text: str = Field("") - audio: str = Field("") - transcript: str = Field("") + text: Optional[str] = Field(None) + audio: Optional[str] = Field(None) + transcript: Optional[str] = Field(None) class MessageItem(BaseModel): """ The item to add to the conversation. """ - id: str = Field() + id: Optional[str] = Field(None) type: Literal["message", "function_call", "function_call_output"] = Field("") status: Optional[str] = Field(None, enum={"completed", "incomplete"}) role: Optional[str] = Field(None, enum={"assistant", "user", "system"}) @@ -83,12 +84,19 @@ class MessageItem(BaseModel): @classmethod def from_message(cls, message: Message) -> Optional[MessageItem]: + if message is None or not message.content: + return None id_ = message.msg_id + if len(id_) > 32: + id_ = md5(id_) call_id = None output = None arguments = None content = None role = message.role + if not role: + role = Role.ASSISTANT.value + if message.type == MessageType.FUNCTION_CALL.value: type_ = "function_call" call_id = message.call_id @@ -98,25 +106,32 @@ def from_message(cls, message: Message) -> Optional[MessageItem]: call_id = message.call_id output = message.content else: - if not message.content: - return None type_ = "message" if role == Role.ASSISTANT.value: content_type = "text" + content = [ + Content(type=content_type, text=message.content), + ] elif role == Role.USER.value: content_type = "input_text" + content = [ + Content(type=content_type, text=message.content), + ] elif role == Role.SYSTEM.value: content_type = "input_text" + content = [ + Content(type=content_type, text=message.content), + ] else: content_type = "input_text" - - content = [ - Content(type=content_type, text=message.content), - ] + content = [ + Content(type=content_type, text=message.content), + ] return cls( id=id_, type=type_, + role=role, content=content, arguments=arguments, call_id=call_id, @@ -142,7 +157,7 @@ def get_audio_bytes(self) -> bytes: buffer.write(data) return buffer.getvalue() - def to_message_head(self) -> Optional[Message]: + def to_message_head(self) -> Message: if self.type == "function_call_output": return Message.new_head( @@ -155,6 +170,7 @@ def to_message_head(self) -> Optional[Message]: elif self.type == "function_call": return Message.new_head( typ_=MessageType.FUNCTION_CALL.value, + msg_id=self.id, role=self.role, name=self.name, call_id=self.call_id, @@ -167,8 +183,6 @@ def to_message_head(self) -> Optional[Message]: content += c.text elif c.transcript: content += c.transcript - if not content: - return None typ_ = MessageType.DEFAULT.value if self.role == Role.ASSISTANT.value: @@ -176,12 +190,16 @@ def to_message_head(self) -> Optional[Message]: return Message.new_head( typ_=typ_, - role=self.role, + msg_id=self.id, + role=self.role or "", content=content, ) else: - return None + return Message.new_head( + msg_id=self.id, + role=self.role or "", + ) def to_complete_message(self) -> Optional[Message]: if self.status == "incomplete": @@ -192,7 +210,7 @@ def to_complete_message(self) -> Optional[Message]: name=self.name, call_id=self.call_id, content=self.output, - ) + ).to_message() elif self.type == "function_call": return FunctionCallMessage( msg_id=self.id, @@ -202,7 +220,7 @@ def to_complete_message(self) -> Optional[Message]: name=self.name, arguments=self.arguments, ) - ) + ).to_message() elif self.type == "message": parsed_type = MessageType.TEXT if self.role == Role.ASSISTANT.value or self.has_audio(): @@ -214,12 +232,15 @@ def to_complete_message(self) -> Optional[Message]: parsed_content = parsed_content + c.text elif c.transcript: parsed_content = parsed_content + c.transcript + if not parsed_content: + return None if parsed_type is MessageType.AUDIO: return AudioMessage( msg_id=self.id, + role=self.role or "", content=parsed_content, - ) + ).to_message() else: return Message.new_tail( msg_id=self.id, @@ -242,16 +263,56 @@ class ConversationObject(BaseModel): object: str = Field("realtime.conversation") +class TurnDetection(BaseModel): + type: str = Field("server_vad") + threshold: float = Field( + 0.5, + description="Activation threshold for VAD (0.0 to 1.0), " + "this defaults to 0.5. A higher threshold will require louder audio to activate the model, " + "and thus might perform better in noisy environments.", + ) + prefix_padding_ms: int = Field( + default=300, + description="Amount of audio to include before the VAD detected speech (in milliseconds). Defaults to 300ms." + ) + silence_duration_ms: int = Field( + default=500, + description="Duration of silence to detect speech stop (in milliseconds). " + "Defaults to 500ms. " + "With shorter values the model will respond more quickly, " + "but may jump in on short pauses from the user." + ) + + +class InputAudioTranscription(BaseModel): + model: str = Field("whisper-1") + + class SessionObjectBase(BaseModel): """ immutable configuration for the openai session object """ model: str = Field("gpt-4o-realtime-preview-2024-10-01") - modalities: List[str] = Field(default_factory=list, enum={"text", "audio"}) - voice: str = Field(default="alloy", enum={"alloy", "echo", "shimmer"}) + modalities: List[str] = Field(default_factory=lambda: ["audio", "text"], enum={"text", "audio"}) + voice: str = Field(default="coral", enum={"alloy", "echo", "shimmer", "ash", "ballad", "coral", "sage", "verse"}) input_audio_format: str = Field(default="pcm16", enum={"pcm16", "g711_ulaw", "g711_alaw"}) output_audio_format: str = Field(default="pcm16", enum={"pcm16", "g711_ulaw", "g711_alaw"}) - turn_detection: Union[Dict, None] = Field(None) + turn_detection: Union[TurnDetection, None] = Field( + default_factory=TurnDetection, + description="Configuration for turn detection. " + "Can be set to null to turn off. " + "Server VAD means that the model will detect the start and end of speech based on audio volume " + "and respond at the end of user speech." + ) + input_audio_transcription: Optional[InputAudioTranscription] = Field( + default_factory=InputAudioTranscription, + description="Configuration for input audio transcription. " + ) + instructions: str = Field(default="", description="instructions of the session") + tools: List[dict] = Field(default_factory=list) + tool_choice: str = Field(default="auto") + temperature: float = Field(default=0.8) + max_response_output_tokens: Union[int, Literal['inf']] = Field(default='inf') class SessionObject(SessionObjectBase): @@ -260,8 +321,3 @@ class SessionObject(SessionObjectBase): """ id: str = Field(default="", description="id of the session") object: Literal["realtime.session"] = "realtime.session" - instructions: str = Field(default="", description="instructions of the session") - tools: List[dict] = Field(default_factory=list) - tool_choice: str = Field(default="auto") - temperature: float = Field(default=0.8) - max_response_output_tokens: Union[int, Literal['inf']] = Field(default='inf') diff --git a/ghostos/framework/openai_realtime/event_from_client.py b/ghostos/framework/openai_realtime/event_from_client.py index 174507db..a6576cb0 100644 --- a/ghostos/framework/openai_realtime/event_from_client.py +++ b/ghostos/framework/openai_realtime/event_from_client.py @@ -1,8 +1,8 @@ from typing import Optional, ClassVar, Self -from abc import ABC, abstractmethod +from abc import ABC from enum import Enum from pydantic import BaseModel, Field -from .event_data_objects import SessionObject, MessageItem +from ghostos.framework.openai_realtime.event_data_objects import SessionObject, SessionObjectBase, MessageItem __all__ = [ 'ClientEventType', @@ -25,7 +25,7 @@ class ClientEventType(str, Enum): - session_update = "session.updated" + session_update = "session.update" input_audio_buffer_append = "input_audio_buffer.append" input_audio_buffer_commit = "input_audio_buffer.commit" """ @@ -73,23 +73,21 @@ class ClientEvent(BaseModel, ABC): description="Optional client-generated ID used to identify this event.", ) - def to_dict(self) -> dict: - return self.model_dump(exclude_none=True) + def to_event_dict(self) -> dict: + data = self.model_dump(exclude_none=True) + data["type"] = self.type + return data class SessionUpdate(ClientEvent): type: ClassVar[str] = ClientEventType.session_update.value - session: SessionObject + session: SessionObjectBase class InputAudioBufferAppend(ClientEvent): type: ClassVar[str] = ClientEventType.input_audio_buffer_append.value audio: str = Field() - @classmethod - def new(cls, audio: bytes) -> Self: - raise NotImplementedError("todo") - class InputAudioBufferCommit(ClientEvent): """ diff --git a/ghostos/framework/openai_realtime/event_from_server.py b/ghostos/framework/openai_realtime/event_from_server.py index dbb94b97..1e50ee6c 100644 --- a/ghostos/framework/openai_realtime/event_from_server.py +++ b/ghostos/framework/openai_realtime/event_from_server.py @@ -4,7 +4,7 @@ from enum import Enum from pydantic import BaseModel, Field from ghostos.core.messages import Message as GhostOSMessage -from .event_data_objects import ( +from ghostos.framework.openai_realtime.event_data_objects import ( RateLimit, Response, MessageItem, DeltaIndex, ConversationObject, Error, SessionObject, Content, @@ -133,7 +133,7 @@ def is_input_audio_event(cls, event: dict, e_type: Optional[str] = None) -> bool def is_respond_event(cls, event: dict, e_type: Optional[str] = None) -> bool: if e_type is None: e_type = event.get("type", "") - return e_type.startswith("conversation.") + return e_type.startswith("response.") @classmethod def is_conversation_event(cls, event: dict, e_type: Optional[str] = None) -> bool: @@ -214,7 +214,7 @@ class ConversationItemCreated(ServerEvent): 3. The client has sent a conversation.item.create event to add a new Item to the Conversation. """ type: ClassVar[str] = ServerEventType.conversation_item_created.value - previous_item_id: str = Field("") + previous_item_id: Optional[str] = Field(None) item: MessageItem = Field() @@ -265,7 +265,7 @@ class ConversationItemTruncated(ServerEvent): class InputAudioBufferCommitted(ServerEvent): type: ClassVar[str] = ServerEventType.input_audio_buffer_committed.value - previous_item_id: str = Field("") + previous_item_id: Optional[str] = Field(None) item_id: str = Field("") @@ -423,7 +423,7 @@ class ResponseAudioDelta(DeltaIndex, ServerEvent): delta: str = Field("") def get_audio_bytes(self) -> bytes: - return base64.b64decode(self.audio_bytes) + return base64.b64decode(self.delta) class ResponseAudioDone(DeltaIndex, ServerEvent): diff --git a/ghostos/framework/openai_realtime/output.py b/ghostos/framework/openai_realtime/output.py index 9266c1ab..a19f9ab3 100644 --- a/ghostos/framework/openai_realtime/output.py +++ b/ghostos/framework/openai_realtime/output.py @@ -1,6 +1,6 @@ import time from abc import ABC, abstractmethod -from typing import List, Optional, Dict, Iterable, Callable +from typing import List, Optional, Dict, Iterable, Callable, Set from queue import Queue from ghostos.contracts.logger import LoggerItf from ghostos.core.messages import Message, ReceiverBuffer, SequencePipe @@ -8,43 +8,103 @@ class OutputBuffer(ABC): @abstractmethod - def stop_response(self): + def stop_output(self): + """ + stop the current response. + """ pass @abstractmethod - def start_response(self, response_id: str): + def end_output(self, response_id: str): + pass + + @abstractmethod + def start_output(self, response_id: str): + """ + start a new response + :param response_id: + :return: + """ + pass + + @abstractmethod + def stop_speaking(self): + pass + + @abstractmethod + def is_speaking(self): pass @abstractmethod def add_response_chunk(self, response_id: str, chunk: Message) -> bool: + """ + add a response chunk to certain response. + :param response_id: + :param chunk: + :return: + """ pass @abstractmethod def add_message(self, message: Message, previous_item_id: Optional[str]) -> bool: + """ + add complete message to the output. the already sent message will not be sent again. + :param message: + :param previous_item_id: + :return: + """ pass @abstractmethod def get_outputted_messages(self) -> List[Message]: + """ + get already outputted messages. + :return: + """ pass @abstractmethod def get_response_id(self) -> Optional[str]: + """ + get current response id. + :return: + """ pass @abstractmethod - def add_audio_output(self, response_id: str, data: bytes, filetype: str = "wav") -> bool: + def add_audio_output(self, response_id: str, data: Optional[bytes], filetype: str = "wav") -> bool: + """ + send an audio message to output. + :param response_id: + :param data: + :param filetype: + :return: + """ pass @abstractmethod def add_error_message(self, error: Message): + """ + add error message + :param error: + :return: + """ pass @abstractmethod - def output_item(self) -> Optional[ReceiverBuffer]: + def output_received(self) -> Optional[ReceiverBuffer]: + """ + :return: + """ pass @abstractmethod def speaking_queue(self, response_id: str) -> Optional[Queue]: + """ + get uncanceled response speaking queue. + :param response_id: + :return: + """ pass @@ -52,41 +112,86 @@ class DefaultOutputBuffer(OutputBuffer): def __init__( self, - close_check: Callable[[], bool], + is_close_check: Callable[[], bool], logger: LoggerItf, ): + self.is_close_check = is_close_check self.logger = logger - # status. + + # response stream self.response_id: Optional[str] = None - self.response_chunks: Optional[List[Message]] = None + self.response_item_ids: Optional[List[str]] = None + self.responding_item_id: Optional[str] = None + self.response_chunks: Optional[Dict[str, List[Message]]] = None + + # speaking self.speak_queue: Optional[Queue] = None - self.close_check = close_check self.outputted_message_ids: List[str] = [] self.outputted_messages: Dict[str, Message] = {} + """the outputted messages in order""" + self.error_messages: List[Message] = [] + """unsent error messages""" + self._is_speaking: bool = False + self.unsent_message_ids: List[str] = [] + self.sent_message_ids: Set[str] = set() - def stop_response(self): + def stop_output(self): self.response_id = None self.response_chunks = None + self.response_item_ids = None + self.responding_item_id = None + self.stop_speaking() + + def end_output(self, response_id: str): + self.response_id = None + # self.response_chunks = None + # self.response_item_ids = None + # self.responding_item_id = None if self.speak_queue is not None: - self.speak_queue.put(None) - self.speak_queue = None + self.logger.debug("send none to speaking queue but not stop speaking") + self.speak_queue.put(None, block=False) - def start_response(self, response_id: str): + def start_output(self, response_id: str): + self.stop_output() self.response_id = response_id - self.response_chunks = [] + self.response_chunks = {} + self.response_item_ids = [] + self.responding_item_id = None + self.start_speaking() + + def start_speaking(self): + self._is_speaking = True self.speak_queue = Queue() + self.logger.debug("start output speaking") + + def stop_speaking(self): + self.logger.debug("stop output speaking") + self._is_speaking = False + if self.speak_queue is not None: + self.speak_queue.put(None, block=False) + self.logger.debug("speaking queue send none") + self.speak_queue = None + + def is_speaking(self): + return self._is_speaking def add_message(self, message: Message, previous_item_id: Optional[str]) -> bool: if message is None or not message.is_complete(): return False + if not message.content: + return False msg_id = message.msg_id + # the message is a new item. if msg_id not in self.outputted_message_ids: + self.outputted_messages[msg_id] = message self.outputted_message_ids.append(msg_id) self.unsent_message_ids.append(msg_id) - self.outputted_messages[msg_id] = message + else: + self.outputted_messages[msg_id] = message + # re-range messages if previous_item_id is not None: outputted_message_ids = [] current_message_id = msg_id @@ -110,9 +215,16 @@ def add_response_chunk(self, response_id: str, chunk: Message) -> bool: if response_id != self.response_id: return False if self.response_chunks is None: - self.response_chunks = [chunk] - else: - self.response_chunks.append(chunk) + self.response_chunks = {} + if chunk.msg_id: + self.responding_item_id = chunk.msg_id + if not self.responding_item_id: + self.responding_item_id = "" + if self.responding_item_id not in self.response_chunks: + self.response_chunks[self.responding_item_id] = [] + self.response_item_ids.append(self.responding_item_id) + chunks = self.response_chunks[self.responding_item_id] + chunks.append(chunk) return True def get_outputted_messages(self) -> List[Message]: @@ -125,13 +237,14 @@ def get_outputted_messages(self) -> List[Message]: def get_response_id(self) -> Optional[str]: return self.response_id - def add_audio_output(self, response_id: str, data: bytes, filetype: str = "wav") -> bool: + def add_audio_output(self, response_id: str, data: Optional[bytes], filetype: str = "wav") -> bool: if response_id != self.response_id: return False queue = self.speak_queue if queue is None: return False queue.put(data) + return True def add_error_message(self, error: Message): self.error_messages.append(error) @@ -139,7 +252,7 @@ def add_error_message(self, error: Message): def speaking_queue(self, response_id: str) -> Optional[Queue]: return self.speak_queue - def output_item(self) -> Optional[ReceiverBuffer]: + def output_received(self) -> Optional[ReceiverBuffer]: chunks = self._output_chunks() if chunks is None: return None @@ -148,38 +261,52 @@ def output_item(self) -> Optional[ReceiverBuffer]: return ReceiverBuffer.new(sent) def _output_chunks(self) -> Optional[Iterable[Message]]: + # first of all, the error message is priory if len(self.error_messages) > 0: error = self.error_messages.pop(0) - return [error] + if error.msg_id not in self.sent_message_ids: + yield from [error] + self.sent_message_ids.add(error.msg_id) + return + # if there are unsent complete message, send it. if len(self.unsent_message_ids) > 0: msg_id = self.unsent_message_ids.pop(0) - if msg_id not in self.outputted_message_ids: + if msg_id in self.outputted_messages and msg_id not in self.sent_message_ids: message = self.outputted_messages[msg_id] - return [message] + yield from [message] + self.sent_message_ids.add(msg_id) + return + # output current responding if self.response_id is None: return None - chunk_idx = 0 output_item_id = "" response_id = self.response_id - while not self.close_check(): + if len(self.response_item_ids) > 0: + output_item_id = self.response_item_ids.pop(0) + if not output_item_id or output_item_id in self.sent_message_ids: + return None + + self.sent_message_ids.add(output_item_id) + + while not self.is_close_check(): if response_id != self.response_id or self.response_chunks is None: + # stream canceled + break + if self.response_chunks is None: break - if output_item_id in self.outputted_messages: - continue - - if len(self.response_chunks) > chunk_idx: - item = self.response_chunks[chunk_idx] - output_item_id = item.msg_id - if item.is_complete(): - if output_item_id not in self.outputted_messages: - self.outputted_messages[output_item_id] = item - yield item - break - chunk_idx += 1 - yield item + break + + chunks = self.response_chunks[output_item_id] + + if len(chunks) > 0: + first = chunks.pop(0) + yield first else: time.sleep(0.1) + + if output_item_id in self.outputted_messages: + yield self.outputted_messages[output_item_id] diff --git a/ghostos/framework/openai_realtime/state_of_client.py b/ghostos/framework/openai_realtime/state_of_client.py index 48761134..852d20a6 100644 --- a/ghostos/framework/openai_realtime/state_of_client.py +++ b/ghostos/framework/openai_realtime/state_of_client.py @@ -1,8 +1,10 @@ from __future__ import annotations from typing import List, Optional, Tuple, Protocol, Self from abc import ABC, abstractmethod -from .configs import OpenAIRealtimeAppConf +from ghostos.framework.openai_realtime.configs import OpenAIRealtimeAppConf from ghostos.core.runtime import Event as GhostOSEvent +from ghostos.abcd.realtime import Operator, OperatorName +from ghostos.contracts.logger import LoggerItf from enum import Enum @@ -33,17 +35,17 @@ class AppState(str, Enum): """local conversation function call""" -class OperatorName(str, Enum): - listen = "listen" - commit = "commit" - stop_listen = "stop listen" - clear_audio = "clear" - respond = "respond" - cancel_responding = "cancel" +listen = OperatorName.listen.new("start listening and sending audio buffer") +respond = OperatorName.respond.new("create a response") +clear_audio = OperatorName.clear_audio.new("clear input audio buffer") +stop_listen = OperatorName.stop_listen.new("stop listening but do nothing.") +cancel_response = OperatorName.cancel_responding.new("cancel current responding") class Client(Protocol): conf: OpenAIRealtimeAppConf + logger: LoggerItf + vad_mode: bool @abstractmethod def reconnect(self) -> None: @@ -53,6 +55,10 @@ def reconnect(self) -> None: """ pass + @abstractmethod + def update_session(self): + pass + @abstractmethod def synchronize_server_session(self): """ @@ -62,6 +68,10 @@ def synchronize_server_session(self): @abstractmethod def cancel_responding(self) -> bool: + """ + cancel server responding and local output + :return: + """ pass @abstractmethod @@ -76,6 +86,17 @@ def stop_listening(self) -> bool: def is_listening(self) -> bool: pass + @abstractmethod + def is_server_responding(self) -> bool: + pass + + def is_responding(self) -> bool: + return self.is_server_responding() or self.is_speaking() + + @abstractmethod + def is_speaking(self) -> bool: + pass + @abstractmethod def audio_buffer_append(self, buffer: bytes) -> None: pass @@ -96,10 +117,6 @@ def clear_audio_input(self) -> bool: def create_response(self) -> bool: pass - @abstractmethod - def is_server_responding(self) -> bool: - pass - @abstractmethod def receive_server_event(self) -> bool: pass @@ -140,15 +157,15 @@ def recv_server_event(self) -> bool: return self.client.receive_server_event() @abstractmethod - def operate(self, operator: str) -> Optional[Self]: + def operate(self, operator: Operator) -> Optional[Self]: pass - def allow(self, operator: str) -> bool: + def allow(self, operator: Operator) -> bool: operators = self.operators() - return operator in operators + return operator.name in {op.name for op in operators} @abstractmethod - def operators(self) -> List[str]: + def operators(self) -> List[Operator]: pass @abstractmethod @@ -159,13 +176,11 @@ def destroy(self): self.client = None def default_mode(self) -> Self: - if self.client.conf.start_mode == "listening": + if self.client.vad_mode: + # start vad mode and listening to anything. return ListeningState(self.client) - elif self.client.conf.start_mode == "idle": - return IdleState(self.client) - elif self.client.is_server_responding(): - return RespondingState(self.client) else: + # wait for user's operator. return IdleState(self.client) @@ -234,32 +249,29 @@ def state_name(self) -> str: def operators(self) -> List[str]: return [ - OperatorName.respond, - OperatorName.stop_listen, - OperatorName.commit, - OperatorName.clear_audio, + respond, + stop_listen, + clear_audio, ] - def operate(self, operator: str) -> Optional[Self]: - if operator == OperatorName.respond.value: + def operate(self, operator: Operator) -> Optional[Self]: + name = operator.name + if name == OperatorName.respond.value: + # commit and create response. self.client.commit_audio_input() return CreateResponseState(self.client) - elif operator == OperatorName.commit.value: - self.client.commit_audio_input() + elif name == OperatorName.stop_listen: + # stop listening, and do nothing. + # do not clear the input audio buffer automatically. self.client.stop_listening() return IdleState(self.client) - elif operator == OperatorName.stop_listen: - self.client.stop_listening() + elif name == OperatorName.clear_audio: self.client.clear_audio_input() + # clear and stay idle return IdleState(self.client) - elif operator == OperatorName.clear_audio: - self.client.clear_audio_input() - # clear and go on listening - return None - else: return None @@ -274,21 +286,24 @@ class CreateResponseState(StateOfClient): def on_init(self): if self.client.is_server_responding(): + # if create response while responding, cancel current one first. self.client.cancel_responding() - if self.client.is_listening(): + elif self.client.is_listening(): + # if create response while listening, commit it self.client.commit_audio_input() + + # create response. self.client.create_response() return def state_name(self) -> Tuple[str, List[str]]: return AppState.waiting_response, self.operators() - def operate(self, operator: str) -> Optional[Self]: - # todo: test later + def operate(self, operator: Operator) -> Optional[Self]: return None - def operators(self) -> List[str]: - # todo: test later + def operators(self) -> List[Operator]: + # when creating responding, no operators allowed. return [] def tick_frame(self) -> Optional[Self]: @@ -302,33 +317,44 @@ class RespondingState(StateOfClient): def on_init(self): if not self.client.is_server_responding(): self.client.respond_error_message("enter responding state but server is not responding") + # stop listening while responding. + self.client.stop_listening() return def state_name(self) -> str: return AppState.responding.value - def operate(self, operator: str) -> Optional[Self]: - if operator == OperatorName.cancel_responding.value: - if self.client.is_server_responding(): + def operate(self, operator: Operator) -> Optional[Self]: + name = operator.name + if name == OperatorName.cancel_responding.value: + # cancel current responding. + if self.client.is_responding(): self.client.cancel_responding() return self.default_mode() - elif operator == OperatorName.listen.value: - if self.client.is_server_responding(): + + elif name == OperatorName.listen.value: + if self.client.is_responding(): self.client.cancel_responding() return ListeningState(self.client) else: return None - def operators(self) -> List[str]: + def operators(self) -> List[Operator]: return [ - OperatorName.cancel_responding, - OperatorName.listen, + cancel_response, + listen, ] def tick_frame(self) -> Optional[Self]: - if self.client.is_server_responding(): + if self.client.is_responding(): + self.client.logger.debug( + "responding state is speaking: %r, is server responding %r", + self.client.is_responding(), + self.client.is_server_responding(), + ) return None else: + self.client.logger.debug("responding state return default mode") return self.default_mode() @@ -337,22 +363,23 @@ class IdleState(StateOfClient): def on_init(self): if self.client.is_listening(): self.client.stop_listening() - elif self.client.is_server_responding(): + elif self.client.is_responding(): self.client.cancel_responding() - # when idle, update local conversation. - return def state_name(self) -> str: return AppState.idle.value - def operate(self, operator: str) -> Optional[Self]: - if operator == OperatorName.listen.value: + def operate(self, operator: Operator) -> Optional[Self]: + if operator.name == OperatorName.listen.value: return ListeningState(self.client) + elif operator.name == OperatorName.respond.value: + return CreateResponseState(self.client) return None - def operators(self) -> List[str]: + def operators(self) -> List[Operator]: return [ - OperatorName.listen.value, + listen, + respond, ] def tick_frame(self) -> Optional[Self]: diff --git a/ghostos/framework/openai_realtime/state_of_server.py b/ghostos/framework/openai_realtime/state_of_server.py index 1c610929..aa196bb4 100644 --- a/ghostos/framework/openai_realtime/state_of_server.py +++ b/ghostos/framework/openai_realtime/state_of_server.py @@ -1,8 +1,8 @@ from __future__ import annotations from abc import ABC, abstractmethod from typing import Protocol, Optional, Dict, Self, List, Union -from .event_from_server import * -from .event_data_objects import ( +from ghostos.framework.openai_realtime.event_from_server import * +from ghostos.framework.openai_realtime.event_data_objects import ( MessageItem, RateLimit, SessionObject, @@ -10,6 +10,7 @@ from pydantic import ValidationError from ghostos.core.messages import Message, MessageType from ghostos.contracts.logger import LoggerItf +from ghostos.container import get_caller_info class ServerContext(Protocol): @@ -17,42 +18,97 @@ class ServerContext(Protocol): @abstractmethod def respond_message_chunk(self, response_id: str, chunk: Union[Message, None]) -> bool: + """ + respond a message chunk usually a text chunk. + :param response_id: + :param chunk: + :return: + """ pass @abstractmethod def respond_error_message(self, error: str) -> None: + """ + output error message + :param error: + :return: + """ pass @abstractmethod - def update_history_message(self, message: Union[Message, None]) -> None: + def update_history_message(self, message: Message) -> None: + """ + update history message + :param message: + :return: + """ pass @abstractmethod def update_local_conversation(self) -> None: + """ + update realtime conversation with local conversation + """ pass @abstractmethod def add_message_item(self, item: MessageItem, previous_item_id: Optional[str] = None) -> None: + """ + add realtime message item to update history message + :param item: + :param previous_item_id: + :return: + """ pass @abstractmethod - def start_response(self, response_id: str) -> None: + def respond_audio_chunk(self, response_id: str, item_id: str, data: bytes) -> bool: + """ + respond + :param response_id: + :param item_id: + :param data: + :return: + """ + pass + + @abstractmethod + def save_audio_item(self, item: MessageItem) -> None: + """ + save audio data to local storage + :param item: + :return: + """ pass @abstractmethod - def get_responding_id(self) -> Optional[str]: + def start_server_response(self, response_id: str) -> None: + """ + start server response + :param response_id: + :return: + """ pass @abstractmethod - def stop_response(self, response_id: str) -> bool: + def get_server_response_id(self) -> Optional[str]: pass @abstractmethod - def respond_speaking_audio_chunk(self, response_id: str, data: bytes) -> bool: + def end_server_response(self, response_id: str) -> bool: + """ + end but not cancel a response. + :param response_id: + :return: + """ pass @abstractmethod - def save_audio_data(self, item: MessageItem) -> None: + def stop_listening(self) -> None: + """ + stop listening inputs. + :return: + """ pass @@ -94,16 +150,20 @@ def _destroy(self): pass def recv_invalid_event(self, event: dict): - se = ServerError(**event) - error = "Received invalid event: %r" % se - self.ctx.logger.error(error) - # send error message. - return self.ctx.respond_error_message(error) + type_ = ServerEventType.get_type(event) + if ServerEventType.error.value == type_: + se = ServerError(**event) + # send error message. + self.ctx.logger.error("state %s recv server error: %r", type(self), se.error) + return self.ctx.respond_error_message(se.error.message) + else: + self.ctx.logger.error("state %s receive invalid event: %s", type(self), str(event)[:100]) def ack_server_event(self, event: ServerEvent): - self.ctx.logger.info( - "handled server event type `%s` and event id `%s'", - type(event).__name__, event.id, + line = get_caller_info(2, with_full_file=False) + self.ctx.logger.debug( + "ack event: event `%s` id `%s' by state %s at line `%s`", + event.type, event.event_id, type(self), line, ) @@ -121,7 +181,7 @@ def __init__( self.conversation = ConversationState(ctx, session_id="", conversation_id="") self.session_id = session_created.session.id self.session_obj: SessionObject = session_created.session - self.input_audio = InputAudiState(ctx) + self.input_audio = InputAudioState(ctx) self.tokens_rate_limit: Optional[RateLimit] = None self.requests_rate_limit: Optional[RateLimit] = None @@ -130,9 +190,13 @@ def is_responding(self) -> bool: def recv(self, event: dict): type_name = ServerEventType.get_type(event) - if ServerEventType.is_session_event(event, type_name): + if ServerEventType.error.value == type_name: + se = ServerError(**event) + raise RuntimeError(se.error) + elif ServerEventType.is_session_event(event, type_name): return self._recv_session_event(event, type_name) - elif ServerEventType.rate_limits_updated: + + elif ServerEventType.rate_limits_updated.value == type_name: return self._update_rate_limit(event) # input audio event @@ -160,12 +224,12 @@ def _destroy(self): def _recv_session_event(self, event: dict, e_type: str): if e_type == ServerSessionCreated.type: obj = ServerSessionCreated(**event) - self.session_id = obj.session_id + self.session_id = obj.session.id self.session_obj = obj.session elif e_type == ServerSessionUpdated.type: obj = ServerSessionUpdated(**event) - if self.session_id and obj.session_id != self.session_id: + if self.session_id and obj.session.id != self.session_id: # recv other session event, which is not possible. return self.recv_invalid_event(event) self.session_obj = obj.session @@ -185,12 +249,13 @@ def _recv_input_audio_event(self, event: dict): def _update_rate_limit(self, event: dict): # todo: use rate limit in future. rlu = RateLimitsUpdated(**event) - for limit in rlu.ratelimits: + for limit in rlu.rate_limits: if limit.name == "requests": self.requests_rate_limit = limit elif limit.name == "tokens": self.tokens_rate_limit = limit self.ctx.logger.info(f"Rate limit updated {rlu}") + self.ack_server_event(rlu) class ConversationState(StateOfServer): @@ -245,14 +310,13 @@ def get_conversation_items(self) -> List[ConversationItemState]: current_item_id = next_item_trace[current_item_id] return items - @abstractmethod def recv(self, event: dict): type_name = ServerEventType.get_type(event) # conversation events if ServerEventType.conversation_created.match(event): return self._conversation_created(event) elif ServerEventType.conversation_item_created.value == type_name: - return self._item_created(event) + return self._conversation_item_created(event) elif ServerEventType.conversation_item_deleted.value == type_name: return self._delete_item(event) @@ -275,7 +339,7 @@ def _conversation_created(self, event: dict): self.conversation_id = cic.conversation_id return self.ack_server_event(cic) - def _item_created(self, event: dict): + def _conversation_item_created(self, event: dict): server_event = ConversationItemCreated(**event) item = server_event.item if item.id not in self.conversation_item_states: @@ -284,14 +348,19 @@ def _item_created(self, event: dict): created_event=server_event, ) self.conversation_item_states[item.id] = conversation_item_state - - # let conversation_item_state handle the item event. - state = self.conversation_item_states[item.id] - return state.recv(event) + if len(self.conversation_item_states) > 4: + first = list(self.conversation_item_states.keys())[0] + if first != item.id: + del self.conversation_item_states[first] + else: + # let conversation_item_state handle the item event. + state = self.conversation_item_states[item.id] + return state.recv(event) def _delete_item(self, event: dict): cid = ConversationItemDeleted(**event) item_id = cid.item_id + # delete exists conversation item. if item_id in self.conversation_item_states: del self.conversation_item_states[item_id] self.ctx.logger.info(f"Deleted item {item_id}") @@ -309,7 +378,8 @@ def _on_response_event(self, event: dict): self.ctx.logger.error("Response is not created") else: rc = ResponseCreated(**event) - self.response = self._create_response(rc) + response = self._create_response(rc) + self.responses[response.response_id] = response return None # response exists. response = self.responses[response_id] @@ -340,8 +410,9 @@ def __init__( created_event: ConversationItemCreated, ): super().__init__(ctx) - self.previous_item_id: str = created_event.previous_item_id + self.previous_item_id: Optional[str] = created_event.previous_item_id self.item: MessageItem = created_event.item + self.message = created_event.item.to_message_head().as_tail(copy=True) self._on_conversation_item_created(created_event) def _destroy(self): @@ -351,7 +422,7 @@ def recv(self, event: dict): type_name = ServerEventType.get_type(event) # conversation item is created yet. - if ServerEventType.conversation_created.value == type_name: + if ServerEventType.conversation_item_created.value == type_name: obj = ConversationItemCreated(**event) return self._on_conversation_item_created(obj) @@ -363,10 +434,10 @@ def recv(self, event: dict): elif ServerEventType.conversation_item_input_audio_transcription_completed.value == type_name: obj = ConversationInputAudioTranscriptionCompleted(**event) # update transcription. - if self.message is not None and self.message.type == MessageType.AUDIO.value: - self.message.content = obj.transcript - self.ctx.update_history_message(self.message) - return self.ack_server_event(obj) + self.message.content = obj.transcript + self.message.type = MessageType.AUDIO.value + self.ctx.update_history_message(self.message) + return self.ack_server_event(obj) elif ServerEventType.conversation_item_input_audio_transcription_failed.value == type_name: obj = ConversationInputAudioTranscriptionFailed(**event) @@ -379,15 +450,16 @@ def recv(self, event: dict): def _on_conversation_item_created(self, server_event: ConversationItemCreated): self.previous_item_id = server_event.previous_item_id self.item = server_event.item - self.message = self.item.to_complete_message() + self.message = self.item.to_message_head().as_tail(copy=True) # add new message item. self.ctx.add_message_item(server_event.item, server_event.previous_item_id) if self.item.has_audio(): - self.ctx.save_audio_data(item) + # save audio. + self.ctx.save_audio_item(self.item) return self.ack_server_event(server_event) -class InputAudiState(StateOfServer): +class InputAudioState(StateOfServer): def recv(self, event: dict): type_name = ServerEventType.get_type(event) @@ -402,15 +474,23 @@ def recv(self, event: dict): def _on_input_audio_buffer_stopped(self, event: dict): se = InputAudioBufferSpeechStopped(**event) + self.ctx.stop_listening() # todo: truncate audio return self.ack_server_event(se) def _on_input_audio_buffer_started(self, event: dict): + """ + the input audio started. + :param event + :return: + """ se = InputAudioBufferSpeechStarted(**event) + # todo: start to truncate input audio. return self.ack_server_event(se) def _on_input_audio_buffer_committed(self, event: dict): se = InputAudioBufferCommitted(**event) + # todo: return self.ack_server_event(se) def _on_input_audio_buffer_cleared(self, event: dict): @@ -433,7 +513,7 @@ def __init__( ): super().__init__(ctx) self.response_id = event.response.id - self.response = event.response + self.response_obj = event.response self.item_states: dict[str, ResponseItemState] = {} self.responding_item_id: Optional[str] = None self._on_response_created(event) @@ -444,11 +524,12 @@ def _destroy(self): self.item_states = {} def is_done(self) -> bool: - return self.response.status in {"completed", "cancelled", "failed"} + return self.response_obj.status in {"completed", "cancelled", "failed"} def recv(self, event: dict) -> None: type_name = ServerEventType.get_type(event) response_id = ServerEventType.get_response_id(event) + # receive current response event only if response_id != self.response_id: return self.recv_invalid_event(event) @@ -475,11 +556,11 @@ def recv(self, event: dict) -> None: return self.recv_invalid_event(event) def _on_response_created(self, event: ResponseCreated): - self.response = event.response + self.response_obj = event.response self.response_id = event.response.id # start response - self.ctx.start_response(self.response_id) + self.ctx.start_server_response(self.response_id) return self.ack_server_event(event) def _on_response_done(self, event: dict) -> None: @@ -488,12 +569,19 @@ def _on_response_done(self, event: dict) -> None: """ rd = ResponseDone(**event) # update message item - self.response = rd.response - self.ctx.stop_response(rd.response.id) - if rd.response.output: + self.response_obj = rd.response + self.ctx.end_server_response(rd.response.id) + if rd.response.status not in ["completed", "cancelled"] and rd.response.status_details: + error = rd.response.status_details.error + if error: + self.ctx.logger.error("response done with error: %s", error) + self.ctx.respond_error_message(repr(error)) + + elif rd.response.output: # update history messages again for item in rd.response.output: self.ctx.update_history_message(item.to_complete_message()) + # update local conversation when response is done. self.ctx.update_local_conversation() return self.ack_server_event(rd) @@ -507,9 +595,6 @@ def _on_response_output_item_added(self, event: dict) -> None: self.item_states[item_id] = state self.responding_item_id = item_id - # todo: 最后统一处理消息发送. - return self.ack_server_event(se) - class ResponseItemState(StateOfServer): @@ -557,12 +642,11 @@ def recv(self, event: dict) -> None: elif ServerEventType.response_text_done.value == type_name: se = ResponseTextDone(**event) - # no need to handle return self.ack_server_event(se) elif ServerEventType.response_audio_delta.value == type_name: se = ResponseAudioDelta(**event) - self.ctx.respond_speaking_audio_chunk(se.response_id, se.get_audio_bytes()) + self.ctx.respond_audio_chunk(se.response_id, se.item_id, se.get_audio_bytes()) return self.ack_server_event(se) elif ServerEventType.response_audio_done.value == type_name: diff --git a/ghostos/framework/openai_realtime/ws.py b/ghostos/framework/openai_realtime/ws.py index 8f0abcaf..c7fd62e4 100644 --- a/ghostos/framework/openai_realtime/ws.py +++ b/ghostos/framework/openai_realtime/ws.py @@ -92,7 +92,7 @@ def send(self, event: dict) -> None: if self._closed: return self._ws.send(data) - self._logger.debug(f"[OpenAIWSConnection] send data to server: %s", data) + self._logger.debug(f"[OpenAIWSConnection] send data to server: %s", data[:300]) except websockets.exceptions.ConnectionClosedOK: self.close() @@ -101,15 +101,14 @@ def recv(self, timeout: Union[float, None] = None, timeout_error: bool = False) return None try: data = self._ws.recv(timeout=timeout) - self._logger.debug(f"[OpenAIWSConnection] receive data") if not data: self._logger.error(f"[OpenAIWSConnection] receive empty data: {data}") return None if data: + self._logger.debug(f"[OpenAIWSConnection] receive data: %s", data[:300]) event = json.loads(data) - self._logger.debug(f"[OpenAIWSConnection] receive event %s", event["type"]) - if not data: - return event + return event + return None except websockets.exceptions.ConnectionClosed: self.close() return None diff --git a/ghostos/ghosts/__init__.py b/ghostos/ghosts/__init__.py index e69de29b..3e7446d8 100644 --- a/ghostos/ghosts/__init__.py +++ b/ghostos/ghosts/__init__.py @@ -0,0 +1,2 @@ +from ghostos.ghosts.chatbot import Chatbot +from ghostos.ghosts.moss_agent import MossAgent diff --git a/ghostos/prototypes/realtime_console/example.py b/ghostos/prototypes/realtime_console/console.py similarity index 100% rename from ghostos/prototypes/realtime_console/example.py rename to ghostos/prototypes/realtime_console/console.py diff --git a/ghostos/prototypes/realtime_console/vad_test_script.py b/ghostos/prototypes/realtime_console/vad_test_script.py new file mode 100644 index 00000000..32a66553 --- /dev/null +++ b/ghostos/prototypes/realtime_console/vad_test_script.py @@ -0,0 +1,59 @@ +from ghostos.framework.openai_realtime import ( + RealtimeAppImpl, + OpenAIRealtimeAppConf, +) +from ghostos.bootstrap import get_ghostos +from ghostos.contracts.configs import Configs +from ghostos.contracts.logger import LoggerItf, get_console_logger +from ghostos.ghosts import Chatbot +from ghostos.framework.audio import get_pyaudio_pcm16_speaker, get_pyaudio_pcm16_listener +from rich.console import Console +import time + +console = Console() + +if __name__ == "__main__": + ghostos = get_ghostos() + logger = get_console_logger(debug=True) + ghostos.container().set(LoggerItf, logger) + configs = ghostos.container().force_fetch(Configs) + app_conf = configs.get(OpenAIRealtimeAppConf) + # app_conf.listening = False + app_conf.ws_conf.proxy = "socks5://127.0.0.1:1080" + jojo = Chatbot( + name="jojo", + description="a chatbot for baseline test", + persona="you are an LLM-driven cute girl, named jojo", + instruction="remember talk to user with user's language." + ) + shell = ghostos.create_shell("realtime_test") + conversation = shell.sync(jojo) + realtime_app = RealtimeAppImpl( + conf=app_conf, + vad_mode=True, + conversation=conversation, + listener=get_pyaudio_pcm16_listener(), + speaker=get_pyaudio_pcm16_speaker(), + ) + listening = False + + with realtime_app: + messages = realtime_app.history_messages() + logger.info("render history messages") + for message in messages: + logger.info("render message %r", message) + + while not realtime_app.is_closed(): + state, operators = realtime_app.state() + logger.info("state: %s, operators: %r", state, operators) + buffer = realtime_app.output() + if buffer is None: + time.sleep(0.5) + continue + logger.info("receive buffer") + while buffer is not None: + for chunk in buffer.chunks(): + logger.info("receive chunk %s", chunk.content) + tail = buffer.tail() + logger.info("receive tail %r", tail) + buffer = buffer.next() diff --git a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py index 6dfcec94..d973d1c8 100644 --- a/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py +++ b/ghostos/prototypes/streamlitapp/cli/run_ghost_chat.py @@ -68,7 +68,7 @@ def bootstrap(): container = get_container() if shell is None: logger.debug("start shell background run") - shell = ghostos.create_shell("ghostos_streamlit_app", "ghostos_streamlit_app") + shell = ghostos.create_shell("ghostos_streamlit_app") shell.background_run(4, StreamlitBackgroundApp()) Singleton(shell, Shell).bind(st.session_state) diff --git a/tests/core/messages/test_pipeline.py b/tests/core/messages/test_pipeline.py index 9656d9a9..9ddf0a7f 100644 --- a/tests/core/messages/test_pipeline.py +++ b/tests/core/messages/test_pipeline.py @@ -35,3 +35,10 @@ def iter_content(c: str) -> Iterable[Message]: messages = SequencePipe().across(messages) messages = list(messages) assert len(messages) == len(content) + 1 + + +def test_sequence_pipe_with_tail(): + item = Message.new_tail(content="hello") + messages = SequencePipe().across([item]) + messages = list(messages) + assert len(messages) == 1 diff --git a/tests/framework/openai_realtime/test_configs.py b/tests/framework/openai_realtime/test_configs.py new file mode 100644 index 00000000..4caf92ed --- /dev/null +++ b/tests/framework/openai_realtime/test_configs.py @@ -0,0 +1,7 @@ +from ghostos.framework.openai_realtime.configs import OpenAIRealtimeAppConf +from ghostos.framework.openai_realtime.event_data_objects import SessionObject + + +def test_configs_session(): + conf = OpenAIRealtimeAppConf() + assert isinstance(conf.session, SessionObject) diff --git a/tests/framework/openai_realtime/test_events.py b/tests/framework/openai_realtime/test_events.py new file mode 100644 index 00000000..c44f55cd --- /dev/null +++ b/tests/framework/openai_realtime/test_events.py @@ -0,0 +1,6 @@ +def test_session_update_event(): + from ghostos.framework.openai_realtime.event_data_objects import SessionObject + from ghostos.framework.openai_realtime.event_from_client import SessionUpdate + session = SessionObject() + ce = SessionUpdate(session=session) + assert ce.session == session diff --git a/tests/python/test_bytes.py b/tests/python/test_bytes.py new file mode 100644 index 00000000..02bbdefb --- /dev/null +++ b/tests/python/test_bytes.py @@ -0,0 +1,8 @@ +from io import BytesIO + + +def test_bytes(): + b = BytesIO() + b.write(b'hello') + got = b.getvalue() + assert len(got) == 5 diff --git a/tests/python/test_queue.py b/tests/python/test_queue.py new file mode 100644 index 00000000..4202140f --- /dev/null +++ b/tests/python/test_queue.py @@ -0,0 +1,8 @@ +from queue import Queue + + +def test_queue(): + q = Queue() + q.put(None) + value = q.get(block=True, timeout=5) + assert value is None diff --git a/tests/python/test_slice.py b/tests/python/test_slice.py index 458f645c..14bf4727 100644 --- a/tests/python/test_slice.py +++ b/tests/python/test_slice.py @@ -13,6 +13,13 @@ def test_slice_negative_index(): assert arr[:-1] == [0] +def test_slice_pop_0(): + arr = [0, 1] + arr.pop(0) + arr.pop(0) + assert arr == [] + + def test_thread_safe_append(): from threading import Thread From 5b66b082e052ad613b6feca21d2cbe949ed7223d Mon Sep 17 00:00:00 2001 From: zhuming Date: Tue, 17 Dec 2024 13:12:39 +0800 Subject: [PATCH 132/148] dev: realtime api pass baseline tests --- ghostos/framework/openai_realtime/client.py | 50 +++++++++++-------- ghostos/framework/openai_realtime/output.py | 2 + .../openai_realtime/state_of_server.py | 4 +- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/ghostos/framework/openai_realtime/client.py b/ghostos/framework/openai_realtime/client.py index 60205475..25847099 100644 --- a/ghostos/framework/openai_realtime/client.py +++ b/ghostos/framework/openai_realtime/client.py @@ -280,7 +280,6 @@ def update_session(self): ce = SessionUpdate( session=session_obj.model_dump(exclude_none=True), ) - ce.session.instructions += "\n*ALWAYS RESPOND WITH AUDIO*" self.logger.debug("update session: %s", repr(ce)) self._send_client_event(ce) @@ -288,37 +287,44 @@ def synchronize_server_session(self): if self.synchronized: return count = 0 - self.update_session() - self._hack_openai_realtime_beta() + # self.update_session() + history = [] previous_item_id = None for msg_id in self.server_ctx.history_message_order: message = self.server_ctx.history_messages[msg_id] if not message.content: continue - ok = self.add_message_to_server(message, previous_item_id) - if ok: - previous_item_id = message.msg_id - self.logger.debug("Synchronizing server session with item %s", msg_id) + history.append(message.role + ": " + message.content) + + # ok = self.add_message_to_server(message, previous_item_id) + # if ok: + # previous_item_id = message.msg_id + # self.logger.debug("Synchronizing server session with item %s", msg_id) count += 1 + if history: + history_content = "\n\n---\n\n".join(history) + # ce = ConversationItemCreate( + # item=MessageItem( + # role="system", + # type="message", + # content=[ + # Content(type="input_text", text=history_content), + # ] + # ) + # ) + session_obj = self.get_session_obj(self.vad_mode) + ce = SessionUpdate( + session=session_obj.model_dump(exclude_none=True), + ) + ce.session.instructions += "\n\n# history messages\n\n" + history_content + self._send_client_event(ce) + else: + self.update_session() + self.logger.info("Synchronizing server session done with item %d", count) self.synchronized = True - def _hack_openai_realtime_beta(self): - ce = ConversationItemCreate( - item=MessageItem( - role="user", - type="message", - content=[ - Content( - type="input_audio", - audio="EgAXABMAFwAUABgAHQAeAB8AIAAiACMAJgAoACYAJgAmACUAJgAkACIAHQAYABkAHAAcABsAGgAaAB0AHgAeAB4AHAAeAB4AHwAhACEAIAAeAB8AIwAiABwAGQAbABoAGgAYABYAFgATABMAFwAZABcAFgAXABsAIQAhACAAIAAfAB8AIQAhAB0AFwATABIAFAAUABAAEwAQABEAFQAWABgAFwAXABkAHAAeAB4AHQAbABkAHAAbABkAFQAOAAwADgAQAA8ACwAJAAkACwAPAA8ADQALAAoACgAMAA0ACgAIAAgACAAIAAYABQAFAAMABAAFAAYABAABAAIAAwAGAAYABwAGAAIABAADAAYABQAAAP3/+//8//7/+v/3//b/9f/4//n/+v/5//T/9f/6//r/+v/4//b/8//1//f/+f/4//P/8f/w//D/7v/q/+b/5v/n/+n/6v/p/+j/5f/l/+f/6f/r/+b/5//r/+r/6f/n/+b/5f/j/+T/5f/l/+T/4P/g/+D/4P/h/+D/4v/i/+H/4//n/+P/4P/f/97/4P/e/97/3P/b/9v/2P/a/9n/1f/S/9T/2v/b/9r/2P/Z/9n/2f/Z/9z/3P/b/9n/2v/d/9v/2//Y/9f/2f/Y/9z/3f/a/9v/3f/i/+z/7f/s/+//8f/x//L/9P/0//X/9P/2//b/9//3//j/+P/4//n/+P/6//r/+v/6//v/+//7//v/+//8//v//P/8//z//P/8//3//P/9//3//f/8//7//f/9//3//v/+//7//v/+//7//v////7//v/+//7////+//////////7//////////////wAA////////AAAAAAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAAD//wAAAAAAAP//AAAAAP//AAD//wAA//8AAP//AAAAAAAA//8AAAAAAAD//wAAAAD//wAA//8AAAAA/////wAA//8AAP//AAAAAP//AAD//wAA/////wAA/////wAA/////wAA//8AAP////8AAP//AAAAAP////////////8AAP////8AAAAA///////////////////////////+//////////7////+/wAA/v///////v///////////////v////////////7////+///////+/////v/+/////v////7//v////7////+//7////+/////v/+/////v/////////////////////////////////////////+/wAA//////////////////////////////////////////8AAP///////////v///////////wAA//8AAAAAAAD/////AAD/////AAAAAAAAAAAAAAAAAAAAAP////8AAAAAAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgABAAEAAAABAAAAAQAAAAAAAQABAAAAAQABAAEAAgADAAMAAgACAAIAAgABAAIAAgACAAIAAgABAAIAAgACAAIAAgACAAIAAwACAAMAAwADAAIAAQABAAEAAQABAAAAAAABAAEAAQABAAEAAQABAAIAAQACAAIAAgABAAEAAgACAAEAAQABAAEAAQACAAEAAQABAAAAAAABAAIAAgABAAIAAwACAAIAAwADAAMAAwADAAIAAwACAAIAAwACAAIAAwACAAEAAgACAAIAAgACAAIAAgACAAIAAwADAAIAAgACAAEAAgABAAEAAQABAAIAAgADAAMAAwACAAMAAgADAAQAAwACAAMAAwADAAQABAAEAAUABAAEAAQABQAEAAUABQAFAAQABQAEAAMABQAFAAUABQAFAAUABAAEAAQABAAFAAUAAwAEAAUABQAFAAYABgAFAAcABgAFAAYABgAGAAUABgAGAAcABwAIAAcABAAFAAQAAwADAAMAAgADAAMAAwADAAMABAAFAAYABgAGAAUABQAFAAQABAADAAMAAwADAAEAAQACAAIAAQABAAIAAgABAAEAAQACAAIAAwAEAAMAAgACAAMAAgACAAIAAQACAAEAAgABAAEAAQABAAIAAAAAAAAA/v/+//7//v/9//z//f/8//7//////////v8AAAAA/////wAA//8AAAAAAAAAAP7///////z//f/8//r/+//7//r/+f/6//n/+P/6//r/+v/6//r/+//6//v//P/7//v/+//7//r/+v/5//j/9//3//f/+P/5//j/+v/5//f/+f/3//f/9//3//b/9f/0//T/9P/z//T/9f/0//P/9f/1//X/9P/z//P/9P/1//X/9f/1//b/9f/0//P/9P/0//L/8v/y//L/8f/y//P/8v/y//P/8//y//D/7//u/+7/7P/t/+3/7v/t/+7/7f/s/+z/7P/s/+7/8f/y//P/8//1//b/9P/1//X/8v/w/+3/6//r/+r/6//s/+z/7P/s/+3/7P/t/+//7//w//L/8f/x//D/8f/y//H/8f/x//D/8P/x//H/8v/v/+7/7v/v/+7/7f/u/+//7v/t/+7/7f/t/+3/6v/r/+3/7v/v/+//8f/w/+7/7//x/+//7f/t/+z/7P/t/+z/7v/u/+7/7v/w//D/7//u/+7/8v/y//D/7//y//D/8P/y//H/8v/x/+//7//w/+//8f/y//L/8v/x//H/8f/v/+7/7v/w/+7/7f/u/+3/6v/s/+//7//w//L/8v/x//L/8f/y//H/8P/y//H/7//u/+v/6v/r/+v/6v/t/+7/7P/u/+7/7f/w/+7/7f/v/+7/7P/r/+v/6//q/+v/6f/o/+r/6v/r/+3/7v/r/+n/6v/q/+f/6f/m/+X/5f/l/+b/5v/o/+r/6//r/+z/7f/r/+v/7v/t//H/8f/x//T/9P/y//P/9P/0//T/9P/z//b/9f/z//L/8//y//D/7f/r/+v/7P/s/+3/7//w//P/9P/2//n/+f/5//v/+f/5//n/9v/4//r/+//7//3//P/9//v/+v/8//7//v/+//3//P////7///8AAAMABAAFAAcABAADAAMAAwAFAAUABwAIAAgABwADAAIAAQACAAIAAwABAAIAAQABAAAAAwAGAAoADAANAAwACQAHAAgABwAKAA8ADQANAA0ADQAMAAsACwAMAA4ADgAPABEAEQARABUAFgAVABYAFgAXABQAEQASABEAEQASABIAEAAPABEAEAASABYAGQAZABkAGQAVABYAFgAXABoAGQAaABkAFwAVABUAFAAVABcAGAAYABgAFwAXABcAGQAaAB0AHgAeAB8AHwAcAB8AHQAdAB4AHwAeABwAHQAcAB0AHQAbABsAGwAbABwAGgAYABkAGQAbABsAGwAbABoAGwAaABkAFwAaABoAGwAbABkAGAAZABgAFwAYABUAFQAWABUAEgATABQAFAATABMAFAATABIAEwATABIAEgAVABQAFAATABIAFQAUABYAGQAYABgAGAAWABMAEgAVABUAEwATABMAEAAQABAADwAMAAoACgAJAAwACwAMAAwADAANAAsADAALAAsACgAKAAoACAAGAAQAAQABAAMABQADAAUABQAFAAcABgAGAAYABQAEAAUAAwACAAMAAgACAAQAAwADAAUABQABAAEAAgAEAAMAAwADAAMAAQABAAIAAAAAAAEA///+//v/+v/3//X/9P/z/wUABgAFAAUABQAFAAQABQAFAAcABwAFAAYACAAGAAUABAAFAAUABQAEAAMAAgAAAAAAAAAAAAIAAgAAAAEAAQABAAAAAAAAAAEAAAAAAAAAAAABAAMAAgABAAEAAQABAAAAAAACAAAA//8AAAAA///9//7//f/9//z//P/8//r//P/7//v/+//7//r/+//9//z//f/+//7//v/9//3//f/9//r/+P/6//n/9//2//b/9f/0//T/9f/2//b/+P/7//f/9//4//n/+f/5//j/+P/5//v/+//6//v/+v/5//n/+P/0//L/8v/x//L/8f/y//H/8P/w/+//8P/y//D/8v/y//P/9P/z//L/8P/y//L/8P/y//P/9P/0//X/8//z//L/8f/x//H/8v/w//D/8P/t/+7/7v/s/+z/7f/t/+r/7P/s/+3/7//v//P/8//z//P/9P/z//L/9P/y//P/8v/x//H/7v/t/+z/6//t/+7/7f/v/+7/7f/v/+7/7v/v//D/8v/y//H/9P/0//P/8v/w/+//7//w/+//7//w//D/8v/y//L/8P/w//D/8f/x//H/8P/w//D/8P/y//P/8v/y//D/7v/v/+//8v/z//P/9P/y//P/8v/z//T/8//z//T/9f/1//P/9P/2//X/9f/2//f/9v/4//j/9//2//f/9//5//r/+v/6//r/+v/7//r/+P/5//n/9//3//b/9v/0//b/+P/7//v//P/9//7//f/9//7///8AAAAAAQABAAAAAAD///7//P/7//3/+//9//3//P/9//3//P8AAAAAAQABAP///v/+//3///8AAAAAAAAAAAEAAgACAAQABQAGAAUABQADAAMAAwAAAP//AAD///7/AQACAAIAAwACAAIAAAABAAIAAQAAAAMABAACAAMAAwAEAAIAAQABAP///f/+//////8AAAAAAAABAAIAAgADAAQAAwAEAAMAAgADAAIAAQACAAMAAgAFAAIAAAADAAEAAQADAAIAAAAAAAAAAgADAAMABQAFAAcABwAGAAcABQAFAAQAAgACAAEAAQABAAEAAQADAAEAAQAAAAAAAAADAAQABQAGAAcABQAFAAYABwALAAsACgAKAAkACAAHAAYABgAHAAYABgAIAAkACQAKAAsACwALAAwADQANAA4ADwAPAA0ADAAKAAoACQAJAAoADQALAA0ADQAMAAwADAAOAA4ADgAPABEAEAARABIAEgARAA8ADgANAA4ADAAJAAgACAAIAAoACwALAA0ADAAMAA4ADwAOABAAFAAVABcAFgAVABQAEgASABMAEgASABAAEQARABEAEAARABAADwARABMAFQAWABcAFwAXABYAFQAUABMAEQARAA8ADwAOAA4ADgAQABEAEgATABMAEwATABEAEAARABAADwAQABAAEAAOAA0ACwAJAAUAAwAEAAQABQAFAAcACAAMAA4AEAAPAA8AEQAQABAAEAAOAA0ADAALAAoACAAIAAkACAAIAAgACAAHAAYABQAFAAYABgAHAAkACQAIAAgABQAEAAMAAwABAAAAAAAAAAEAAAAAAAEAAQABAAEAAgABAAEAAAAAAP///v8AAAAA/v8AAAAA//////3//f/7//r/+//7//z/+//9//3//P/7//v/+f/6//n/+P/3//b/9//2//T/8v/y//P/8f/x//L/8v/1//b/9P/0//T/8v/z//T/9f/0//T/9//2//T/9P/z//L/8v/y//P/8v/0//T/8//z//H/8f/v//H/8f/x//L/9P/2//b/9//2//P/8//y//L/8v/x/+//7v/u/+3/7f/u/+//8v/z//T/9P/1//X/9P/1//X/9P/0//T/9P/z//L/8P/w//D/7//u/+7/8P/w//D/8f/x//L/8v/y//H/8//y//H/8f/v/+//7v/u/+7/7P/r/+3/7//u/+7/7f/u//H/8P/w//L/8//0//T/9P/1//P/8//z//H/8P/v/+3/7P/s/+v/6//r/+3/7f/v/+//8P/y//L/8v/0//T/8//1//T/8//z//P/8v/y//H/8f/x/+//8f/w//H/8P/x//H/8f/x//L/9P/1//X/9f/0//T/8//y//L/8f/y//H/7//v//D/7v/u/+3/7f/u/+//7//x//P/8//2//f/+P/5//r/+f/4//n/+v/2//T/8//y//H/8f/x//H/8f/y//T/9P/0//b/9v/3//j/+P/6//j/+f/5//n/+P/4//j/+P/3//f/+f/4//n/+v/6//v//P/+//7//v////7//v/+//7//v/+//7/AAABAAIAAgABAAEAAAAAAAAA///+//7//f/8//3///8AAAIABAAFAAcABwAHAAcABgAGAAYABgAFAAQABQAEAAUABAADAAUABQAGAAgABwAIAAgACQAJAAgABwAHAAcACAAHAAkACQAIAAkACgALAAsACwALAAwADAALAAsACgAHAAcABwAGAAUABwAHAAcABwAIAAcACgAIAAcACAAKAAkACQALAAsACwALAAwADAALAAwADAAKAAoACgAIAAcABwAGAAYABgAHAAcACAAIAAoACQAJAAgACAAHAAYABwAHAAcABgAHAAgACAAJAAgACQAIAAkACQAJAAcACAAIAAgABwAHAAcACAAHAAYABgAFAAMABAAEAAUABAAGAAYABgAFAAYABgAGAAYABgAFAAcABgAFAAcABgAFAAQAAgABAAEAAQABAAEAAQAEAAUABAAFAAQABAAEAAMABAAEAAMABQAHAAYABwAJAAcABwAJAAYABAADAAIAAgAAAAAAAAAAAAAAAAACAAEABAAEAAQABQAHAAcACAAHAAUABgAGAAYABwAGAAUABAADAAUABQAEAAUABQAFAAIAAgACAAEAAQAAAAEAAwADAAQAAgADAAMAAwACAAIAAgACAAEAAQACAAAAAwAFAAQABAAGAAcABwAHAAcABgAGAAYABgAFAAUABQAFAAUABgAGAAgABwAIAAcABgAFAAQAAgABAAAAAQAAAAAAAgAAAAAAAAABAAEAAQACAAMABAAEAAMAAwAEAAUABAAFAAQABAAEAAIAAAAAAAAAAAAAAAAAAAD//////f/+/wAA//8AAAAAAAABAAIAAwAEAAMABAAGAAQAAwAGAAYABQAGAAUAAwADAAMABAADAAIAAQABAAEAAQAAAAAAAgABAAEAAQAAAAAAAAD+//7//f/9//z//P/8//7//v/+////AAAAAAEAAQABAAAAAAABAAAAAQABAAAA//8AAAAA//8AAAAAAAABAAEAAgABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAD+//////8AAAAA///////////+//3//v/+////AAAAAAEAAgAEAAQABAAFAAcACAAHAAYABwAGAAUABAAEAAQABAABAAAAAAD+/wAAAQAAAAEAAAAAAAAA////////AAD9////AAD+/wAAAAD///7//v/+//3//v//////AQAAAAEAAgAAAAEAAQABAAMABAAFAAUAAwABAAIAAAAAAP///f/8//v/+f/6//v/+f/4//n/+v/7//z/+//8//z//v/8//7////+//3//f/8//v//P/8//z/+v/7//3//f/9//z//f/7//v//f/8//z/+v/3//j/9//3//f/9f/0//T/8//1//T/9P/2//b/9v/1//b/9v/0//X/9//1//b/9f/z//P/8v/x//L/8P/w//D/8f/w/+//8P/w/+//7//u/+7/7//s/+z/7v/t/+//7v/s/+//7f/r/+3/7P/u/+//7v/t/+//7f/s/+z/7P/r/+z/6v/r/+v/6v/r/+r/6v/q/+v/6//q/+v/6//s/+3/7v/w//D/8f/z//L/8f/y//P/8//z//P/8v/0//T/8//0//X/9v/2//X/9f/1//T/8//z//T/8f/y//P/8v/y//P/8//z//L/8v/z//T/8//y//L/8v/y//T/9P/0//P/9P/2//f/+P/4//r/+v/8//z//f/8//3//P/8//3/+v/5//n/+v/7//v/+//7//z//v8AAAMABQAGAAcABgAIAAYABgAFAAUABAACAAIAAAAAAAAAAAAAAAAAAAAAAAEAAAADAAMABQAGAAcABwAIAAkACgAJAAgACAAIAAcACAAGAAYABwAHAAgABwAHAAoADAANAA8ADgARABEAEQATABEAEAAOAA8ADwAPAA0ADAALAAkACQAIAAkACQAJAAsADAAOABAAEAARABIAEQARABAAEgATABEAEQARABIAEwASABMAEwARABIAEgASABEAEwARABEAEgARABMAEgARABEAEAAPABAAEAAQAA8ADgAOAA8AEAAPABIAEwATABQAFQAUABUAFQAVABcAFgAYABcAFwAWABQAFAASABEAEQAQAA0ADQAOAA0ADwAOAA8ADwAOABEAEQARABMAEwATABMAEQASABEAEAAPAA4ADgAMAAwADAALAAoACgALAAsADAAKAA0ADwANAA0ADgAOAA0ADAAMAAwACwALAAsACgALAAsACgALAA0ADQAMAA0ADgAOABAAEQASABMAEwATABIAEgAPAA0ACwAJAAcABwAGAAYABgAGAAcACAAIAAoADAANAA0ADAALAAwADQAMAAwACwAJAAgABwAGAAYAAwACAAMAAgADAAMAAwAFAAcABwAIAAkACQALAAsACgAKAAoACQAJAAcABwAIAAcACAAHAAcACAAIAAgACAAGAAYACAAJAAoACQAKAAkACQAIAAgABQAGAAQAAwABAAAA/f/8//z/+//8//3//P/+//7//f/+////AAABAAEAAgAAAAEAAwACAAEAAgABAP///v////7//v/+/////////wAA/v8AAAAAAAABAAAA/v////7//v/8//v/+//6//v/+f/5//r/+v/5//j/+P/4//j/+v/7//v/+//9//3//f/+///////+/////f/8//v/+f/5//n/+f/5//n/+f/4//b/9v/2//j/+P/5//v//P/9//z/+//7//r/+v/4//f/9//3//b/9P/1//T/8//z//T/9P/0//f/9//2//b/9//3//f/9v/2//b/9f/0//T/9f/4//f/+P/4//j/+//9//v/+v/7//r/9//2//b/9//3//n/9//3//f/9v/2//b/9v/2//X/9P/3//j/+P/4//f/9//2//X/9P/2//b/9v/2//b/9f/2//j/9//2//b/9v/3//b/+P/4//f/+P/4//j/+P/3//f/+P/4//j/+P/5//j/9v/3//f/9P/3//j/9v/4//r/+//6//v/+//6//n/+f/6//n/+v/6//n/+f/5//r/+//7//z//v////7///////7//v/9//z////9//v//P/7//r/+f/3//f/9//2//f/9//1//j/+v/7//r/+//9//3///8AAAAAAAAAAAAAAAAAAP///v/7//r/+//5//j/+P/5//j/9//6//v/+v/7//3//f/+/////f///wAA//8AAP/////8//z/+v/5//n/+v/7//v//P/9//3///8AAAAAAAAAAAAA//8AAAAAAAAAAAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD//wAAAAAAAAAA///+//7//P/7//z/+//8//z//v//////AAAAAAEAAgADAAIAAAABAAAAAAD//wAAAAAAAAAAAAAAAAAAAAABAAEAAgACAAAAAQACAAEAAgACAAEAAgACAAEAAgAEAAQABQAFAAYABQAGAAMAAgABAAEAAAD+//3///8AAAAAAAAAAAEAAgABAAIABQAFAAMABQAFAAQABQAFAAIAAQABAAAA///+/wAAAgACAAIAAwAFAAYABQAHAAcABwAIAAcABQAFAAMAAwABAP//AAABAAAA///+////AAAAAAEAAwAEAAQABAAEAAQABAAFAAMAAgABAAEAAgAAAAEAAgACAAMAAgABAAEAAQAAAP///f/9////AAABAAMAAgADAAQABAAGAAQABAADAAIAAwACAAIAAwABAAEAAQAAAAAAAgABAAAABAAFAAYABwAGAAYABgAGAAYABgAGAAYACAAFAAYABgAFAAUABgAGAAUABQAHAAYABQAFAAUABQAEAAUABgAGAAUABQAEAAUABAAEAAMABAAFAAQAAwAFAAMAAwAEAAQABAACAAMAAwABAAIAAgABAAMAAgADAAMABAAEAAUABgAFAAUABgAHAAUACAAHAAcABwAGAAcACAAHAAoACAAHAAcABwAHAAcABwAHAAcABwAGAAgACAAHAAgACQAIAAoADQAMAAoADAANAA0ADAALAAgACQAJAAgACAAHAAoACQAIAAoACgAJAAsACwAKAAwADQANAA0ADAAKAAkACQAIAAkABQAFAAYABgAGAAcABgAFAAQABgAGAAYABgAJAAkACgALAAoACgAJAAkACQAJAAcABgAGAAYABQAGAAYABgAIAAkABgAFAAYABgAHAAYABgAHAAkACgAJAAkACgAKAAoACgAJAAgABwAGAAYAAwADAAQABAAEAAUABQAEAAUABAAEAAUABQAEAAQAAwACAAIAAgAAAAAA///+////AAABAAEAAwAEAAQABAAEAAMAAgACAAMAAQAAAAAAAAD///7///////3//v////7//v8AAAEAAQACAAIAAgACAAMAAwAEAAEAAAD/////AAD+//3//P/9//3//f/9//3//f/+/wAAAAABAAEAAQACAAMABAADAAMAAgAAAAAA///+//7//v/9//7////+//7//v/+//////////7///8AAAAA/v/9//7//v/+/wAA/////wAAAAAAAAAA//8AAP///v/8//3//P/8/////v/9/////f/7//3//f/9//7//f///////v///////v////7//v/9//z/+//6//r/+v/7//j/+P/4//j/+f/5//v/+//8//3//f/8//3//f/9/////v/+/////v/9//7//f/8//z//f/+//r/+P/4//j/9//5//f/9v/3//n/+v/8//z//P/6//r/+//6//v/+v/8//z/+P/4//j/+f/3//f/+f/4//f/+f/7//v/+v/7//z//P/6//r//P/7//v/+//6//n/9//2//f/9P/z//T/8P/v//H/9P/y//T/9//2//n//P/7//v//P/8//z//P/4//b/9v/2//T/9P/y//P/8P/w//H/8P/w//H/8//0//X/9v/3//n/+v/8//v/+//6//z//f/6//r/+v/5//f/+f/5//n/+v/7//v/+//9//7///8AAAIAAwADAAMAAQD///7//v/9//3//v/8//7//v///wEAAAAAAAEAAAAAAAAAAQACAAQAAgADAAMAAQAEAAQAAgADAAQAAwABAAIAAQAAAAIAAgABAAEAAAAAAAEAAAACAAIAAgAEAAMABAAEAAMABAACAAIAAAABAAMAAgACAAIAAQABAAIAAgADAAYABgAHAAYABQAHAAcABgAGAAQABAADAAMAAwADAAMAAwAFAAQAAgAEAAQAAwAFAAQABAAFAAUAAwADAAUABAAFAAQABQAEAAMAAwADAAIAAAAAAAAA//8AAP//AAABAAEAAwAEAAUABgAFAAUAAwAEAAMAAwACAAEAAQAAAAEAAQABAAIAAQAAAAEAAAAAAAMAAwABAAEAAgAAAP//AAAAAP3//v////z/+//6//n/+//9//3//f/8//z//f/+/wAA/v/8//3//P/9//z//P/8//v/+//6//v//P/7//r/+v/7//r/+//7//r/+f/5//n/9//4//j/9v/2//j/+P/3//n/+v/6//r/+f/5//n/+f/6//v/+v/6//z/+v/5//r//P/+////AAAAAAEAAAAAAP3//v////z/+//6//r/+//6//z/+//8//z//P/9//3//v////7///8AAP///v/9//3//P/7//v/+f/6//z//f/8//7//v/+/////v/+//7////+//7//v/////////9//3//f/7//v/+v/8//z//P/8//z//v///wAAAAAAAAIAAQAAAAAA///+//3//f/9//v/+//6//n/+f/5//v/+v/6//v/+//6//v/+//7//z//f/8//3//P/9//z/+//5//n/9//3//f/9//1//P/8f/w//D/8v/y//P/9f/2//T/9f/4//f/9f/1//T/8v/w//D/7v/u/+7/6v/p/+r/6v/q/+z/7P/s/+z/7f/t/+3/7P/t/+r/6v/p/+f/5//n/+f/6P/n/+f/5//n/+f/5v/k/+P/4//h/+L/4f/g/+D/4v/h/+P/4//j/+H/4f/j/+H/4P/g/+H/4f/g/+D/4f/g/+D/3//g/9//3f/e/+D/3v/f/+L/4v/j/+P/4v/i/+L/4v/i/+D/4f/g/+D/3//f/9//3//f/9//4f/i/+P/5P/l/+X/4//k/+T/4v/k/+T/5f/l/+b/5//o/+n/6f/r/+z/7f/u//D/7//u/+7/8f/w//D/8v/z//P/9P/z//X/9f/0//X/9v/2//j/+v/6//v//f////3//v8AAP7//v///wAAAAAAAAAAAQD//wAAAAABAAAAAAACAAIABAAGAAkACgALAAsACwALAAsADQANAAoACQAKAAkACgAJAAoACwAMAAwADwASABIAFQAXABgAFgAXABcAFQAUABUAFAASABMAEwATABMAFQAWABcAFgAVABYAFwAVABYAFwAXABgAFwAXABgAGQAYABgAGgAbABwAHgAeABwAGwAcABsAGQAYABcAFwAYABgAGgAbABwAHQAeAB8AHQAdAB8AHAAcABsAGwAZABoAGgAaABoAGQAYABoAGQAYABkAGQAbABsAGwAbABwAGwAdAB0AGgAaABoAGwAbABwAHAAcABsAGgAaABkAGQAYABYAFAAUABUAFAAUABUAFQAXABgAGAAZABcAGAAYABcAFwAWABUAEwAUABQAFQAWABcAFwAYABcAFwAZABkAGgAcAB0AHgAbABwAGwAZABkAGgAaABkAGgAZABkAGQAZAB0AGgAXABgAGQAZABkAHAAdAB8AHwAfACAAIAAgACAAHwAgAB4AHQAeABwAHgAeABwAHgAeAB4AHgAdABwAHgAdABsAGwAbABgAGgAaABoAGwAbAB0AHgAeAB4AHwAhAB8AHgAcABoAGgAZABkAGgAbABoAGQAaABoAGgAaABwAHAAaABkAFwAWABQAEgARABEADwAPABAAEAAPABAAEQARABIAEgARABEAEQASABEAEQAQAA4ADQAMAAsACQAIAAgABwAFAAUABQAFAAUABQAFAAUABQAGAAYABgAFAAQABAADAAEAAAD///3/+//6//j/+f/3//j/+f/5//j/+//7//v//P/7//v/+v/5//f/9v/2//T/8//z//L/8v/x//D/8P/w//D/7v/v/+//8P/v/+7/7P/s/+v/6f/q/+j/6P/n/+X/5f/m/+X/5P/m/+b/5v/m/+T/5P/i/+P/4//i/+T/4v/i/+L/4f/h/9//4P/h/+D/4f/g/+L/4v/h/+L/3//g/+L/4v/g/97/3v/d/9z/3P/b/9r/2v/c/93/3v/e/9//3//f/+H/4//h/+L/4//h/+H/4P/e/97/3//c/9//4P/f/+H/4v/k/+T/5f/l/+X/4//k/+P/4//i/+L/4//j/+T/5P/l/+T/5v/m/+X/5P/k/+L/4//k/+b/5v/m/+j/6f/o/+n/6//q/+z/7f/u/+//7//u/+7/7v/v/+//8P/w/+//7//u/+7/7//v//D/8f/y//L/9P/1//f/+P/3//f/9//5//j/9//3//f/9//4//n/+P/4//j/+//5//r/+//8//z//P/9//3//v8AAAAAAAAAAAAA///9//7//v/+//3//v/+//7//v/+//7//v/+/////f/9//7//v/+//////////7//v///wAAAAAAAAIAAgACAAMAAwAEAAQABAAEAAYABgAFAAQABAAEAAIAAgADAAUABAAEAAQABAAFAAYABAAGAAkABwAJAAoABwAIAAgABwAJAAgACQAIAAcABgAHAAkACQAJAAkACgAMAAsADAALAAwACwALAA0ADAAOAA8AEAASABMAEwARABEAEQAPABAAEAANAA0ADQAMAA4ADwAPABAAEQARABAAEQAUABQAFAATABUAFwAUABMAFAAUABQAFAATABMAFAAUABQAFQAWABcAGQAaABoAGgAZABsAHQAcABsAGwAbAB0AHQAcAB0AHAAbABsAGgAaABsAGgAWABgAGAAWABYAFgAVABYAFwAYABgAGQAaABsAHAAbABkAGAAXABYAFQASABIAEgASABMAFAAVABQAFgAXABgAGQAaABwAHAAcAB4AHQAbABoAGgAaABgAGgAaABoAGQAaABkAGgAbABkAGAAZABgAFwAYABgAFwAZABkAGAAYABkAGgAaABoAGgAbABoAGAAZABcAFwAWABUAFQATABIAEQASABMAEwASABQAEwATABMAFAAVABMAEgASABAADwAPAA4ADwAPAA0ADgANAAwADQALAAsADAAMAA0ADQANAA8ADAAMAA0ADAALAAsACgAIAAoACQAIAAkABwAHAAYABwAHAAcABwAIAAsACQAJAAwACwALAAsACQAIAAgACAAIAAgABwAHAAgABwAGAAcACAAKAAwACwANAA4ADQANAA0ACwAMAAsADAAMAAwADAANAA0ACwALAAoACQAIAAcABgAHAAkACAAKAAwACQAJAAoACQAHAAYABgAEAAQABAAEAAQABQAEAAQABQACAAIAAQAAAP////8AAP7//f/+//z//f/8//r/+//5//r/9//1//X/8//y//H/8f/w//D/7v/v/+//7v/u/+7/7v/t/+7/7P/s/+z/6//q/+r/5//l/+X/5f/j/+H/4f/h/+H/3//f/9//3v/e/97/3//e/93/3v/c/9v/3P/Z/9r/2f/Z/9r/2v/a/9v/3f/d/97/4P/h/+L/4P/g/+D/3//e/9//4P/f/97/3v/f/+D/4P/i/+P/4//i/+T/5P/l/+X/5v/l/+X/5v/m/+b/6P/p/+j/6P/n/+n/6P/m/+X/5v/m/+b/5//o/+j/6P/p/+j/6P/p/+n/6P/o/+j/6P/m/+X/5v/m/+T/5f/m/+T/5P/k/+X/4v/j/+P/4//j/+L/4f/h/+L/4f/j/+H/4v/j/+P/4//k/+X/5f/k/+T/4//i/+H/4P/f/+H/4f/g/+L/4f/h/+P/5P/k/+X/5//q/+n/6f/o/+n/5//n/+f/5v/n/+j/5//o/+j/6f/o/+j/6f/q/+z/7P/r/+z/7v/t/+3/7f/v/+//7//v/+//7v/u/+//7//x//P/8//z//P/9P/0//T/9f/1//X/9f/2//f/9//1//j/+v/6//z//P/7//r/+f/4//n/+//8//v/+v////7//f///wAAAAABAAAAAgAEAAIABAADAAIAAwADAAEAAwADAAIAAwABAAIAAwABAAAAAAABAAIAAAABAAEAAAAAAAAAAAD//////P/7//v/+//7//j/+f/6//r/+P/5//n/+f/4//f/+f/4//f/+P/4//j/+P/4//n/+P/4//r/+v/4//j/+P/2//X/9P/z//H/8v/y//L/8//0//L/8//1//X/9v/2//X/9//4//j/+f/7//3//v/9/////v/+/wAAAAABAAIAAwAFAAYABgAHAAkACAAJAAwACgAIAAoACQAIAAkACwANAA4ADgARABEAEwAUABYAFgAWABcAFwAXABcAFwAXABcAFwAXABgAFgAYABkAGQAbABsAHAAeAB8AIAAhACAAIAAiACEAIgAhACEAIQAgACEAIQAhACEAIgAhACEAIQAgACEAIgAiACIAIgAjACMAJQAlACUAJwAlACQAIwAjACQAIgAiACAAIAAeAB0AHQAbABsAHAAcAB0AHAAdAB0AHAAcAB0AGwAbABwAGQAXABcAFAASABAADgANAAsACQAJAAoABwAGAAcABQAEAAQABAAGAAQAAwAEAAEAAAAAAAAA//////7//f/6//r/+//5//v/+//5//v/+//7//z//P/8//3//v/9//z//P/8//7//f/8//3//f/8//7//f/+/wAA//8AAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAABAAQAAwAEAAYABwAJAAoACgALAA0ACwAMAAwACgAIAAcACAAIAAgACgAIAAgACgAKAAwADAAMAA4ADwAOAA4ADwAPABAADQAPAA4ADAANAAsACQAIAAcABwAGAAYABQAGAAcABgAHAAkACAAKAAoADAAMAAsADQAMAAkACAAIAAcABgAGAAQAAwADAAMAAgACAAMAAwACAAMAAwADAAQAAwAFAAUABAAEAAMAAgABAAIAAgAAAAAA//8AAAAAAAAAAAEAAQABAAAAAAAAAAIAAAABAAIAAgABAAEAAwABAAEAAQAAAAIAAgADAAIAAwABAAQABAAEAAQABgAGAAUABgAHAAYABwAHAAYABwAGAAUABgAEAAMABQADAAQAAwAFAAYABwAIAAkACAAHAAcABwAGAAcACAAHAAcABwAIAAcABgAGAAUABAAFAAMAAwADAAUABwAJAAkACAAHAAgACAAIAAcACAAFAAUABQADAAAAAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP///v/9//7//f/7//v/+//6//n/+P/4//n/+P/4//j/+f/5//f/9//3//b/9//3//f/9v/1//T/8//y//H/8f/y//H/8v/w//D/7//w/+7/7//w/+//8P/v/+7/8P/v/+7/7f/s/+3/6//q/+v/6//p/+n/6f/p/+n/6f/p/+v/6v/r/+3/6//s/+3/7f/t/+r/6f/q/+j/6P/o/+j/6P/m/+j/5//o/+n/6f/p/+j/6P/q/+z/7f/v//D/8f/x//D/8P/u/+z/6//q/+v/6v/p/+j/6f/n/+f/6P/p/+v/7P/r/+3/7v/v//D/8f/x//P/8v/y//L/8P/x//D/7//w//H/8f/x//H/8f/0//T/9P/0//T/9P/0//L/8//1//X/9P/0//P/8v/z//H/8//0//P/9P/0//X/9v/0//P/9v/3//X/9v/4//f/9//3//X/9v/3//b/9//1//X/9P/1//b/9f/2//X/9v/1//X/9P/1//b/9//3//f/9f/2//f/9v/3//b/9//3//f/+f/4//j/+f/5//n/+P/6//r/+//6//z/+//6//z///8AAAAAAAABAAIAAgABAAIAAwAEAAQABQAFAAUABwAIAAgACQALAAwADAANAA8AEQAQABAAEQARABIAEwATABQAFQATABQAFAATABUAFQAUABYAFwAWABcAFQAVABYAFwAXABkAGgAYABoAGgAZABoAGwAaABoAGgAaABsAGQAZABkAGQAYABgAFwAXABcAFwAYABkAGQAZABgAGAAYABkAGgAZABoAGgAYABgAFwAXABYAGAAXABgAGAAXABYAFQAWABMAFAAVABUAFgAUABUAFQAVABUAFQAUABQAFQAVABUAGAAXABcAFwAWABcAFAAVABUAFAAVABYAFQAWABYAFQAVABQAFAAUABUAEwASABMAEwATABMAEwATABQAFAAUABYAFgAWABYAFQAUABEAEAAPAA8AEQARABIAEgATABUAFgAXABYAFwAXABgAGQAXABgAGAAYABcAGAAVABQAFQAUABUAFQAUABUAFAAUABYAFgAXABcAGAAYABkAFwAXABUAFAATABIAEgASABIAEgASABAAEQASABMAFAASABAADwAOAAwADQANAAwACgAJAAwADwANAAsACwAKAAsACQAHAAkABgAFAAMAAgAAAP/////+//z/+//7//v/+v/5//v/+v/6//7//f/9//3//f/9//z/+v/5//j/9f/1//T/8f/x//D/7//w//H/8v/z//P/8//z//L/8f/w//H/8v/z//T/8//y//H/7v/u/+z/6//s/+v/6//t/+7/7//v/+//8f/y//H/8f/x//H/8P/w/+//7f/s/+z/6f/p/+r/6v/q/+v/6//p/+3/7P/t/+z/7P/u/+7/8P/w//L/8v/x/+//7f/t/+7/7v/w//L/8v/x//P/8//w//D/7v/s/+r/6//r/+z/7f/v/+7/7//x//P/8v/x//H/8P/u/+3/7v/v/+3/7f/s/+r/6v/r/+z/6//q/+n/6P/k/+P/5P/l/+X/6P/r//D/8P/x/+//7//v/+z/6//t/+z/7P/r/+n/5//m/+b/5//m/+j/5//m/+T/5f/o/+j/5//n/+f/5//p/+j/6//t/+3/7P/q/+j/5v/j/+P/5f/n/+v/7v/w//D/7//v/+//8P/y//T/9//6//v//P/9//r/+f/5//n/+f/8//3//P/8//r/+f/5//v//v8BAAIAAgACAAAA/////wAAAgAEAAcACAAIAAgACAAIAAgABwAHAAcABgAGAAoADQAMAA8ADwAKAAUAAgAAAAAAAgAFAAYABwAHAAUAAgAAAAIABQAFAAcACAAEAAIAAAD9//3////+//7//f/7//r/+f/3//b/9v/0//X/9v/2//f/9v/4//X/8f/w/+//7//y//X/+f/6//n/+P/1//P/8P/x//L/8f/w/+7/7v/s/+3/8f/0//f/+v/7//r/+v/8//v/+v/7//r/+v/5//b/9v/1//b/9//5//f/9f/0//X/9P/z//L/8//0//X/9//6//z//f/6//n/+P/6//r/+v/7//r/+f/6//7/AQAFAAcABAABAAAA//8AAAMABgAGAAMAAQD+//3/AAAFAAoADAAKAAQAAAD+/wAAAwAIAA0ADwAMAAgABAABAAEAAgADAAYABQACAAAA/v/+////AgAFAAQAAwADAAAA//8AAAEAAwAFAAQABAADAAQABAAEAAYACgALAAwACgAIAAUAAwADAAYABQAGAAUAAgABAP////8AAAEABAAGAAcABwAGAAUAAgAEAAMABAAHAAYABwAFAAQABAAEAAUABAAGAAQAAwACAAEAAwAEAAUACAAKAAkACAAJAAoACgAJAAkABwAHAAMAAAAAAAAAAAABAAQABgAKAAkACAALAAwADgAOAA4ACwALAAkACAAKAAoADAAOAA4AEAARABIAEAAPAA4ADQAPABEAEwARABAAEAASAA8ADAAMAAoACQAHAAkACQAKAAkABgAFAAgACQAIAAgACgAJAAcABgAIAAkACAAGAAcACAAIAAYABAACAAAA//8AAAQABgAGAAcACAAKAAgABQACAAIAAAAAAP//AAABAP//+v/5//z/AQACAAAAAAD///7//f///wAABAAGAAQAAAAAAAEABQAHAAgABQAAAPv/+v/7//v/+//+/wAAAAD//wAABAAEAAMAAgACAAMABgAIAAkADQAPAA8ADwAPABMAFQATABAADQAMAAoACQAIAAQABQAHAAYABwAKAA4ADwAOAAsACgAKAAgACQAKAAoACgAJAAoADAAJAAcACgAOABIAFQAUABEADwATABUAFQAYABcAGQAZABUAGgAeABwAFwAVABUAFgAVABUAFQAUABEADQAOABIAEwAVABYAHQAgABwAGAATABEADgAPABUAGwAbABYAEwASABgAHgAiACIAIQAeABYADwAQABIAFQAYAB4AIQAgABoAEwANAAoACAALAA8ADgAKAAoABAABAAIADQAYABcAEAAJAAUACQALAA8AFQAVAA4ABwAGAAgADgAOAAcAAwAEAAYACwAMAA4ADQAIAAAA+/8AAAYACwAKAAsACwAGAAEAAAACAAEAAAAAAP7//f/9//r/+v///wYADAALAAUABAABAP7//v8BAAUABgAHAAMA/f/7//z//v/9//7/AgADAAMABAABAAAAAQAAAAAA///9//z//v/+//r/+v/8//7//f/7//n/+P/1//T/+v8AAPr/+f/4//T/9P/+/wkABwD9//j/8f/r//f/BgAEAP7//P/0/+//+v8DAAAA9//x/+3/6v/p/+7/8//x/+f/4v/n/+7/8f/0//P/7f/t/+j/3P/f/+j/5v/j//H/AwAKAAwA///m/9P/zv/Z//b/FgAlABgA+v/Z/8j/1v/5/xEAEgACAOP/vP+m/7f/3f8AABAADAD//+z/2f/Q/87/0P/a/+j/8P/v/+v/6//r/+j/7v8AABEACQDg/7n/sP+8/8//6/8QACIAEQDw/+P/7P/5//3//v/1/9//0f/U/97/4f/f/9n/zf/F/8j/2//z/wIAAQD1/+v/5P/g/+L/5P/h/9f/0//R/9D/2P/l/+7/7//u/+j/3//b/9//7P/+/wUABgAHAAgABQAAAPv/6//V/8X/uv+4/8L/2P/q/+7/7P/p/+T/5f/n/+3/9//6//b/9f/1//P/7f/f/8v/wf/D/8n/zP/O/9D/0P/O/8//0f/T/93/6P/u/+//7//t/+b/2P/P/8n/x//L/9P/1//T/83/y//F/7v/u//D/8r/0v/h//L/+f/3//T/7f/h/9r/z//C/8L/zf/X/+T/8v/3/+v/3v/b/9P/zf/a/+7/9f/y/+7/6f/f/9f/1//X/9v/5P/v//v/AAACAAcABwACAPv/8//s/+//+P/7//3/AAABAAIABAAGAAIA+//1//X/9v/2//f///8FAAkADAAKAAEA+P/y//b/AQANABsAJAAeAAwAAAABABEAKAA4ADgALAAdAAwAAwAIABkAJwAlAB4AHAAdAB8AKAAqACEAEQADAAAAAQAVADQASQBOAEMALwAWAAQAAAAJABwANQBHAEYAOAAlABsAGwAbAB0AIAAjACgAKgAiABsAHAAjACoAMQA2AC0AFgAFAAMAEwAyAFMAZQBbAD0AGwD///z/EwAzAEkASwA+ACcAFwAWAB8AJwAwADwARgBMAE8ASQA8ACgAEwARAB8AMwBIAFUAUAA9ACYAGAAXACIANQA+AEAAOQApAB0AFwAYACAALAA4AD4AOwAvACYAIwAkAC0AOAA/ADgAKwAlACEAIwAsADYAPgA/ADYAJgAbABMADgAOABkAJAAnACoALAAkABcAEAAQABQAGAAZABgAFgAaAB8AHQAbAB4AHAAWABAADQATABoAHAAcABsAGwAdACEAHwAbABcAFgATAAsACgAMAAsADAAOABAAEQAQAA4ADgANAAkABgAKAA0ACwAHAAQAAAD//wEABwALAAwACQADAPz/+f/3//r/AQADAAIAAAD///r/8v/r/+n/6//w//T/9//7//3//v/+//r/9//5//z/+//8/////P/+//3//P/7//n/+v/8//3/+//4//X/9f/3//n/9//2//X/9P/1//b/8f/s/+3/7//y//j/+//8//j/9f/0//b/+P/7//3/+v/1//L/8v/x//H/8f/w/+//7//w//D/8//3//f/8//y//P/9f/0//X/9f/1//b/9f/0//b/+f/8///////8//n/9//4//n/+////wEAAQD///z/+v/3//X/9P/0//P/9P/3//X/8P/s/+v/6//u//D/8v/x/+//6//n/+3/8f/w/+//7P/q/+j/6P/o/+7/8f/2//f/8//w/+v/6P/o/+v/6P/r/+//7v/t/+7/7v/t/+7/6v/n/+b/5v/p/+v/6//v/+7/7f/s/+z/7P/s/+z/6//t//D/6//r/+3/7P/t//H/9f/0//H/7v/u/+7/7v/v/+//7//w//D/8v/2//f/9f/y//D/7f/r/+3/7//x//P/9v/5//j/9P/4//v//v/9//f/9v/4//T/8v/y/+7/7P/t//L/9//3//X/9v/5//X/8P/z//n/+f/4//b/+P/8//z/+//6//b/8v/0//j//P/9//j/9v/2//b/9v/6//z/+//6//v/+f/6//v//v///////P/8//r/9v/3//f/8v/z//P/9P/1//D/9f/2//X/9//4//X/8//3//7//v/8//z/+//6//n/+//6//j/9//2//j/+f/6//z/+P/2//b/+P/4//v/+f/2//b/9v/2//j/+//7//r//P/6//n/9f/2//b/8v/0//j//P8AAP///f/7//r/9//2//T/9v/3//b/9//7//z//f/+//z/+//+/wIAAwAAAPz/+v/4//f/+v///wEA///9//v//P/9//3///////7//P/+/wAAAAD///v/9//2//b/9P/z//L/8P/w//P/+f/5//r/+f/6//n/+f/6//v//P/+//7//P/8//3//f/6//v//P/9//7//v/7//n/+f/8//7//v/8//z//f/8//r/+f/2//b/9//4//j/9v/1//X/8//w//H/8//0//f/9//5//z//f/+//7//v/+//3//P/6//r/+f/6//j/9v/3//b/9//4//z//v8AAAIAAgADAAMAAwACAAEAAQAAAAAAAQABAP//+v/6//v//P8AAAMABwAFAAAA///+//v//f/+/wEABQAFAAcABQABAAAA/v/+/wAAAgAGAAgABgAEAAEAAAABAAQAAgABAAIAAAAAAAIAAgADAAUABAADAAMABgAIAAgACAAGAAcACAAKAAoADAAMAAsABwAFAAYACAAJAAsACwAJAAgABgAHAAgABwAIAAkACwAMAAsACAAFAAQABgAJAAsACwAKAAcABQACAAIAAwACAAQABwAJAA0ADgANAAsACwAJAAsADAAPABAAEQARABIAEgASABMADwANAAwACgAJAAcABwAHAAgACgALAAoACgAIAAkACwAKAAsACwAKAAsADQAPAA8ADQAMAAgABQAHAAcACQALAAsACwAKAAoACwAMAA0ADgAOAA4ADgAOAA0ACwAJAAgACAAJAAkACQAIAAUAAgACAAUACQAJAA0ADgANAAwADAAKAAoACgAIAAgACgANAA0ADgAOAA8ADAAJAAgABwAHAAcACwAPABAAEAANAAoACQAJAAoADAAMAA0ADAALAAgABwAGAAMABQAHAAcACAAMAAwACgAKAAoACAAKAAkACQALAAgACQAKAAcABwAHAAcAAwADAAUABQAGAAoACwALAAwADAANAAwADgAPABAAEAAQABAADgALAAkACAAGAAYABAACAAIAAwAFAAoACwAHAAgACAAHAAkACgANAA0ACwAMAAsACQAKAAoACQAIAAcACAAHAAUABwAHAAcABgAHAAkACwALAAsADAAMAAoACgAIAAcACgAMAAoACQAHAAYABAAEAAcABwAHAAgACAAIAAgACAAKAAsACgALAAgABgAGAAQABQAFAAIABAAEAAIABgAGAAUABAACAAEAAAABAAEAAgADAAIAAgABAAEAAQABAAAAAAAAAAAAAQACAAIAAgADAAIAAQAAAAAAAAD//////P/8//v/+//6//n/+//7//v//P/9//z//f/9//7/AAD//wAAAAD+//3//v/9//3//v/8//r/+P/3//j/+v/7//z//v/+//z//P/8//r//P/8//3//f/9//7//f/9//3//P/4//f/9//3//f/9v/0//P/8f/x//L/8f/0//f/+P/8//3/+//5//n/+f/6//3//f/+//3/+//6//j/9v/3//n/+P/6//r//f/8//z/+//7//r/+//+//3//v/9//z//P/8//z//v/+//3//P/7//b/9f/z/+//7P/s/+3/7//w//P/8//z//L/8//0//P/9v/3//b/9f/3//n/9v/5//f/9//3//b/9v/3//X/9P/0//P/9P/0//X/9P/0//T/9P/1//f/+P/7//z/+//8//r/+f/4//f/9//2//b/9//2//b/9v/1//P/8f/u//D/7//v//D/8f/x//L/8f/u/+//8P/y//L/9P/2//b/8//0//X/9f/2//b/8//x//H/8v/1//b/9v/3//X/8//x//H/8f/y//H/8f/x//L/9f/z//P/8//0//T/9v/3//f/+P/3//b/+P/5//j/+v/8//3//P/7//v//f/7//v//P/6//r//P/+////AAAAAAEAAgABAAAAAAAAAAAAAAAAAAAA///+//7//v///wAAAAAAAAEAAQAAAAAAAQABAAEAAgADAAEAAAAAAAAAAAABAAAAAAABAAIAAAAAAAAAAAAAAAEAAgACAAIAAgAAAP7//f/9//z//P/+//3///////7//v/+//3//v/9//7//v/+//3//v////7//v////7//v/+//3//P/8//v//P/9//7//v/9////AQAAAAAAAQABAAAA/////wAA///////////+////AAD+////AAAAAAAAAAADAAQABgAJAAoACgAJAAoACQAHAAkACQAIAAkACQAHAAQAAwABAAAAAAABAAMABQADAAQABAABAAAAAAABAAIAAwACAAMAAQAAAP7////9//z//P/9//z//f/8//v/+v/5//r/+f/8//z//f///wAA/f/+//7//f/7//7//f/9////AAAAAAAAAgABAAEAAQAAAAAAAQAAAAMABAAHAAcACAAKAAoACgAJAAkACwAMAA0ADwAQABIAEwAUABMAFAATABQAFQAWABYAFgAYABYAFAASABIAEwAUABYAFgAWABUAFQAUABYAFwAXABUAFQAUABMAEwATABQAFwAYABcAGQAZABgAGAAXABcAGAAYABgAFwAWABYAFAARABEAEQASABUAFwAXABcAGAAXABYAFgAXABcAFgAXABcAFwATABEAEAAOAAwACgAHAAUABAADAAMABAADAAMAAgACAAIAAgAFAAYABwAIAAoADQAQABQAGAAZAB0AHgAdABwAGwAbAB4AHgAeAB0AHQAaABgAGAAYABYAFQAUABMADwALAAkABwAFAAUABQAEAAQAAgAAAP/////9//3//f/8//7//f/+/wAAAQACAAQABAACAAMAAQAAAAEAAwAEAAUABAACAAMAAQABAAAA/v/9//j/9v/y//H/8P/t/+z/7P/s/+r/6P/p/+v/6//s/+//8P/w//L/9P/2//X/8v/0//P/8//1//f/+v/7//z//P/7//r/9//2//T/9P/z//D/7//s/+n/6P/l/+T/5f/l/+b/5//l/+T/5//o/+n/6v/t/+7/8P/v/+//8f/y//P/9v/2//f/+f/6//r/+//9//3//v8AAAAAAgAFAAcABwAKAAoACAAIAAkACQAIAAgACAAFAAYABAADAAQABAADAAAA/v/8//n/9f/z//D/7f/q/+v/6f/n/+T/5f/n/+j/6f/r/+7/8f/z//T/9//4//v//f/+//7///////7////////////+//3/+//9//z//f/8//v/+//3//T/8v/w//D/7P/q/+r/5//j/+H/4v/j/+P/4//j/+T/5v/p/+r/7P/s/+z/7//u/+3/7f/s/+z/7f/u/+//8v/z//T/9//6//z///8AAAIABAAEAAUABgAFAAQAAgD+//r/9f/x/+3/6f/l/+P/4v/f/9//4P/h/+T/5//t//H/8//3//v//f///wEAAAAAAAAA/f/6//n/9v/1//X/8//x//L/7//v//D/7v/t/+3/7f/v//D/8v/0//T/9f/4//j/9//4//r/+v/8//3///8AAAAA/v////z/+//8//3/+//7//3//v8AAP//AAD///7///8AAAAA//8AAP///f///wAAAAAAAAAAAwAGAAQABgAIAAgACAAJAAwADgAPABAAEQAUABMAFAAWABcAGQAaABsAHAAdABsAHAAbABoAGAAZABcAFQAUABEADwAOAAsACgAJAAgABwAGAAgABwAJAAsACwAPABIAFAAWABgAGQAZABoAHQAfAB8AHwAfAB4AHgAdABoAGAAXABgAFQAVABMAEwASABAAEAASABAAEAAQABAAEQAQABIAEgASABMAEQAOAA4ADQAMAAsADgAOAA4ADwAPAA4ADgALAAkABwAGAAUABAAEAAMAAQACAAMAAgACAAAAAAAAAAEAAgADAAYACAALAAsADQARABIAEwAUABQAFAAWABUAFQATABAAEAAOAA4ADQAKAAYAAwAAAP3/+//8//n/9v/2//f/9//1//X/9v/0//P/8v/w//H/8v/y//T/9v/3//r///8AAAQABgAJAAoACwALAAwADAAOAAwADAANAA4ADAANAA0ACgAIAAcABAAAAP//+v/5//b/9f/1//b/+P/5//r//P/8//3//P/8//z//f/9//7/AAABAAQABgAHAAkACgAGAAcACQAIAAcACAAJAAkACgALAAsADAANAAwADwAPAA8AEAAQABEADwAOAA4ACwAJAAkACQAJAAoACwAMAA0ADgAPABAAEAASABEADwARABEAEQAOAA0ADgANAAwADAANAA0ACwAMAAwACwAKAAsACwALAAwADQAPAA4ADAAJAAgABQADAAIAAwACAAIABQAGAAcACAAMAAwADQAOAA8ADgAOAAwACwANAAsACwAHAAMAAwABAP//AAACAAIAAwAFAAoACgANAA8ADQALAAwACgAIAAgABgAGAAUABgAEAAMAAwADAAMABAAFAAcACQAMAA8AEgAUABQAEgARABAADQAIAAUAAwABAP//+//9//3//P/7//z//v////////8AAAAA/v/+//3//f/9//3//P///wAAAAADAAcACQAMAA4ADgAQAA8ADAALAAkABQABAAAA/v/7//j/9v/0//P/8//0//P/8//z//X/9//3//n/+v/6//n/+v/5//n/+P/3//X/9f/0//T/8//1//T/9P/1//T/9v/3//f/9v/2//f/9v/2//X/9v/1//T/8//0//P/9P/0//T/9v/5//r/+v/7//v/+v/6//j/9f/w/+z/6f/l/9//2v/W/9X/1//X/9b/2f/b/9v/3f/i/+D/4f/l/+b/5v/m/+b/6f/q/+n/6f/p/+j/5f/m/+X/5P/k/+X/5//p/+3/7//v/+7/7v/w/+7/6//p/+j/5v/k/+L/4P/d/9v/3P/c/9r/3P/e/97/4P/k/+P/5P/l/+b/5v/l/+P/3//c/9j/1v/V/9P/0P/R/9P/0v/Q/9H/1f/X/9r/3//i/+X/6P/p/+n/6v/r/+z/6v/p/+j/5//n/+j/6f/o/+n/7P/u/+7/7P/s/+r/6P/m/+H/4P/b/9n/2f/Z/9n/3f/f/+D/4v/k/+f/5//o/+z/7P/t/+3/8P/x/+7/7v/v/+//7v/t/+r/6//r/+n/7v/x//L/9P/2//r/+//8//7//v/+//z/+f/3//P/9P/y/+//7v/x//P/9v/3//v///8DAAcABwAJAAsACgAJAAYABgACAAAA/f/5//b/9P/x/+7/7v/v/+7/7v/v//H/8v/0//b/+P/5//r/+f/5//j/+P/7//z//f///wAAAQADAAUACAAIAAkACgALAAwACgAJAAYAAgD////////8//3/AAD///3//v8AAP7//f/8//n/+f/2//X/9P/1//T/9P/0//P/9v/3//r//f8AAAEABAAFAAcACgAKAAgACgAHAAUABgAFAAIA///5//j/9//x/+//8P/w//L/9v/5//7///8DAAQABAAEAAUABgAGAAUABAADAAEA/v/9//v/+f/6//r/+//3//f/+P/4//b/9//3//n/+v/7//3/AAAAAAQABwAIAAcACQAHAAcACAAKAAsACwAPABMAEwAVABcAFwAWABUAEgAPAA8ADQAMAAoACgAGAAcABwAFAAcACgALAA4AEQATABUAFgAWABcAGAAZABsAHgAeAB8AIgAgACAAIQAhACAAHwAfAB8AIAAgACAAHwAcABsAGwAXABUAEwATABIAEAAQABMAFQAVABUAFQAWABcAFwAYABwAHAAbAB4AIQAiACMAJQAmACUAKAAmACMAJAAhACAAIAAfAB4AHQAdABwAGgAZABcAFgATABIAEwATABUAGAAaABwAHQAhACMAIQAhACIAIgAjACMAJAAmACYAJAAjACMAJAAjACQAJAAlACYAJQAkACQAIwAhACMAIgAgACAAHwAdAB8AHgAaABkAFgAVABMAEQAQAA4ADAALAA0ADwAPABEAEwAWABQAFwAWABUAFgAZABkAHAAdABwAHwAgACAAIgAjACAAHwAfACAAHQAaABkAFAAQAA4ACwAKAAkACgAKAAoADQANAAwADQAMAAsACQAIAAcACAAIAAkACwAMAA4ADQAMAAkABwAFAAUABAAFAAUABQAEAAQABQAGAAgACAALAA4ADAAMAA8ADwAKAAoACQAJAAcABgAHAAkACwAMAA0ADgANAAwADAAMAAsACgAIAAYABQAEAA==", - ) - ] - ) - ) - self._send_client_event(ce) - def cancel_responding(self) -> bool: # cancel local response first. self.server_ctx.output_buffer.stop_output() diff --git a/ghostos/framework/openai_realtime/output.py b/ghostos/framework/openai_realtime/output.py index a19f9ab3..deb4ce40 100644 --- a/ghostos/framework/openai_realtime/output.py +++ b/ghostos/framework/openai_realtime/output.py @@ -164,6 +164,8 @@ def start_output(self, response_id: str): def start_speaking(self): self._is_speaking = True + if self.speak_queue is not None: + self.speak_queue.put(None, block=False) self.speak_queue = Queue() self.logger.debug("start output speaking") diff --git a/ghostos/framework/openai_realtime/state_of_server.py b/ghostos/framework/openai_realtime/state_of_server.py index aa196bb4..bc44f7bf 100644 --- a/ghostos/framework/openai_realtime/state_of_server.py +++ b/ghostos/framework/openai_realtime/state_of_server.py @@ -581,8 +581,8 @@ def _on_response_done(self, event: dict) -> None: # update history messages again for item in rd.response.output: self.ctx.update_history_message(item.to_complete_message()) - # update local conversation when response is done. - self.ctx.update_local_conversation() + # update local conversation when response is done. + self.ctx.update_local_conversation() return self.ack_server_event(rd) def _on_response_output_item_added(self, event: dict) -> None: From eb946443b54bd7a31961d613114622668c32c4e4 Mon Sep 17 00:00:00 2001 From: zhuming Date: Thu, 19 Dec 2024 16:49:32 +0800 Subject: [PATCH 133/148] dev: prepare docs and ready to test as package --- .ghostos/memories/.gitkeep => docs/.nojekyll | 0 docs/README.md | 4 +- docs/_sidebar.md | 2 + docs/assets/meta-agent-cycle.png | Bin 0 -> 185753 bytes docs/assets/moss_achitecture.png | Bin 0 -> 78872 bytes docs/assets/sphero_bolt_gpt.gif | Bin 0 -> 3980476 bytes docs/index.html | 30 ++ docs/zh-cn/README.md | 52 +++ docs/zh-cn/_sidebar.md | 10 + docs/zh-cn/concepts/code_interface.md | 344 ++++++++++++++++++ docs/zh-cn/concepts/ghost_in_shell.md | 0 docs/zh-cn/concepts/service_container.md | 0 docs/zh-cn/framework/eventbus.md | 41 +++ docs/zh-cn/framework/llms.md | 0 docs/zh-cn/framework/messages.md | 16 + docs/zh-cn/framework/tasks.md | 0 docs/zh-cn/framework/threads.md | 0 docs/zh-cn/getting_started/chat.md | 1 + docs/zh-cn/getting_started/configuration.md | 3 + .../getting_started/define_moss_agent.md | 0 docs/zh-cn/getting_started/embodied_agent.md | 0 docs/zh-cn/getting_started/installation.md | 55 +++ docs/zh-cn/getting_started/meta_agent.md | 0 docs/zh-cn/getting_started/project_agent.md | 0 docs/zh-cn/getting_started/realtime.md | 1 + docs/zh-cn/getting_started/scripts.md | 0 docs/zh-cn/getting_started/workspace.md | 0 ghostos/bootstrap.py | 4 +- ghostos/core/aifunc/driver.py | 2 +- ghostos/core/moss/__init__.py | 2 +- ghostos/core/moss/abcd.py | 16 +- ghostos/core/moss/examples/baseline.py | 4 +- ghostos/core/moss/lifecycle.py | 14 +- .../moss/{test_suites.py => testsuite.py} | 2 +- ghostos/demo/main_agent.py | 0 ghostos/framework/openai_realtime/client.py | 3 +- ghostos/ghosts/moss_agent/instructions.py | 2 +- ghostos/prototypes/ghostfunc/driver.py | 2 +- .../scripts/{init.py => copy_workspace.py} | 16 +- pyproject.toml | 8 +- tests/core/moss/examples/test_baseline.py | 2 +- tests/core/test_bootstrap.py | 1 - .../{test_configs.py => test_objects.py} | 0 {.ghostos => workspace}/.example.env | 0 {.ghostos => workspace}/.gitignore | 0 .../.streamlit/config.toml | 0 .../assets/audios/.gitignore | 0 .../docs/ghostos/en/aifunc_introduction.md | 0 .../docs/ghostos/zh/aifunc/introduction.md | 0 .../docs/ghostos/zh/aifunc/request_info.md | 0 .../docs/ghostos/zh/aifunc/usage_example.md | 0 .../assets/images/.gitignore | 0 .../configs/documents_registry.yml | 0 .../configs/ghostos_conf.yml | 0 {.ghostos => workspace}/configs/ghosts.yml | 0 {.ghostos => workspace}/configs/llms_conf.yml | 0 .../configs/openai_realtime_config.yml | 0 .../configs/realtime_conf.yml | 0 .../configs/registered_aifunc.yml | 0 .../configs/streamlit_app.yml | 0 {.ghostos => workspace}/logging.yml | 2 +- workspace/memories/.gitkeep | 0 .../runtime/aifunc_frames/.gitignore | 0 .../runtime/cache/.gitignore | 0 .../runtime/events/.gitignore | 0 .../runtime/logs/.gitignore | 0 .../runtime/processes/.gitignore | 0 .../runtime/prompts/.gitignore | 0 .../runtime/tasks/.gitignore | 0 .../runtime/threads/.gitignore | 0 .../runtime/variables/.gitignore | 0 workspace/source/.gitkeep | 0 72 files changed, 591 insertions(+), 48 deletions(-) rename .ghostos/memories/.gitkeep => docs/.nojekyll (100%) create mode 100644 docs/_sidebar.md create mode 100644 docs/assets/meta-agent-cycle.png create mode 100644 docs/assets/moss_achitecture.png create mode 100644 docs/assets/sphero_bolt_gpt.gif create mode 100644 docs/index.html create mode 100644 docs/zh-cn/README.md create mode 100644 docs/zh-cn/_sidebar.md create mode 100644 docs/zh-cn/concepts/code_interface.md create mode 100644 docs/zh-cn/concepts/ghost_in_shell.md create mode 100644 docs/zh-cn/concepts/service_container.md create mode 100644 docs/zh-cn/framework/eventbus.md create mode 100644 docs/zh-cn/framework/llms.md create mode 100644 docs/zh-cn/framework/messages.md create mode 100644 docs/zh-cn/framework/tasks.md create mode 100644 docs/zh-cn/framework/threads.md create mode 100644 docs/zh-cn/getting_started/chat.md create mode 100644 docs/zh-cn/getting_started/configuration.md create mode 100644 docs/zh-cn/getting_started/define_moss_agent.md create mode 100644 docs/zh-cn/getting_started/embodied_agent.md create mode 100644 docs/zh-cn/getting_started/installation.md create mode 100644 docs/zh-cn/getting_started/meta_agent.md create mode 100644 docs/zh-cn/getting_started/project_agent.md create mode 100644 docs/zh-cn/getting_started/realtime.md create mode 100644 docs/zh-cn/getting_started/scripts.md create mode 100644 docs/zh-cn/getting_started/workspace.md rename ghostos/core/moss/{test_suites.py => testsuite.py} (98%) create mode 100644 ghostos/demo/main_agent.py rename ghostos/scripts/{init.py => copy_workspace.py} (67%) rename tests/framework/openai_realtime/{test_configs.py => test_objects.py} (100%) rename {.ghostos => workspace}/.example.env (100%) rename {.ghostos => workspace}/.gitignore (100%) rename {.ghostos => workspace}/.streamlit/config.toml (100%) rename {.ghostos => workspace}/assets/audios/.gitignore (100%) rename {.ghostos => workspace}/assets/docs/ghostos/en/aifunc_introduction.md (100%) rename {.ghostos => workspace}/assets/docs/ghostos/zh/aifunc/introduction.md (100%) rename {.ghostos => workspace}/assets/docs/ghostos/zh/aifunc/request_info.md (100%) rename {.ghostos => workspace}/assets/docs/ghostos/zh/aifunc/usage_example.md (100%) rename {.ghostos => workspace}/assets/images/.gitignore (100%) rename {.ghostos => workspace}/configs/documents_registry.yml (100%) rename {.ghostos => workspace}/configs/ghostos_conf.yml (100%) rename {.ghostos => workspace}/configs/ghosts.yml (100%) rename {.ghostos => workspace}/configs/llms_conf.yml (100%) rename {.ghostos => workspace}/configs/openai_realtime_config.yml (100%) rename {.ghostos => workspace}/configs/realtime_conf.yml (100%) rename {.ghostos => workspace}/configs/registered_aifunc.yml (100%) rename {.ghostos => workspace}/configs/streamlit_app.yml (100%) rename {.ghostos => workspace}/logging.yml (91%) create mode 100644 workspace/memories/.gitkeep rename {.ghostos => workspace}/runtime/aifunc_frames/.gitignore (100%) rename {.ghostos => workspace}/runtime/cache/.gitignore (100%) rename {.ghostos => workspace}/runtime/events/.gitignore (100%) rename {.ghostos => workspace}/runtime/logs/.gitignore (100%) rename {.ghostos => workspace}/runtime/processes/.gitignore (100%) rename {.ghostos => workspace}/runtime/prompts/.gitignore (100%) rename {.ghostos => workspace}/runtime/tasks/.gitignore (100%) rename {.ghostos => workspace}/runtime/threads/.gitignore (100%) rename {.ghostos => workspace}/runtime/variables/.gitignore (100%) create mode 100644 workspace/source/.gitkeep diff --git a/.ghostos/memories/.gitkeep b/docs/.nojekyll similarity index 100% rename from .ghostos/memories/.gitkeep rename to docs/.nojekyll diff --git a/docs/README.md b/docs/README.md index 205f3bcc..ed48cdb8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,3 @@ -# todo +# GhostOS -todo: generate docs by agent \ No newline at end of file +> An awesome project. diff --git a/docs/_sidebar.md b/docs/_sidebar.md new file mode 100644 index 00000000..b0ee16d0 --- /dev/null +++ b/docs/_sidebar.md @@ -0,0 +1,2 @@ +* [Home](README.md) +* [Hello](README.md) \ No newline at end of file diff --git a/docs/assets/meta-agent-cycle.png b/docs/assets/meta-agent-cycle.png new file mode 100644 index 0000000000000000000000000000000000000000..776abf260a320a4a1d03a9a1e3c398adabfc3b3e GIT binary patch literal 185753 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%0Do2s7RX2r{I!)ps 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 D=8elmy@+%{L_;qEhAJR>LlIF> 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 &6cpBUp8|-B8 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

saPT}Ym#-+*rE(Z)BSh3gb(AbN4cY<#(*Xs# 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

uKuX>>W(2k7mon3ti4qhej zJ%L%^9iH{U&F>znxI)E{&-R39g)Io_0CwK`B57%<&;owGFEe4VRgu=Ki=Zky2c18* zA{JjHwW|##Q_twZ&I1c%4!akq+ml`@Y0=ELTWwZBna-(F2Ru;619(2GQ2yN2QpQeW=SIO;cFAy46qQ z{js0~sV1gB3-#bkBq0aM2x8oG?YD#S;Ek*G|CwHCyZyUEh{eIbMLn~VE)wE#>Mmf8 zr@q5VU@i%C%qivzX_*W45PFWql-*f;0=kB%Utb^hx-K9>(mp=MP@n%iWWgVvD;%z* z_-g$fmP3fppXz*hzP;%@Pt&8~|K8ctD{I;DQTodht}Pex)J#&RyU122(amw?!Qc#4 zS5NXwELh(I?D`cXRTZw&t{qWl6w>qgcgM+>JKbU~%SQqctpO_Iwki=N3u6xwfe#J< zi<1R?Lis7Tgd)BdAzd9$EvUY%D7@xXY?V}~fMT(ETjy)(R(^+tqTcis-?jk5V$Rq0 z+4yaO^_M^oxu4eDvM6pjdDe_-)CHoKb@Krb0wC8q0!uw0i(SGb?C&4=dmEaFK4%A_ z6xTc~q8)sIE|Yf@kkFSYElAu!=ZPpdNdL8Z;w&zqWR=%>XBT2x$exPkp^jy9%el}N zWW2r~_ks1x^UT-ybNS@#hh3umWjavTO^;6gc=i1nO3E@rSmE>Nwc=a$4L;g?=8ww; zFMN{7=arc@R&cs&eccKA_1$gOT@>uQtmC}^$0u7a{vd5fhm!B1bFnweisURETt2^< z_0smM?@Idf^04A#7~SlW;7?>VZfT!~F*p7D@44SxsK1fq{YC$0mv^(- z6G>friT$ll6D6?1h#xy?t@RF-M&JGnO_jVWanMz*XC0=!J!-OIeU&;mDE~x1{Pmuz zJ}qe<95x1@HO?$(vJ{H8=y!90$y$5xpC$EJ2ss+gjSC`-R?YblQM=}u!clWfL6oUA zCRfZ2u`0q-Q?;6HAD2jU5wRU)iiD5tT8f-)6`l|Y9NfiPpV~C zNIh)nHBt9|ETXPeAGO#=(``hTBRfN=wF!%Zth|D-KF1Dwv##aeH@eE4e*5Tu8UxziIlR^Oh3{B?fuJjV4tf820ax%7^4odEr=^BfcFef6>*NHXfbc%TGK&rn8JM@8D0 z8E|$##ApBmC$3#LVH_sRjXqiz#{OkxLvBSEM2w-b?lRGwY6pcEm^)G*y4M7}Jxs+% z((|PIqz$RoCMZy}=m&DPi0hJtif@nTU~kqJJm2-O*@=XE2HfH-OaYTPqg^$BMm6S^ z)ti0UlY_5Z)G|y&?e2GA`bz9Wx3-jKAslX{&N6WgnnkYKf%=^RZn_h|$3I%Z`VaXe zd*@fV{N&X%UL~*mXcanz-!QrK(##F3(R4p8Lyhlx_n4a4nyNjdjBle>NRIFdb%5a@ zKN&jrZ%63q)O7RZANd8WV3UGi+ky z*2^(*tfht3ZgonOcRqOx+$yDV-TJiR$y286`isJ@W>M;*-l5lY?s+3Sh#hsa=8v@> zu9iz`UbLAwlkK@etdPYzi^ng8iPx{%2ZjXLI9f+Jy~(wg8`qSW!1Rk*y?xPF0F^u# zyQanR7){;#F159bLC)O>`Y|?o>a6sW$_FyC>YG9j0%SD>E*75CIE{JeGIilHmqNtR ze^VLsJ@H*X@vXD*}tzM0AN_@JwF0ByK(xN6)8<*#kt&(8RWO>g^}J&`En1&*a0TW3Ce#D$az=>(auelF>ir!|?8UR~4Q*s#r)&gld!( z$SS6|U$ME?VJVzF_B6&ab|thvM)I#~c9MB+gSG2tqwoW`#9mormdm%~#=# z!f5+|Eoyy<4Z7r1L4deW(i>f)UxW9#Hl`4V6x*W@v@7D@lFYg(yO(wEfB%Qaa){j0 z$ygN_{)-gfTWkT}oQ$3muTJcfJ(<-|7y94bju-0J++xJ-x06fqXAfnczryT)l^wUS zGr4&0^{Ka)>u=rldlAKQtyR7d*b!Uf_+?JJeECJvsecANrIT8>b29p89~=Gw0RcYuzMe9=Xg2jPl2q$WNqrb?d|^BC-sh#!EUDgXcv@o6fLH@Re=)+$ z+BCtsc6v(mx~0g2vzu|YLygB6pVk1YX9dCcXn!WJdH{Xj=E&+aIvzTfux~wSigHb| z<9d99C)8K{uG8kfxYoA22J1OtMeaW&5+``rv^+jbr4v`gLPF#I@q_T)?|o;-ej(HD zg!|HP!I>v{9(1hVt@LEwg!+HD?ZJPOl`1D`pgwSzFmNk%JmQ*8aprCN5vd2eoErvF z{m<|G;JcMt%RH2L_f~>d?Y+7wv4Y8e*W(Nt7B1*Imc?{qino~^O0kf}eu7}FP#K-d zZ>3N>_zt6R8p6ds&j)lq#cH9ImxOkF4?AA)Gi+lYr&xz}G z;xD*U`o(wRoLV-;Q%I$;omn@QU`m~)Y8$VNQ`{TpCi|D*9jB0KgRb6=4&(^r;9s-n z+Vwb-kpp4pU#?YuBZRvJ7K!5yFD)!pI5`1_&sZohE&`0hz1H**d#!G!!$95=K=%@e zZ`GSup>)vwdN9iiefJb`g;ERrxb)6~{`Ro`IL{r*jy#cPb(C4VdUlSH2{xPt>7d`# zPsW^03ej1i7Q`dVX7klrw@hA3-1UNDC-J$(a4YEBg9X-^*8T(=pF2V83Pf z4jD~n4_yQ8BqXoC)1cJ<87_V;@mi;=UV6C^)9kTX(p;o2ZI-pRvg)zyNHnWhd+jLm zR>e2aL#)`5Ui_s>E~a%Y*MH4~PV1?SP;l0(0vX7^thw$fY+?D~4*Gq@MAU3vyo%^) z=#_R(Y#3`nW^ywN#~lzhR=sIg6P9Ajzh>JyR!JhbN0Z<#UhHQqD(n1Sc${i-%kB!mJ*Er^479LR z=MN0Ti50y)rxG}NPSF`^B)!uAJYRj(Vo^f#Z`-n;Oi*)aze=-guQM~$KPY*r9vb!U!x;w4Dv|C2&G`ac)nUUdf<;$stNQu9bb9b)nm#lhL_=)IC z$gmK|gBP3GkJ`?933op&&N;cL&F1iT7YPuc->$9)y*Q`Bk0xGy3;0=Y#oZ3l4h~#9 zua0mqSCtO}$tpMnTI6LDlUvhOCBb?AHVJ~EBcp0#p!X7Wr|-I}Y#0slAy~{r z<$yZ2v!a?M2@=@}s|%kk{32Z@n<9DJ^yVYT6BG9afK*zys$E5QLW>BmCUP|M^_KTtJ^XEc6{)-RRUPWpiIs~H3sJ7mHT@qHk zuQds^R5#gT3*xfCMqtNT*{F^51lb_jU`tE1ahtbHUh810cgaaKZ>U>f`hz!rmQt)E~@kW^Rfu zD)7Br#q{+*%l+a<* zL>KN#J(O*rcf2n0yW05E12kOw@CQL513c0tTMf)VFE96jX79Q=H+g5}Lk?Azrb$PC%RoWXm1A$c&REiW zr%P^22O;OomaZIZE}tFuTXc*Z$a||irl6C%^an`jQxH@4dEJ})*;g=Kll4?dx2sB{ zaIN3fSG`q*C0~{XYko8FLL(Vmuh<$Vs<0_sDX~wjJ9` zPs7XrGhG+nKel!^c9_NXMGz0>nwp1WhVcEpt@k@Gk6I!T1pP8q9& z4ad+N$=+^u72OOZc*I>Y+ADQJFgKB+f|PmWJ+==}G!EBvv-BPmON2+#jy zcxa!!seMB1$S_?OIBJ}7;ji{+;1x9%Kgy!xTm67qFXyaCOXMGo+SfBx{kywCrCn52 z`3IjgY^Y%Oc z*`LQ_HxcC7* z9}j-^YNzL<-UO8JiT?W5@SmD){{^vIUxuVl_lpn$7lozd{G}}eeSh_f8_2SBn$La? zWjUdNG0qs>=Ee$X&4mgxeUa4xS;)QM%|6KwFTyrx_EW?BAN%BcIp-nw8bX=u(Suq= zN4Byy7`HDPX-#h;aNNbFem4o*H%Hqy<4vzSovL#nto&NZ*{NZoe2 z*~WWq^}dV8fnom(?QdF*jf-=BDhO}0F;ity?2(My#p3V3ey$p`a@5u3U;DBb1KPeE z3Hlz14D|8P&cRS84}&(psJ*@4^*ZlM-mub}O^>&>Yre0W#u!^_5mYO==%9c zq=r&|*CP&5o!v_N8%B@Y_B*agjC_bcAOFvA^hfe{9J$}USY3U}w72AYCC4+?Sie7b z&=5Xra4nw5JNSAg{PG9k-o7tKF#X>;2ULy&%F(9Z&zlU<1`MkrlE_P!v-gLDiWs;& zUroks2kPaSz1f&Lkceg<0|s@K+S)G{c|u)3oZQb_&-;AmacqWFsUT)XgIoO7L6NLq z*N2x+hU;Xzl-Cg-y3ivfEnXd8vM(m*-vEN7>2QdaXBSbhGx#@XQ3 zLbKHEhlj4``ct^q{JGNIjMEkM%Bqp+isbkthsBgUEB$A;GQR%uzTIQca(zdpS>n68 zFIImjA+o_;p7Hs|xyPTE_aYx?CcoU{%aY~@KGCO6e}y^QDZ6#m4w^4%VZU~lyFFX+ zQxBTWSuug&^rYFo-jTg?$nxu|&bMe@o==a%Sa&Zh>R;OAabaQn!dPK(UfDQ)Q{4Jw z2ec<`->S?EG>E1q+8y#39IMOE_q+YHjJup`-|ag^|Bs>bjA~+w!f1M}RWr;gwFOFdC}IqlS$&f5>({Gxv&M0`}JKk-5>O!riQZ#$P2Wz%qdzoyU?r6^o6$zP3OW4GMQau|Jg?>%8Q#3m@< zW3Hxi8cI%HLMIsRIwN}Qn?;zLi6 z5OG&^p{k_Wq*9~(-K4)OR*OspsP6ny`_HO}zugFhY#gb}V59I|FA(@fm-Br8KM%$+ zp+lKwk#8F%@hx7JFoWIlD^KnRw9Qxjk!tt3>c@|=tTxhK?qCdAizvJPd1e*%m?=@6 zUu7J7W9U3S;t79K@i=2N_nnXH^Y0x;MqT;JI)P@L3nLtZ@QZ<6)*q2gzEAI6`tm7k zifUA%Ba7|+I@#yDcK1NQ;7=&>%9P}_g?%h(t@qI*vjJo5;E}K7#$1AuV<}llG*W2~ zrj%V)UVOz-{%Cwxn^kCGa+}6c#xpWDf>@=Y&R36^kQK~(JFZz+IykO{yc#gm+VNL;rRi#yT-iICqPII#{rhPH;w!~o72(KSNw%!!M%CrtoXNia54wmvSvVsQLzoOB$<|K z?x-fJ^6wRrnzE~{zv}&r^t6r3$Lz(O+k}pLe;BOk|B4u59kgY(S9Z+&xENPo#cgAx z6mRUiUTAqrvn|BqiFLsE2dKoY*M50dEmm{eMGr3NU@-SSShuq8%Y1qG<__rSK zjP#Dd3q^j=Ej;o5<&L($&i894=A#+IC4wuL=jP@uX~KV6aGs7Mu`>OvKu7?hU55O?5!EYv$6KI zrh{$j70-P%)!K`n-yDhy5rtkbS@#pDvv~V9oP1EGP3p+G4Z1Dn{?AvxjtpM2CF_mp zr)^%=mW}F=co^W&YCvGMjZDp>yRbK(2K{v?drCg>sB|Jq&-GoX*H256(wS?u4bwF_RWr)#+8Zn2?_5ryfSXF z{S(u~&nMU`L!@4G8CefrB4ew7wyV2EtnoG|`s;INeBpfGxmWM@XA}*uL56Zv{XrOC z^Go3#w{Gip9@BUIj;V<9y))wlGfGH@jh=7Od+L{;*G7hNzCt<~2sr+^b0w$vfqsdd z2qk<{;dFnw!6x<4f&MQ-jq_a!&)A<2409V4pRQGZ+JbpG*^ITK=t7=ZnZEUu>CE$C z;y&j_lx6!yx+AQOV82^5-)TA-X)0+0%T%pm+a*2XW)&-6PAH4!&$uWgw0p|E-X+BsLFP;|0vu`Xij$#hx3fX{}jRjh0+@ca@H|uUX;PQz8XRW$str zVOM^MiqlXZPqBaSebHT?{dVry*xG~FR90QwxEsF2{)&f0BTmEp;Zo3#vI`;EjrimR z`;HkQiJMo0jw!df7>7yhhN>qVnL)A`O*i7??ScZ%Al~%r&vHt$1M#Axd1oup64w%} zXRp|)1S76nj8T|&njY5ZWtF(+c9>K6u(EZfib26qC*QY>aBL=*kvgMag)A9zDv@K9 z8o2RV{nJ0hJq<0bH{2cG_jnio;8T)F+KY#WrM2E!GL#JK_ylHn9#C#6o7II{c;|)} z^T>C6HDg&k4r{0?5}MR3?#Wa6@4Ct$CM9RJNv0|%Lo;ohqhf3AGO(oGE^$%VeJr57 zztL^1*rYu%GO^rC0QR`qsrh?!q5=eeA`hec{7FQUtHfv+K+pR|h;HSv8Q)+R){vT` z5`k^%5-O>z`Xg>bj~wArz8xSiH74w`95iKiZcWCY4a5HZ$J#&tHQ&h*g*~NeU+(fv z+R)pjla?hs!8~E{AH^a4SUe$Ixtjgp;LkJ5z>dhFq1G+Jd|~x-ua-^qfbn=R-X$=C z2ms)JL!c-CAE3RLEC;ZG%K!j^518RpmTEs!_P_8w|BK$+0f+>;VSBr!o{3Tp6aN?f z^>c1*#+lu7JHPf%+*kC?UnC^2PFU&7o87Yr%Uota1=QFs_H0);xrqB@md3 zm{xu%{Xr+62XgEU{Z(`B{c?ap+LPE{hmtmoeqG>By?aVeym!f=_SB^C-vl~El9i&_Z;3`aIXT=)j9)uULE}>ZGD8$%?x}!-r_ZeGFig*^5!5)~AkUiGA z?4O>xjTckQNrE~=h`V6$wfWX$p@4))YCgy-=!+Eyf?LV=+b~r=WV&qIPzWoHpC2`) z??jZEo>PtR+HYm&0g*~fyBH8h)38@nDcfj;<-dXsTM@-Wvn1>k6AXF()-QyyBY@$Lw~vY215%?*4ooY{~5=h{_l4{a8Ct)G&S~* z_w}bo>+-C-thTn z_V|H8P!xtj5{Ucb5KILy5(=AR1KN?EBAOh#ZPiUXl?y8-i5n8@5_^uU=|gQq%wlFG1EWWsJog)%F8&`L*xb zM-kDv`dt1gB? zpLEfl)D<4QRYepJn zFstIjT$Zg&m^iByA}z-SNIQO2G|_Q&+vlkF-CO@0+v4GcQ;d?!sk7(&KVUTP466#% z@IYa2G2rG8R;V~`Ov0c*a0P{N4YSoAqQe26AY!`S&+5j#<>;OqADAQyG4{xkaZX~c z4YqSe5&*Z?x&w%NyI|&RJtyHiEAqT$iiwUN>1r2QUbTKr`(0;{XnE_<5qV=XrD@6Y zh7`I&Hpc>KnJ3=z4ftCyg){w(+H*Z>>M1~um%vX7Ebi6x4 zyS2jhvlW2q`QsRLpP{SM9*Ug)r_!VCNMy=tF-N z{7x+l2*U;i48hj7#w9%3^ro8Qji2691tgp9u%v@Sm?EoN!|z@SiRXAf8hT5G9lJ$dwR%PhT~AY_T;xBi>;f&A2=+# z1!f12TzQYR`DQ&4w&#!#Q?_2DDA+CM!yY34c@5dxGHKjwa;Uvp+O z_aXd~eueM1@B6cUKLPB5c`-Mf1U+z7H%Z?ZK55P2`Y&a)V3I7gdyJ6hlpJF$3}GJc z7UIbRt*XNY&nc4CnL<} z^R$-FT^yYVl=)Hkx!L$eV}qreqfF>{kpVwE&TC_zlz^a91Q!P13ejji%)n}o#K z_vugh%n=`VxSvVzlU`CeMLt?9fc=8kR{%2m{ay0_p8K|6+UfGk*Dr3~D$62$=Vj^f zk_vyMlf_qwh)VU(Q;9dbYpzw*2Pw=u7_1@>go@$qK@)=Rib2!?AZ3mNenIqXPa=OS@x;%>M`e4A zT!ae&*dUe8x#fvNq=TpuhTlXAAyz+z0PaRd;JhR3m00M!5s?S6@Rd5+bVBhvHc(mG z+Xgda>w`!knr#_cn;M#*wi$7-zPjvEK5Lw)?0S_X$2(=?LxwnUpgx>EXBE_rpfv(C z?V63hBxWAzAb;$1!Xvs(M!5fdYwZuA{)dL;_C}e{EUiLORzai!&fdi3NknURz%>au z*${h`PGIR4;u;42o6{~3W9wH*#P!2iC_lHinBFh4-u7M{^9_ucD!=V1J8(LzffnGXY+ zA6B(2#r-;G&pt`gXdjg*>iMrcVoNz`u3NHbnbjefCCd9Mw^r-ph~rVv=hd~DxtK~%xLCPv{bm>!ANn&PwnNAq|y|} z_ba*bzoPB=dmQN%fX+6ZzZc!-#96dJ`5_{oh~S$`!uBLoYZ%nrlq4~qdDzCs*3&1? zotQ^5>@Ej8VPznR#wyTM9P4&Z{-Yt6eM{CmrhRlzYeUFEU_J#loK(%gxQ4 z91K15!%&L_Jx~QM?;BvgHZXp|*!*+kFacUs0^wSj-K zZJo*i5663s2iZM(-bx0Z%RucIgypvq;)ab55poI$IZG1aPs0-RPag6`x`&JVf@`h# zSm{|)#_b-d1a{eggO9bZhK<9;V(W`Oq9(E5OrMkNJR5B{OwNyV$^EB(=%2qf$ei=l zP$5q&EVqQyJEO21Avfl(^*zGhixKYgMRO!90(RT(l1BnG$2@5t=!%gde#!i$QeZUD zA(a}5tk>lOqE3iI34^kCs zsLwJippljwYiF}hgw4TxDAFLq(Cf^T(cwoEzLibQV_TkveruCCHx{AbWC!LyYt0Kt zU{ACbrJIFCpGRnxZ+;QUy&=0Y?6z7V_jA?u4AP9(r7&$DG4WNQG~C=d>U2kVy~+SZ zg+f`@59}U+9;ARd(hltH$W$)WfDKefLjtGW_i&x>x*9A3iXTbjUU!V{{2gRLsD86T zi=a6i1b~eaLCT3h(@;IB6OES8rcG7HcL8+aCEy;}Lx1`jKM{2JtGG*%3a-0}IVyG} zKs9n1Xt7Zf{6_>q0ALbLW8$O_eXP|x=KIjZ757udzRuc5uPfOGbCPAP>3mxCW5m5q*~dN#2^+Sr_;uwM!_8Jw%r>4D zhW(PakBa!bb_g7qAxqrL{hj`yr+7pqaFzs8o7VjS0%;Op#|)93OZ8-2!PPZU&L!845p-|1StRn1yK1m_Eu>bLN?_9Lj8Q0 zMDa55j>_$lBw}cTni9|WGEm0u*3s`am)n3b?nht34G?(X_>wKvTP@{m}9k74D++!aK zy75Oujez7efrdw*8WZ)Y1jI=W?2mJfI|n}d4X>kj5bIZLF&*?+;%a$|(wQyP^$>}- zIN<@nz}pb<>qJ!T5>kh*rvPvI@5bJJisI^neDD@qni965*Du;CZ+}R?w&wWTTMdfi z&Pw0z8}>1a7B}m~j(CyIzav9;AjQsER^^s4$HxT^~Kz9sbI3-z`J~&!Kv8|3goiU8UTVD37i* zge?lwh#nBxL0z^$HSl z>3H(PhD30@_~eP3vaREsEsusJWHP@U5rISGE+Oeum?8;LFNj(~3-SpAj}k?4DJ7kP z$qJj`n>!*7+*^IQbWx(@hdFFVsvcQ?vSjwU_R~In?M$;Rkk3}iqCYbIi=o4iS|Ay0 z*NwG98xN*Pa~@y^%AGs4F4N2(t!Q5^tp{FuD}%%VKU4{R;R%m(pr0(*52~%HmachM zyO95Net9XQ0U0^B?$-p&p0vIT&S?cs0n1)hBnP=(Nit8=z9(d<;}~V09M6_RgOn1% z-+OwF*n-jp+beP+RlXo9+E820m?STFDj<=*B_iA{s@Q3vexr)tg;zJ?!8^t)e<>L) z_r5e&!tdY@3ZcGk@x=Jrdlm-}0H?efCr)vC=<*-NAp~NC0~?&ky#S%>&pKMy1Fp67 zU5Ri4z5qnW5w$}Qf4L$raVPDjVfGp)8!1SNFLK@&dHg?NH%HQV#h#7rX>lmg?(DFt zighjC_oVkDULa7DAhf$A!e0{Ep@7vmvim9Livj{%p8zmhGBX|lKgN%)b;y+I$X5&9 z)6*TnB;E}w^$n6XX||CY0gf1)@4x2mN_koLB~%<80t_--MOi-h`Q^DP0Q@UdXEpT2 zQ1XllwGz-92~!d%p(6MMMAGp4Y$}4m5qR)QltF;Tosra-vQUd$n~qDvz){H|V13Gd z`+=*H$NG@<)#%3LN@1Mp`r) zmJ6C*X&B)|)DDWM-%EY8Wprl9qznLI6h&?Dg8K48CFE@8%hb_s;>Ncw5VVBS+QO51Ig^C z?=}Vv{Cg}zXd1X~kCDd#Irk&H?ny%1K0j3o+#J=Cl-IpJby~PjH!%gZH+#LA3BQc2 zI!;Ao5D{}rgmg9mpR{KAJjh4%>(OdhEEjQh{cuIK$$o1K-pibBB7A!2;>EK1sx>1zo}7CIbP?~v9P9Pv&d`zshL|DW&y9NM`BO)oC8xUqC|P6dPAJ^k#1b1*R*v$Yo~WcZw0q zh76bx-jsn$>KiV=sBA5fPK+$vj~y=_uVO?AmaAXA)KmDDIa#Lc%fB}EdrehHQrnRK ztMP&5Ysgvp_Y*i0-D00gMuTA@mq zTrytfphasP1H@RbXBnEiD`6Zok#$%~Ku&X6P?ft{6$bF|R%O=}VwT+waQ1Q<=dWgq z+~bK(E!D`X*nP?;=zOjw$Wfy65YkX~MjR#d z_J;jZkLO>n)&!IucPq>k6YI2c*-OzW{DRhQCva}fZMyJDM#L8N_Q&m&;}GL}wCJjk zp~{ic4AbAN;tWF)giZ2l&%EKg-y45#d)ol_f{)s1p#u455JZ41iUWYknflUL=xsPq zO*2*<1VdptbV-Chd6;EyVFVMy7b8@tdtv}32^|m;fwMg;+ME$~Hk(==-{09@6K~#5 z7dvY@BH`=S7RnH63~G0|CG#~;i~CX~Nlo?La}OusLMP4rIxk*w+A>@g`xp|t8~J*d zZv0sT-`AYx(C>l%MPD~q6BLXUekmu&ck3v*S*V{o4lvao{Ok-R%L@bdv03DP0 zO6pUeo>tDiS?zfZrugaS075a2O1iV5doCq+)k(+5)Vsjo(`B1ka=54^;PH370H@m< zDk4`weF_y%4WVl{|As51a&w>(PA;S-{9MTb17o{OQu&!dlnu`}xJ>}%$k4ICETn!a zZ*P55>K64_!T!CsrBsRsI4Sn>LNiopXCEE02o1^1*qT~?gZ}s9zxupn1XoE6`e^;~ zCnFFaT8u8Nqg_-GW>ZUXKV5fO-&x6-ZVv3nFfz5Y$&l#eQdA5S>9wj}MmQwgemH?{ z6$dpp0Ue!Xo|%TwF@URlNa%-31fjyP6tANXGkjKltw*XwdelWEZL)frVbtW0iwzbN zuOxFU#X10RLiS{tF%dx;z6yH|eA%}6ldXs)9T!OwZ&CQstAQ@`&NmQVncYH+84ZJP zn3J%Pl+5)Y;)&_M51>x~wKHuX$yO>5JKQKf z)bc-?$V~`$%uZ4r03Z-; zJ-Nm-EYaCpIok-hj9t0WTorV}F2>l(NJ2m{#RWJ68a+l8W zi+=`Qg)Fre{&}C27ekgomwSxpyhXV;y2a=^T@BWXA|fCfSE`aV^bO3KHXXB9s4{#SMtCRLjQ>bH(+$66sXnr zZaILew{1=qDdj;_)d%djj7W`y96}+nF+@pp+;$8O`HG(mL+2q~)PAz%)1cwxDgpjP z@@(e0#TCV$iEyJ*Ht5V|rc8Mg)8IL=z_|3L5EBPAY9-~07%qWL*J)TE=iCf-J!V`b zE8+My?Q|-TjjjSTa>!P}vnJxuIW>QT#uMP%eJsHkz{Mje|YBveG zU*(uLzXHDUeW~^S;eDW*E1$6rA7}z0x&?x*!6((0z=wFsa#v!;G&%wtJje9>9ZzcJ z6ghdDH@{VECXaX)l%c#5>qq^JL~uSUkXH*KFy&Mcgg#*v#KxCYH&T9n;*Wqb`#D0N zwpnpPq|6xM0h9}N5KW~(bl0d4;hl6XejHa%B%k{>??}Ed_x%Wlrz&=)V<@pv->htd z>iCg%K>uT*Q%@bzl&5X987e{weD}Ug*(!H`Hhq=MQj}ZH(dS&J0(UO_-i}hR zpuAL&@y`6XGc+iLr8R_vWGc(m!LXDQ81cfBYu-(LfVrqBCr6T-g1!5CU|B((Lr=~ zx$xv-rfl0k2UWyjTmnPGNeiOd+|p1Q5_A$ z%|GfT^xJX3s(_eGi5LPzNsxpLehd`g?V`2(>mZ*$7(VvA`(d(K@_iW&+N5!JjU*d( zg9+y_k>v{3$Ru8sCv)rEl1@y&n8YD=dYh}~W4XGoB z!J&EXse@w7#NBU}GiMK}Tg&mMvos;1WZR|XlW7*OG-NOW!NY?)f=7BgG55Tj8igfV zr-$1-q;S7p%oMVTS-3%3wX6N!PSgEz$uXK-Do|~@`%VMRIJ@8L;{j*c{u*VSpBfbz zqUL#~=6Nx+-<6^{UkXOd1**A7)zA{DdYrHyPzwu^3)w4H_Zg34u1)8{W%iY(X-`a3 zMf}8s04?!XawV~Jk=9%qnI?kI!K;Jze)X%U16t@zJLju*VDmr<-6@qO;deDc-{^EA z%-UIJT+FaS*>Ex!h~VT1lVwJ>b(@N`e<)S+GjOztZTL*yQ0;8tUzxU>(#ndxHnIe| zN-AA>$=Dfa@*fuX21^U-v`o&UYf#D{EA{HzvXkQ%pj$xe)U1qN))^ej<>m0nUicx% z$f0@op~+z;n{}!J77l=US1{<+3_nVi-wxc{6dGW7KSSN>Yc(`$1>#DD9LcwG_q*uL zZ3rUE`?TI?Q5*iefchb_5(#~K6X5#@Rn}sc?irvAmYAhMcU$RFW~g;)T@6sG5J#ff z>L3|jdnLPKIN)|hMaAK-YT6`$``m#mC-P8S`Z)w_P|K);T-C(AcA$+afd<}>Degv$ zH8=`r7hSIItYp;~sE;ELXyoxVpADeMz)?S-)WLp+0?U{ z6V@Fx2Z}~{{Y6AYBQl%nw8L`khf{jtdstiFL|9@XEQtv3@p{-T4!>y&PwU-791Ndf z!_&5eXF^%C1elL@)?6LKZ!0Twn-!f3%WBu#{>$>FLfrja$hgt@@!om-D030&HD-Ec zqW2IFW`=G4@caI)F#SLf((ezufSV1B2kr?g^9h#+&5PI^WQs+0<`}Q(jDvL2Z7{FG zYz}jvL9nUyHeag^V1uxkRD$Aoxos6PC0~U4)Qelp#4h%gn9sG0Sywd-xEX2V_3 z6QeoNzX4z60J{;&_z=fU;=ia>r3JbeLQ&@OK))5NS&(NygT=Ia&6B-TcNY_X?TT$i)bmTH6qrhXx%z=_(9sgYYx_l{*Ru>~N<7dUJUKwX^nPKwv#tpF?nY)*s)m`m;99u#Y zLpWinz}izVEWBb%F&!#S`~CBHh5)%a%|By84W*-gFNTn z@}0V6E!c2Wo(A7$z4xc$Of&UT!4_zSBPB~JYbF+O;Z!R;jmL6{_f2VKec8^!WHKz$ zts>K`ejW8tS%8G)GcwSltg+sV#Voo|*3{W-L}JdZucVV+uKiVrz=?M4S6cjxq$$yH z4|FFHw1Z}HE=%`aZgd97#dlog2-a}sGRQ#S4!qJCn3$Y*V~KM)Hd~?WKiS>u^?fpojSm*YP#vpyWA>XAWbo;huPY%td_DK}GYVQT^>cSQj6tm^j; zJOJq=O#u4qOen6?@Tu8Bmbz-abOo+S{db^j%-wsPa;Q1NWD*rYp(&y>ZI)nB|MtQ$ z@Ms}eQYuu>lwM!+L~M&HNS@BvTO`X4635a6d4(SVzWGw5S(i?Cl^CAx}%s2^k|H8}*q-2rjnF9cPfEP01=g(3y9S z`1qqo{xET0yWW1!bBL}Pg*UB-Fk2yeo}(_E7s^<=7BMjJ@W9@dlfpU;S>|20ss5gQ z)4E3(LZfLDAe3J1hjBj9C!hH4u{*{}k5&5!@Br{Fsx(n)y@K{;?#VV_?*T!~2eTsm zU`Mv$N2bG$Q0;UJp<(KW;}P)CB31^+-GIsB$N1TK_Ch12{lOt52|!IY{Z~`L>6gP2>?>{TV3-8DTPGXU5v2Xg=e*`GO-;>1W}nW zV&&MBnm@AKkt(?a(7{($iqgsofH*`q;h{b!7Nv<~U#7KeY2w?G^4?igRY_NJM&_%r zOXhQVQchkq*d+)r*SXB=So&?4nsmgS92AeHH}lAXWb-Te(0}5kf8uSr1_UuNbXi1b zy~+f0X<^S9DSpjFqL$JpG_gjY70?&{x-=lS0D7rVm+p>}F;9-7Sw{gZcVdvfIn6&I zP>ibp50Mff0-5$SMQ|;-OPG&FfLv3Vk<-k`ZFtOLOcj+GO=U(>n2}UwF^ri&W+sk3 zOl^J0+9LS3W*yjtS2l;gx*I;$h#Y&F5GwchkOQR7ByJXP;VQY8_aZ4Wj~dk!aHysw{6{C{@O=Xk4c<|I7zmQ`^Ts z@q$2oSySv<{p!tzP2*c910|xOrI^4qgqj4GDjI^Xs*+2-LzPL*a{kLY$^P$XFKh5z zmhU!gj|8W35y+Qa2dYtNu2m|6+?N9Au8dR>R4M?q%@V8tL*=v2{pj*y<$0-l} zDQX(x!bws61K5HksW%~t`jHhqFGR00v0qE6lBVRFbDu8|24lLeG7;SwF*IRwRL#G* z%NjJIH%iMf5?s$Y+wT?lTJf@Xv9`uD)fv(6!$p;S0;+?>l?0ipkeh8bH_JN3kT@H} zc8j&UIA*ri@{8D?9-t2Pg-mvt-+1YQaP#;xD!SGLO8`oz7P^`b2a$$bI;Qv6x4Co8 zTvb^7jP2+oG(0gCdKgOs5W_?k0qNjl5B(M@% zKiS?o7BlvV#e+t2VcV}`A_1`>?5r3v^F&DOsS$YU7&CzA@l$v47zO6x3){9o>8Qy3 z8-@Jn2oS@@JX|YRp#kh;eOLwK`)?6!Q{FRaUvh%tB7Er^8eDoIHzHFR7f|eam%)N~OsvOUj z9|Y>5Y5nI2MM=Vq^mP$Sp}%sUC~*; z?dv1=v4|sb;+peo)^&!-I}I}i_W9xvgtkTE^oRQpxGH&@S3i7T!Zdv+Jj_m5=(wCey zA?K9ciUWRIo2<7wA@AzXKt2wVW&C_e0zr`YAof=Lvf|ULc=FC1I&6rUA$xug&nIV^ zBV^f=y~U}A<;xg1@9A^5$QP8Y&byRg|GJ3~g`lMYD_A;V5Fuqj07BpW0}o2_iQNTe(-qxRGkBh=eQ3b$G&F#MT2 z4U&PU>B)0eKBeB0Vi7;knL?%=Y>)td(|a(O`i>~xz{1-uhAQoR1BPbFH*Pd3o}I2} zVQDmz{4ORFue8E7-Y>RYl#i%{F%kgnkpl?24TOgTjw(;*$^_R48%|l2 zOc`%x2Z@ZU9PJUh@Hocc5rMziD=}ativ2wxU^>lVNc!+*m=esMB=kjFp{93My2x|$ z<#xyd^sEc%$06a1e^Pi={{Q9x-11zml>fP#?#Np5f8Q=xHe$oFur7p|mY@@_7OVxd zxJ0jDjUIr6kPh0oqN(gW+q#B@)ZJ(*lz83Nm}t(ta^;HdO?0zT#}!k!%2}Q){N}wY zsmCz@**yrt?n?XhJ3nsI-VN669!l=m-#!8(oIO1)!k5!~YVF{6G*b`|Cu@+2sQ;DF z3%BI*Kw?xXNG;G}YcJs~_g%Li`urmR1Qq8UN&j?s?*QS-2L68N04lJa*m{fJkI%AI z8v1gfby8nKS%q!PSE_AYHc)zhA$hpCQz(ZwWY3>Q9H2ZAEbkmA>#jQN#}BmQmMlTgBhK2+QZh+zcvR z{QRE>=o)%vFE9(5T2%e&lNxq>GxE3`;N`OUf=MI#ktbpq(2Vdk6-}M+>WFhe^7JcQ z+cY7K&*eIDhs+;q4}&m!j%MLvIZOr4><)h5IJ%yuW*7=K-hOl{?)05ZLDFf<#X_MF z99Uw{dP8_2H7RMUuq!Bn|M>O-u(n)r!*x$!gt!bRQ13$HKP#dLkMHR&0Z@>5{2r=Q zv!_mwACuaEmki4`lKfi4*i4olFo18LdR3GRmRDqhD$TTY4a=g z(rs|35SYQm9WdN%yLqZPrAf+*?X0+2JX6?AYPJ;8aLXGov7;DWQRQiBxKJUsys8fQ z2DwzC5U%;eRAgjJkig8Z@e zHGg8dKKkI*xH6D9fCSS_(L3HVX1#(NBX!gXMwr@OL?X5Kmli#g0Oo|@s{B~yDfMDm zj0&mT#r!4rhPa6Vpnf2-I0i>jWnVArYn1*2O zg=vI9&GFDq$mQFyjwY&C2%|X$n?^ydCZ|0gFmMK#)B;(XTQu{y5b&^! zo4l;2myzTTa3YTet5pp}o`}9;L>PdLu8@&A?WueM&zc?Gzwu8Qz>hTgxK9rabgx}2 zMY!*!F(Z9fv#wh#%P!(>78RC}e%fw1XNN>;tY&gVq$jX@re;>X#MNxOgTUFs3`j!; z!$#^q3Ir7KQv`-y0_*Wef=nVqLFHFM> zu~)rRLBC#Vcr2;VCD2e#RIIb?`f?#-b_HXW13V0~15-zF4cX?UrwmXRGYGkJ)z7Pf zcZ*sHwZ8iGpt7)Db!i_Xx1+^HRaXr5E@TYzx$)%siha|ZaPR1eVSQ5gWQ@Jr9UNq2 zd8*TqLWgIB6y-&reL7`mqJFp`zJDWBPD9sQ33BbjZ@#9g2Vln2IcT}PNM zm5v=feM@TT?W*(z5ZH#MPF5;LW^!JN02$Tg^K= zDgTP=D-CT0wf=%OVhwj))1&FI-ktHOj6RRsLLF4dy|(bK$J-(>t?FmL=+DyF^|dGY zA1#>Fu2=po$%2>n^T8tFsG6^7g(7djo90~HYM9i-V%CN$ zUv$3Zc=;WR_>oQD`V5QA@b|dA#B_Zm2AM)y{m;Ns*U@yJyK~}lq@0x`ilXx(`8liWk80jETZ=97vfi{{&3i8l#83iq1{U4PZlc_e~7)td53 z0t#pP6A}IHH=gApmjFyh`;V={3Q2%BR{?U3RGA!+!$}o~cp*#P9&d#Xt({v`)gh`` z0dll`qCVnfNf^>3 z0W=A9(jjhELfstxeGX;5($kjd6JwV+GD+ex*LrhQbs3OJ@k_&0f1aK2OG$y40V}q( z3%g!|;H6p<_LyxgyPYoq81Ur@NvDZhGh~9*!NOZ#>8xXDYD$@RGzbP1yrybW#wEg>&i(p1;-d*SbZgtQKy=rAkKZIK)x!^S$ zv!xWC44#7&n@JD3xC|U#9gNL%@Un5pM~qlxK1`Jan-YNlVz{Ba==`L>FylZkSlDYY zfL(A&4r1g-DreWY|rXTPcENLC_-gWU)nXpcD^4l)4Dk_(v9e#Fh;tiH5Vf5$?S!lT?m)tbr zBE8a9H-BYOK1o92xZ%nNzylg^LGjC|d*7qvYygt|cst%Z+uh?U&)YmVy!YQqP!cv{ z>A`_q?;Jb?z~c)9%Fa2St9z)6@-BG&=^ZT<{+1t0YO4{YbFwZsuoI zMriDiLCVl^g=c@i%i4c!ijpmub7{GsC?XCN8Y`IbZ_zm`q`?LmQ8F_m z2cEgCc|RIP=uEU6o(R@fPkScS6f|>QBe#(w6Jb#pOmJe~d)(ddI(iY%kHVW&X zvFhbnZ3j&3U5O%ZgkMKUT*Q}B7zP^V) z_slQx{0oQwh2Ls>n63IaGXj8`r->U!sO{J&tEeOh50b8dNVT=rsiA)w$rV>#OQqKl zsKX8^L)kmk4(c|a^<2!=Ta?BoN95nY4CMs`2Y&p6;+>|^01^i@a727yUvZ6R5XFPB zZ_Y*Wp@gd-Le;tz`CEr^27^4to(~?L@@0t6uTE8*4cp`C3uOC<5@;M5&6bnT8@JzN z8LwX{IxMb%Te4MOCcCY)mM?S4n^w!i8q3e#m5NqzxQ;DPtZAi4`gP&p3O8Ceb}}W! z;o;BK56eRsso~lF?WS>BQ3{X0U;*b58?4wU|4MAJCeb4O#ThvfR>6XwYA6gsQX%pO zt%!8>mmjS{=<2qh`+p$iO12wywHse%dG!jU_n<+gO{qV&ANyXDQbRu@ktc4x0IX_2 zzi7N9szS}O60+C{Q%_nR`5X{}{*-{GDHM2MsNSuPQ}4zpaU*U?c4`-?wLY(`xzysV z%M1m^nb=E*7_UMY0a$rX>Lp)^!xJl9Xj9@$c%u9&#O?UbmE}dJrT(YuFYOJ#!n(N7 z++qMqVfa`)RWb(HMtEU*eCHw97d3^qXbI#{; z-jDM-@7MG7jiO7#KZ>aCQ)!d-<=^~N6;Ag3_Bkq5)db*oRTr2~dKz>caUtKM=cWr+ zSkPHydKeS^#_MZnq@Z6`1kf8bMR%W7IQ*jMT*L5Tw!N1EH&ewbdgqTQiY`%<&kuwI_-WfPW zy)piFyp(mnqV%nnIm8@QWvU>2SB2Xm7r&UzVhJ z$Cn?YB*h)DvUW(6<`NDU^4_Kox1;s=+~JC2oR(|HX7fx?xv}pbA>gean zKi06|PE=**NON(l3_n8V@ylBYVw;9ha;e{C&t3xup@MOjc>ZFo3O7K^>|hR+2N}Gz z^#S(UW?5nGZGl4^#1`>dLU0GuR^|-lQOY_D2K|X7V@MgJccDovLbE1yWff zSQ0psgbExxV9U2j29K_hz15Re9M+`-VhX@}J5uN~cI+qw6&xEy7 z(`5$@q}sjZyj`SqZ1(MfZynErZlNd`$KWt_JvzfRpN{&)6^@*r_iYXSlLmAJgn;UN zw$(S{H;5MuML%XeNS*_sM-MH5)##!T#-D#s0jQ>4Idq?*Iw4Bes(UB(y;7a7{8C_b zfF1{=iD>H$xT*g#RQF4Vt~XxN;Y?(x$l)FbSR$VQ5!=r`l<8M(9`zL%0Yq^ zvtO>N%J9jW!OIw3AH1Y7A+oPQJtS3-BO`i(yN*Y`=Ddp6XutmUZ1u;pRg+iC$?M{m zvm~^}Bt_}ZT&$m|u)KT#hmHx)(qI$z30`YQLd1`c8=MM0HkSJ1)j?C^WaD4K@Kb*d z8YfG=l(gs4{+cpIFMSQ?mM(4)_ds#@YVRp01h75c=hWhOQeMVhO6tKh@O^PJki3l&0Q8O=QKUUXTAe zJkH)xclt(-5fHfoDZJ3=sw%w)F_NL*$YBBwE`)rOV$AK;zyrVv*Gt_ncnXH@t^9&C zwtwcxvwxNs4;9xQdQt28=a|W#QqOWPGysS(@hiJsE@Ki@`tH#8Y7&w8aXsTy z*H+fCgU7c1y!mm^%_O?aB=t731$_6<*FOh8!T#<`y!m1;CqF%EDO@`UqVN(L{iZEp-oHb?b+OtMEqXWfxBI!&~uySJKbgFz~OzgiQR}xCxujTX)jwOnrGeQoT#wP30u*Ac>TZqf#YXf6mg}w zPWz(1G^B*ooc|g_2-!!^rL5$#@qyt>` zLaMi2$L8;x@tkEGKuojkYOFr|x#w7Kc#q$y@ulJGvHiz0TDLz`)sE~?j+>WKQ>Y0dg(|Ky`$MI_q=YGX}8vi=3ldtfI(e&#m z;b)GFj)(%=o~I1_Fm`pX37uyO{K&@MY;9bX<;$l-TX(GZ?S-OQ`)hJJ9`%_F9|1~MKaQ78}SmOJfk|XMGw{WYr zO9j7rt}YO>6PG3SFJzb>v3VzPk?doW<+BgnxNzIOa1(S4u!4 zo&~-cJZ`ppGnl6HrRo5HnNm2d`v7uN#q~e+kbHV7Rui!Q5zJHN-l5YE9C^OKwzNw9 z<7Nh;=ONluk+9BYoez?kB#b+mHi~}mLkTO-n10m zxAdA~Ux~y4X$@3X5H?|ncSy{?%*PIXN(PvAEnv58SR4WErPssn(mwxylc8+RKbgv9r))Ot;=Rn3 z1c~VDotrgOO4-_bYti}3F;F1C4!V21CzUpME^n24aim_16$UAijJ1Qx(XbL4X=-YV zW!rX>wcoGxhwz8<`z-(&3LGm$ybQ!IpUy-NIoV6RKu*jCNdTk<_Na0kgzs{=>Q)QN zZi#GPPh4;5cK@E|ox^3uCQN;|C>Iq%wf|C3>EO9mfRvOj8aDV z_atNA=EC(jV3SOOrN>1o-GQ_ZAl6k6h3a^<`>rvWYVp-VhqGck!YXCj)N)FKC6~PF zu{yub`9x!l&6E?Qq?j&~MPuk-LBsdi(H|4v z-rmvM@#H2URWkkfLj&eE-L_}zzg#B8bPlHM+2{;%CXC&G&Y(%6hc%7Duj`k5(r==p=|_`(?_UYbyD9KnC%|jQ6#ow zsTqOn1M}CpHO)L_yNX@}m2M;%R&<*N@_@H@vc#EtSq18i4nCPKqnkB)tOX(3((t(A z$lklD_ZNiEx(PpI7r7sD$}nDq!3FmoK-oDzAy0QXDa^CDe&1(8m91_E@ZPlsVc$;Y zSZgoa#OEj>a3sfK+=xUhi@(DPm1c6KpqYFuQ#Yl3oF(8118Zd`B$M)wb=Nv|&a9vmQ z+`OQBm)R$7cZ?p7?u8%j#bRB`8tr)1m4(}bAz`48ZxU3mrx@N2Tuiql-8iDpY+fj5 z*fj?%)kr^Bw%*_3Cj5w}ulSt}w;19A&8V0}kF)sQ78YBeHqlOkm}3|Sbt?$cfvWNO$-C_^_E~uQJ~9Mt{*V6@ zer%@CI~jtGJmV$_k_WuK0R`EEer6mh9aVBxs>JQ4$=Lo;(S%FbbSRbJO3W4kaKfwu zw!ThPbDQ4p1IUo*qQOl_X==OG83$*8gLau8eKR`%H>p|gGU^K-i=QtVkJ#%}cp=j3 zuKgYLm}-$QDpCn&fiSl-$Ch)t1`ac`b5Hr}9ZRSVD0cf8U1=J{h&VlLTP0KSgfo^E z7^Sz8WZC!nuKag9_};gS%jd&-fl8S8Fn2oGeSwQgjACNrVp<++kaG%czD+kjc(b}8 z+I{uz>xO~z*B+Y;nvSak-}-%~=i<*V{@tl0=%as8Kie*RRZ;In@g6a;nRLFLRFRC< zyL*eG$^Y4!otyhTdaqWD5lIfyGiM+OU#nbZ4zSK9x5?JA1uj7#tOF-#d+Y>MleRAE9Ht;yJmOTxdDnOd8u6FWa=S|Uc4vkVKu?l_akx6Wu@-i7-XIGgdgygU{rH9WGwzs z9%)&1*2JloaQVALzpdHjn+eqKYr0>3KEK{@L0AK5olJCLV0~Ogvqd}dLqQ1|L%Vr&CbK-@GLO5| z-d(3zsDWCcq4OseH@Wpj`0e7$*yunV0Z(F9Lu~M)6fIENXTi-#_gY`5UYN?gEtROa z)^Q&#>c*&^*_5EqYHX$X+U%*!w+jl(3lraZcXxq&42kbRhy9rVd<|3265jVj~mq zf3}!+WNPKi1j&pA>9h7Sy9_m3RwEvkSXsmVBnj{Wv-z zlZ5OfA&a@F$X?VuKqPw{CBb2PGP`S9SNNKFGTWb?%WxhTetJ;lR!3>6tFkoX801<- z$jOrONlX995v*_mJkQbniFxjvNrMo*YJJ6s!iMV78mFWMg|WwgEOc zbzUiwP;-N*pl99FD6cVX6%YB=t&e-D^4g4P+Y%$NzX;i}->`Yg2^$h10}S2;Q{bxm zl&hNhy}ggHutB}*FeIuouI9>6J6dh-+AI0=w0Z1;L<~?W3@d%HV~_YcM%h5Dc>{A~ zK`OGtqSjS5t=APhptGu-iFz_N%M@=lBp>e6T-W{(=cvC5Q(g;Hn(I|~x}j9YlRRL} zkETo6r$XIno({FpOTkvsBUAj&lMk||x|LARZ+__(iI@stqY6HNE3_ap#sh2O0?pVI2MWa@$U6J9 zwTM-|57Ox0$NJx?5 zy~r50DZf`TY*ET6aC?(3xyeT3OSZ$}R6kH0=Rvx=3s=-dwnGiwu6ei>^h%~NJY5&0 zo=)p1xe?Phlp_r_lE^YalF}h}_5`1i&W)4a6;H=n%3y=Z|0oli_SYvr?gWK>@Oh^3 zV>P32#&t(qd4-|Kf6I`)f|vak_(=f=lEO(@4_F?f!*VTa!f4jRlGSpW)oqT|BH2?S z4$lpp9|ZAC_UgarL4a4*pb`GyDe+*RT2VMX7GKOmw{v(!9Y#rumSYc8vu3XE`- zJRDf<#V4z?HX?2%NGC4nw~=M@7nIuqWj*`k&|uk}AJi6dh9^_=mZ8M^1_?%qX3F7- z`=Z`>__bM#VywDGwKQ8Q z;sRC>0Vmytr?vqZV>X!xmVPIm@h~tmy(v8mlo9l$N?+{MZ{!$LQuFi|@$X*5fM@Ca zPW-3-LfPjFvgzuA!XYV0Eya*xrGI;7vY)#XGmfYpF8o$A8y7o^jjL^n^?zbw{?bfe z|3$I&>ALkynY*?H14sIo0d=JS94%CcYW%uatTNDeB~y;V1wV8#t+PC%=!G+*sZ7SM za>NqPWE9OV;_AHdltowRzFGEJ)#r^a){(*7MrF%>k}ieF9egtS(h4?m)3ril$7nw4y;HaEC$ez;G1x%d$MY^2)?6j3(eF=r|9=UQ?gKL|Iom%4Edq{dCtWb-ceDXcZ2+pDl)v(S$kK ze6cA#(vul~kY$>0jqAC21dt7=+aKTOgL|4cORa%)0^t5USXoP8N@rgEzyVFC;4AUI zG6`}|hI}X80?bIk@_iu6BE>L7WqzP_ejr(&iMIg}C?rrcSE`fu-)^w&ip)Tt4v}X| zq!+k?H%jbyFd7=(#BJgOrDsoj4jVHrFw{(BrTqgB+R9#Z`zbZ+vB!Q};;ED@+NZh1 z_hd#~+Z`|U!7J3AABWDKk%&Da-@}MhP1JE1AB+B?|NJ{M>97|6LfEyirkiJ%?O>_9 zHIiMOc=}F;(u!1o2StYz>vQ^M&*4F7(sLCn8Mwo zGWJJ{gOlt4g;pw#ilF4q8^;xyNe9BMmJ-^aiIw#Ei}4H};ee;z3+BOOh?!rD;Y zkY|)K^!u+!zc7vrF6q53f`KeruDLHWUJ~>?E*3Yb} zouF&8&CGR%VpVVSu2aPnP~lRq)DXQr+{ZJ94%-!2l}JL-Zp6xJ0nJPAbUkv-Q1@^! zAO~Q5-KDxRN9NyilV#bX(z$AsvAyL}Hl@e2@=lof$osaqnKso5#!u+X@9&W4wls5; zw};Hs7j!uPN1)w3@aJ*zR{%_AX-{TX&Z`Hh*aMztMFbjM z?!Ws!B)mr11Go1m=_0`<=o~=Ahf6KOA#ro^>|S~QH*!w{iv|}SmkOlyM8^7o=I|id zey@{BhL8W#zWHHTVYgS|d9}iBAoIzr`tYe8e!@w?Ub3^%CgHkWbmr$gt6CacKi~K- z`jnl1XP^RrI2-)$=Jqdde%jT)zZ5rs%6)i+MKWc5K`<#Y7a3B0g3PI8K*+lCIYHHU zjfyTXZdy=A2ku|3&iRUvzQdJjsuqTLTvLu%A)UYd`6@7&>mR)dii#gS8EDB`wix7Pl+fSqs;+kYe9jFdP%g)Dvhc*=sRY(+g*^C*@}<>P0+_RCFP zoE^S9o3in;fXrO*jTE(vn&@VRYrW==?JDOVCGJ#cerkI!wfT2r_0XN8-mSgS&|@D~ z>}C5BZoRA_|94mZZTIQ}#_Ko6nE^Xb_+*a_vQmJFG7qniiGPYUd%jOIDn35^0&MfpJt~V@Ih!S zET#$x`||2C%R(xROWw1sclh4X{`cb^jXbnUUsWDYo4u0si${;owR~8CjKS{1%(6^CNz}?BIg7yFfaq9`1*u5Uytzz#viqH%BRBlh%Et& z(g??}5}w>czJN6k;{(%#!>MF}8iwmTq>{W=2$T2k0wQ%YNuVr3GOd~=AA@09OC641 zJhcBc&kdzpyxeE$`{wA&$IlBek`fhg|uo^V{)Wj680lF2i3$pXjI2Q|E{ zsMg0rJdxxQPrZjrDxpX2w{?shdf-`m#qW(L@niY14`)0n0ZH}3e#j3qd4_$fD!KYe zA6|L-%&)Q3eJ(1Vb2*rCVLFR=HB-fbz_(IC;rjF4xW}PkA3-8IJW{`q!6XJOVia%V zN9mkN@N2z0=J2JB`?bN(O0N?G#x!g^J$7+iSRW8ChCl0XHFY@S^_fAje5G^aHMUWp zKSa#eb8|<3^F@=;iNCKU@CifgBwoj0K+1CSf69<=`)Ivqk%uP(F8p9|dNZIAGzyZC z-Ctm(7j$4g*{!a245<4BVMImi%ZI?+h;RU;>qSCrW?v1?x>>5TSNA3dLOxqF3Tt0o@sPX zw~?52JcmmES(W^)d2H_{FLmP3Z@Q%rA@zmZAg-WIcxZ2>Cg2_bT&5p^w#>b2B@(L5 z&=je=n!VD>6ms!Jr||PyPa!|dwnjBa!-!{VK@A zW$6bYW#8muMD}kbKH0$VJ=}5#X_zU?$wp3Uo^&3KmbFx(U(qt>ZS|V=G8|-7ob$YubWnKAUr=WN#Pi+|brNq8SSP(P#u)JkCG{dKkff6zN&_ zD+I*lAivh-8bj4IQnC=&Lw{i(aRyFqWeyr~3-Zs)fH6&5a7}7Kj&FRh%tBWn1x=&h z|N1n;u}gq^C11WUmns&;Vc{zS5n3`e!s0U&d0ovZ<0Y=t3-!LRnA;{AZ(;nL>Legt zP5N4pjl z)>U)V({0T~AhzJxUNV}(Obt5&)%Rg|pXFJ&-W7!(Tn0*7$r?`3S7Cdu`t;NK4+>wK zIEI%k5b^?KC|MCgUKKm$cX%Gc*&{FRd4|*t@-@^DPB{L?Ze;J#^h0=!8j&pf0| zE@7g>6+UO8_Ky{aL_6H5q@x;5Zy8J9{j*WjVa*vsc%fG{;j?q8a(DT28E3w>AJv-c zlZxU9pGugP*H}@@zMIu+Pb_U4wck@;d=do={d41eVn#z?os@Gm3jj*{bmUDzsmw#` z=}iJ#;8|DvdJ~L0OH4DzusO;L&|y%x2_ClBC)0c>P$ZHEE+5VO>9zLAz2y!({5W~r zpWWUTck8_NIU=`kzY3Pe7Cs0%f@QObdiA3k+>nojmqLo``&kz+u&~tpjnX})ciS%# zu{6I{>?4}-)xC}xrItdz@f$2+pj!@XPg7(0FpG+>d+xitFyF_sWbvBdIiq_iB7 zJ$3gSZYA*xQjcBrG<%jbe!6_GsrUO&K6_atqQX?V-xHvS@A4ffg{f3Eh@X)2q=7^y zEmMj99T2E+IWZuv8)w`Y>wKSZ@8fF3{=m5x&@RaEgP$-C>zu$j>y|1m^dB%?;H?#Dyh`+Vm zHeP`l`06kfc!JS&Mf{qNv{sz|(qepzI0$$kwR^t){F`fTXV=VXu}O_jMIsunhaL9G zod^iI*Dt}}_{%cQTt z!hVRJ;X=>EXtdH@AMdk_E0nH~6q`sRB?6DU=-j6mvL_rUbK@tPzvepu5ZzuAy`?Xl zg@HtHk#{gcT%5>~zVRmjUv!fyx&;t%j`0_Y@dr~u&3O5`DR8UdnFc(gdR_BDoTkT; zRg;*9ho|*}zyQgxGbi=~fL9bsz&cT%BK)lsN7b00fjS~Kw3a7;Ns!`z`z1I4w++<& zEtbgiR=)8g@oQn!u0i0}%rwWLKskV(UIA{YCJkGlrxa(dpazmI#Avah8l<3Ss;u`m z1}Xpx2}|22V1t{b=I`Hwh*QBJIQ%VD+;u@n44YJ@B4UMTajVl*enosjkzt#$`vlr*6>(`3a! z2@uXeA3rI;*yDb5l}f#?YXSld4L{4AXW;Ta^r# zIC0bV7$UL?)rlUVCqDxSRkD!{G*pp3lDP(tro+P4M8mkSFb*sjS8SS86wHA^yZJDI z-=#Glg`5)d`FsIo<+8MiO$|CiEAXX;y77iked{9jR*dZSj#Swk`LF5G+lAeE5jB)f5K<@Rv025#2i%Ho14wFagxq;GGA1qpx zOh>#db04W@gi9-ysw&}0pv`t$;UIlmYh~vWl?!Ql7dLH(Q|qze_|R@(L`Z|{aeaqG zJ@NZ?iloL z`}CZF3YJK<=t($uq6BhjEuD}m;mkyP8Dwm#q7M{E2z@#~LPw8qlAG9kWIh*JOhr|& zQPY&zX5gUV{zT@6{P?N;vH^p#@dBMG`Q8@e*UDAuy}jY5(CDc zJ6nt|5AiPd#3;0zYcJwO-Sn??Cn?kQ6&7!|-vjIM5WYMPwgqDovF@)THdv;`!HUTuDv>^~ zU^F8E^wL%pjQ04YcYTj8H7YT76kbjNC(;2c>pHW)uuJLV$RwsLcGapgu+DSyX*Y$D zuG+5G4j69NR%ksi5o-!e2ZaCD8BJGSDVX{T&0w0_SdHmCe`Yo@tGu-yxzIjbISP;! zPTN1G^xQe*$+vCtICFVJ4KP`Nl zjhFN!i8yNcvT&8H8{_#OEB@ZF3b z@LkZmj2-@g?%TuHMu*=qfHpE>4^8zX3zOirNymA;hv%dWxT=&>N4mheR6MHv zj+{7%@9wQZH=xj%xmjS(N#;BDXctEu^^$N1JZ5mQ@H9dw4A`d9oRfg)(^S0c02oQhnkql<0@-?s`dG%)=PYMI%88&i{lzSgE z5BPUbGDys|>zv}pMpguVU+6&_M{l5Z@WnJ0fT|s;s6b^AWE97J{L8*-3VfP}cIrTj zu6o-UOAu4Vy{1NGw9%aIIp&sV17_SvR>)dO=n+6zu^e+(Tl96p?Nrw zyCtIAbWy|^kyWn9e;AP|oG_IN;B#`o`Jij<{(F6EXRL*-xcpl@*3W5Z!2Dt4;*8t% zV!5NAo@b;6)LhDyrL?`ps04hJ$r9Zg<$GEMYW2vlj^} z!%q%s(Lkslq8^gmlk*a>{rDx1JLLq((rGYJ%;8%wd>PY{F(CB*^BpQhVif%;*?Erh zM>O{aJd-!J(Tr;63f-3zdd5KuJ-%rDu6kl^&Cz09;t+}tm^kqnd1>wDTIDM~>BA{~ zSV4>s?u3iRJt=CI^mv=p<9(ANA^a`>jCUcZSRqW2Ip0|zPriGYj1YhOAmf`I5DMM} z%H-ETa}-Q>Kj?cl=i#;Zoj#1|r&kVGsz$wv@X)BkTatWsDz-0wd@N;hY;I}0#`=19=bnN3lIg-#;F$sr`<{no&k&6#8Niz3C_7X|KC15rcW68MweANQNjM{Q$9k z+3Gk}*PMTv`~otC-S4n!R}FPR;Z1hkrWVKLbWaY&|B#sdZ@=D~k+y%+H_mrO2R2dh zlsdQ=(V^0_8T3ocrTEWw;*U=6e*SYd@`b^PMTu8f`1rJ% zJ^#;6`w_T+0SjnLZv;MQx37rk{`|B&8m5rXO({;o^hFn&>QgG5$PiKUU9q~N;K*2* zVuW4%;8j&X47~*eQ)j5j#rZ5K=Y%;dTqpY40aaCe!m6QiVMF0rGW5RT!OG}AC-&4u zMgTLf`DQYjYRWq|t^>=0R@7@dv}u>r*z{DSTBk|PsA~B#Fk28jjn5(m%;o6ytaP8e zb>Kr~j&3(E;*0A&KvYhF>5FcM8*Qk8l@`Y?&1Y@4%GU8Z4oTTL*|HW7B2zIIn7q_l ze?(r|Xymzd>p35cKt2uat43<-#x|PpL(-amD*K(f|KYly&*z`YUNo_f$|u{_8+-y1 z86T9LG2vIR9%@-ondHJGSlMba9#*ze7-(CS6dw+g2@29Ub>&EW^Cx9jbJLI4!);ZW9lhkhDTfpoPLe8m+U?2dmnmg?qPnwWG>)^6z^(`jq7+3f z{w%8&SeY{@0Jttma5TL_!?R?<=GF(L0&s@uIl3DK(CZgBvse}9A2q9Kr9k@BDjgqg zGyw^PE7!pY{p>vuj5_bSqvop&`H0o(kIHQ?w=?A2?WX22rQuEsPvs`O&VQA^9OU;^ zdi@XS8_`fJFzdllA4*#HXP3Y%{^Q=<9O(UJBh~Z+U%OAob~pLd+(;S0eN`6Gt?<7+ zZei*01M44C4Z-`Iw9HXVunVendHw2)&)aCR(3c+Rc}IO-COsE6@k#gx$9g&$445C= z4a`tHvb)aej-rRFT}>$rd|Q@6cl)3me?s=7ObqwxQ`w3~S3~7uZDfgmEyfey=aM(L zPu|&4fs+d$K6#7A5>^;&i0zy@CciD5oX<~L(^JHP*{tV*mr_Wzat}yQf(j$U%zr^# z^)wwUK{u>;t#0Ey1Qau0?#+#-rde^aYy`AwxqcE$D=e6J@Ia6*U?4kDgaEcvD)fuplHA#)#{fyK8qp& z!t8Dj$^+J~)AtR_qa4cmmhL&9iWE-)r&Dj4m_HlGsjjU)a00f~M zdOnOiVHO*V^5D;P;C|xl@Psg%OzNaqHU>c&0t#1df`k?70ml9A&k0O`;>MaXf+`0w z!!U0EdM7u$*-Y{^X`yHu_!-C7#(6;8S8WVj(w;z8t+M`XC zb<2=zytWpv7CQL~tFU6NucpIG>ctJNM*HqV;b*1M#9}F zV^d){x}T{e;A-xl>pfIRvOhnUCRr9T5O9X2H7l88GBA?2ub7d0w9>tVlWBc6a?yb% z@m;wilckwX&RJQCllZbds2;!Y%ztH3=G`+T%X#pnVExm{RU9kz-Cm^C3!rF9mzrk6 zlsum??0WCp=EuHAukv<&CItR|Km23~J`sV|Qj`vw+k2Pn3iF?|K2W$g6&g|`8`vJ@ zvvb6yWW;07Izip>Il#K0upVrcSL^z!Nw`*xAwsp*_0$K8jZUVjmEqyBuTE9O(4}i& zTaoAG*WQgA-xDn+^d zi>3|$T=S`{NS7~C#VAnZ+EgygXwPhlOq3CWu=LI=AA&dP6+6g2i6t~!RfW~ux-e7n z#pfjyWgZ;($-y5GL65vMIRWC z&#jezi?cDYNvEH4w}N=oxlQ&}hzy^R6Fxm*Pi zNevr+mASsq7e34jQnW)OFCT>p`Mm{6TdZfn;tOO&M)T9c2kCa9xXQbYUp-4$^0`*~ zI2f7xc6fEerjH+aD1Uvp<{V+~$$uHhn@jg6lE>9PKJ9It2e2gPxHp|mG7cuxw|W>Q z6&aIZ?QQW|LhVd!F0nr0(r;0ZrW%|=YrzpP~6q*+vlf7YvzYtNi* z&AP!*0KHrj!ddfk91}MLIon%V?vpLR*2^>hK_&I|o_9)se!cY#KYUKkL!AV`&@{lZ zG=PmP_bi46fT%BX(V-lO#L|bgtV26kxm(f?)OF{hvxL})4<~OVA9pLD{JK?iLGl3g zt7~b-Yu^nasO&@_Qf_DzZb7{|{^~y{eaSwY0+yGayaRW{7>cCfAP>eF=WqQb&n7T!CE`n?UhF{^L-VSwP$qaRqa~gl6fqD!eD0la`MoV@uoA| zC&KZWI+N8C`Sh>2T|m~6d|MOI13vq%RK;f@xw~01V7JCI9T-Tbz$l0NXP*Hw%a5m} zM<#**Q~<1tgwU_8<*HoU(7m>(Thoke|AI6%%l3MZRn|=Je6kNQ*Q7m+t)QVm^xme= zc#H`EgQKG-dmsPJK(F-zw;nVWWP;TDQuO=aCVfBvT_u?gN(88<^{M4j)D`Gx4h6&& z$kq>(B+Svv0L(H#LMB&))I(H%((l-Vi7_{87Vj2*CO;xU%q-# zcf-j?5Cti)Q)9{;G&HT>e~#r(+Vjq=EXx2Bl?V$>WCFG_&biYeYbt6B&4hL=rY*}; zzE{#byQ~19V0J71HC8*8Tt(AU?5pm6pQYGCsc&ymgf#!^Ys%~HQw%9bn*jX(W^mu% zN#O!uTtL9yp_H{I5j%QH7@%qyklGJGvxgvMLwUJFLv#D_%k&fz#`vTidJAsgY2RlF ztSDoMvU4YK1d0k{9KlfJp(prjl__+Bf*tcJJym)eKHUyJOOx`V8ahuxoVr;7%g|%7 z9krPq>bGSAe9~8*K%F*O@!M6w<)sdo>*FnHDeGj*;D@#wfnm+L- z-69cew*~*f&>^X(y-R}mZ)JS;lUi=I3M{iYjx!4KO#jcJalkV@5SSm3X%n`TMGt&B zQvCE|D=W&52`H0mHl@q=YE&?;#xG?Zw(NawKrPB*g4S_McF>UDsc949>ZUHhA}3< zF;12+wf9TE!WhnelJCSs$~ zQxiFtIjG!20(FrtISjMR$9w#S_FtG5^0zCgv-|96ztTqk9Elw&1Fm`w7uFdFHMx9U z9k1xGoa7^byeIpoe6-wTg{QiJ*W(RL)UEJcTDC**hl|K#frc-eONggC7J1OqbLqe_ z5${Z5Q~}h#Ml{X`e(Ola|M`kB>9j;vVJp=Y;h1oh>KFDbg~M7sGH_Ud;N7-osnHB+DR-Z)rvL#8DWdU)za;9U{Ceo$?Bh~mr$KE6T zNEl2N0U#cAM}$33iBXqg;w#x%E9iT8W zUC$_&Mtk_Tjy?KvAh(Z_m@9``JJ3KyCGO1WyuxPbqtMIPW_^@`XLd2c9!&>a=9k}* zf()-1c@|2%7}OsY=Zx1Pd~1%PhbI|aT;A>HmLvME6o>uN80K&PlKV9iubK$|az`Ao z=@DUFfxWJ=)*>hM;bA6z@25n*B5pIfGE&ycdq%WwZD-Ih8MLl+c-|!9OuDxW)GzF| zH?K-m5}r)o`!&2Yn3QqlB$TW!c+(zK-v5%A9>TB)?1eg)KRT;3xqQ3u$*9^$nuXUK z>!FHKZx7};Px(xXw<-WoL46L6VOaatNj?Ft{L!uN$dt~O-OsFMZ@qaNglikA5Grw3=O+V1Fj8G|r`R(xrw8;{cE?OplvPlnCH84j5hm zDEl*kNy=2~DT=4COqW?IBQZ;J<9*lR{)zE^3lZN=Trf6%d+LVMm#yWJvFbCgKAsJG zs}LCQ$pi3dY`g2fx4#n-!qdnDw1DvNvBycF)amCK7a)-Uq?`#_F+Sm8#8R<+G30OY zY*FMgCx-;jN_xb08GjZ6@xzwv$6IYfK*@v=2WDZ!px2NhVP6|; zpC~E@uTJ+sh2$mOfCN73z$TRSntx21HQW)qHoJ%W+Ax7~8YmyL1_V5>E=_;-I`zw_ z9HC=LIUg>^w?^2>ijysa0k1p0_!Li-(&M?$ah#>(<6*ZR#0};KYBiH~7RUP!kOF#M zZnv9fUic^U{I-*Nx6IB+Q8%~ZauNFMJU|IMb1eM{ZyLF<2LE$6;ml z`$oG<589!5T)1FJF@DA3lenQ6EE1;HWr%CiyGrtGVNs*dbiCxi3^GkzocXcjy5tp| zF5yp$_)vHTwnSOEM9bI8hXy(Lmf@2={>-g->zhJ3rflnqu2FH(9(>82O|gCLv!<0h zvTnE}Aik&@ixLQp2~%q3)}rIi3whJSAlu}bn6xr0Vf8@r*}4S_M)KL5=$bmM>X?Nx z7s{8TX%}S!#25XzuXPc}x6Y{_0+exLQ2rMzEZ?FP!JM*rAf6YVa;bP>RxgWUlSTE)^0rxC zkmY=+9>AU6nRd>E@?AZy?oG0<#w5i#UL9x6XiPLI+Y-pK zs+e<_dT=$#|HN{F@UKvo7GSp~%KSDyao4IBQs*EB9R2&evKSLRO6D?ftIPdR_J@qdrga6_GNS_{Ng3b|uc z6x>qy@PtzD38h_9h|3_`d=O;6^4qfwIT~J=80IyGf`>^j9rK5N0dozTzGb{A{&(Ip zL6F3+XiP{7p1h4T3je?{Y0RFET}LkD6|rL;rzpG$>XQNnX#orGnJQw{TgB)BnVCEu zRd}qFdEo@$()U9r#txyo0H>prd#^AToqpE)F?ZU;{cLYh&&cyWx_ZWQih4B5|Jbtk z$%X6s%O=Cirj|nEBY;asKh#_A)bBu|BP#g2{I99^VVNLZQou@>^lD56plm(2EQInz zKLuy+-^Ec5E=FqFn#C6KG7Qn$?rtgX8GVBTCpR8-WJ!dN6l*r-tQGwEUavs3&gybm zlzNw_7-iYkvanECJ-(977XiTtm?p&f5eEo-i7}%XZMt?wID*-JImRq<%zURR{1omP zA3%MhkaD^Gk#xV<>pC&9D`II|Dd(Yo+Q+qf2TPvmZk=ZaWZnAY#pr?<9<~g8bK?o; z29eqn7!eoqH2=>9?Is>Q9yn09RV`DBVfcXU0iv@JYjSCwdkG>vi9#6QMpO)IvXACef!k;L(D@S z9r(K2a`nfVe|@)P`j&D#^#{;#=TAu33Hv?|Ibcm&)#xG=8K1hEROB-HyR4e8X)#sWm5w%ODh3QW^k zhlfzvi53*%J4+dZdt+@gTJe>+vB#}s%*hIuPq`Ci24%UR=NrpZV*{+NuD$$lbtcJB zJ72T+&NKJ9tMC&KU*xWCuo-Tr72UDhRdY_tI8Mf??Uwi@6UW+3aC~l6} z(XSqhW<^<|)LD;pj;`?riPe@9C79f(B`PQ}o?~lWD?k60cGYcwRu85XSW_2tt!t`tAM|I8H_)8VJ6HgK=UFUCa+~)GV z&EaF-lp0!L&mZNr?}4#LM z2B`yKQ6%p>0m@K~7Jf~3V60Zfxjvm=^@aU1%h-)`W%L6^_zTm4$x46ykQYQ?}tudtGO(N-GDf)x7BJJ7&Qu^|0(k+=kroQOIi7tv$oBuuttwc~O8?Nap0vXMrqas~S>c`MBD zT<@~6$=2#8L4Vs#wKg-HQ`g{Xke6d8V9~W2|n&Q_@(d&hll*chv^ZYTwd-reYkFj zbVO&APA;F+LnH=m2w$vyGXS$+2D-Hiikxb0zq*mwq*WNDKNG=o73dsLT`JXe?N{DR zK@5a9q*oGU>cPlYUrH!bc@WKcCxtNeN;vK) z|FTy$WuU=c++hQ`f*ygr)LG2)>*+Ne&dA?p`+@($Dx!)Pou25`8#-WLw}?)bl2I9@ zeO_H}OFSVH`m8ruO)8S)ZEyo+8N$aP5y0N=V7#FvJR26dm9=9#_myVu-Bw+|*B$?R zw_!iysh71aXE*pTVa?d!;3krx7Bsoc`6ZIW?F5#V@}U;#0m$^W0-Xs!v#}WqY5%@p z5mN~374o<;4DUPPm1aAp`zG9W7cSuK zwRlC6gN;vYe{JqSKGR8yHhw2YxWtnct|U!+H8GCoF#A;AXBCLY+hG1& z1vS|l%-(~3e;nMF@^AW9A#W37*Q){h^+j}2hY-g~@+Phe zN}Ls7-}m)GKFTtXZ%gwO8HheDK51o(S}!^}B44drwDQPHvKXBUn!ZH;^K^F%B8ViM zsgi%oD}-gIb%TH*g8bd_Gy?Z?(TKju-H1} z$CGX9LPJeQBY$rXvbt`FiuUYOnen5E2Ycd_^#ON9-Tx=3g{ zNVIw{v0Vc@I4U{d!4Cd?sXBdL=vX4ESPqDGsRa{bsV?z-cJWkamkC4rK08~g@hC`t ziD5EIG0lm!5Hl>Y*koL&<6+ffa?UL1L-W zKJMGZQeBzYhb*5_yP}L>Db4cj?XO@4`SzZ1j&75VAH5w>@^*^yFXJoR2vk!m(x$U) zBaCh60^W4`ibFot^&Q3SXn~dVll!R9&hexv{Y+|T@oGhRY7*OI(AOq?Aif1PB z)m?xZJ*==xWCJwS%KuWz&P!vu670I44MLJ?R6*}JPVXs5O;=KB>Z7d}sK&gvZ~K5t zqndkDh>oC-ivoNl5AVy?rBgyN+A zjwYV8#Dp2J=Tu)p;*3{(qG$Tdp1j1QcLAGho8rg8n`#p`bx_?!eJPzLoARltNBi7` zS4=NVm^B$#R7V*Q)k6jNP}XNxvZ@HE{XRsm0c6SrbsG&U-2{zCfAAX96omGr ziW|7f3An^k+k{fr3UK}k{67z{5kQ#gpJ4Pp+GJ3I5i)cJ@?t|9?44vS%@j|VvtQO$QU40OBeWz z>*QC0p8o68KKs64ez?-qg*}Wc-XA4UA&{{IfqFVn zw+o0*7iguk3vpf%A&IL5Yj6cPpp z@b5lsw+AOSnWO~GTzq%+5^Am&lyZfgnAntjsr$=CPm_xiUoYIenoOAy3aN3<^FoTH zz&y3zbEayT&4 z)3l*f$moe_iZe^uK2%nw~D@B^XXlf&S8~}6q#v&lpgQULOdgefeO5B2%%WaiZfqL%8nZ&f2w6ykXWSMSj zhHlp)VGO9FwD`dZWJIELR43>)Q#OnVEp?LR%8pHo&<0=N=s00l*tfB;FGYQRQ~r0u zPgGCu0}UbV%l3P`W~p9DOD21kO!hB%E`!a!_nDnKu;J7Jv*QQMuD6>y_*%SZ)k`?z zHD;MfY4)6_X1bYLeO`=t7+Jkmpt3?zTm!csVBs^# z`h`-&YoO6Q=x*c(uc##SZ$tEpfmc7jbuR4_+e_}1KfbrLZ(C)byGMcS!AIR5RPp3E z#msCG-N81@)e3Z4YbOqn)alg9vK8 z%~IyvEj51Sg}73F=z{jmSkprG{v{K$JcE6D)KtAb*M5_jr!yOweJM9h+y<$exCPlE zGn-}$7>1pU-}ds2Hr{P--n?W+?c8v&-TZTvS)8+(&1b{iO(%|=v03Yj8!SF?i2ARx z+`jM{F&Jdf#nMB6RTEdz`JJh|+VbXJ`=s(+4_2TM#qcP;3>wIX%Fhon@Xk|rl?Z!- z4S^bKed^u<{Vt%c^p4k#BjJS*ou$Iis#>njldWX;? zAJifbC&o?a$t!}MY-U-S)YAanb+THb_o#IS!2O_i$7Y8SN*<{L;X~J@P2tqD%$vvg`~(ARN`T5 zK1MNtu-JP@5b3sS8&T{m=EsJ>fC!+{cP5@za6Rh zcEJ`cnyG@ReLywapI-0D8jc6mXN4P=$rHKn&YKGF)!wJCHSITN>!+VeZQI&MQ^U(k z3~C_uU*G^ zR5|5eON}V7@%D{(DIQfB%}2W#&&n%YLt9oRV7miiz*#Z5zpsJ>X}JSl>+D9tcJ|dZ zU;K8Po!1^2quTKh|5>1wO1}1cRB7S{y_Id&u{u1Iu6T0x;L~o}yFA6?>`?o(kLS}Z z3fWq-q-{}!-m}c-v<38gvYC{H!9sW9FD`r=td#mso2($X%rBG6SFiT6ulG*J^lnwS zOVi&wpBZ!Cua19;#i!~tCP-!FKmJ+_O?+%)X(PO>R*T=o2dFKx2u4C9%bGJK%O;%2 z{(?2r!j7Li{+rx=tp5SSAReSYMJ5(f+#wStYkj6Ish*aJL)z3Ic&bHyfuZROJ0qcX z=d@Qx-zKzkLZz!2sNai~TFJ}&wM|*m>vGb+E7=S0|0{j0<-2BLtKXeBEB)7Z7k$?0 z+sV#yWrz4#DUa%Z)|+RYZ8sUOuw=EXJL~O|_lZk^M^+v8A8igi z>y$Iz%9t#6%l14nA%}D~E9tw>{b@r|;eoYI40$aBH*G$@7P3#Ecb|D*;+~1IrP6vB13Ql+@G3*`(LAndi#~IXrzG(jAOS0zP z_NW>CY7Y6T@!k$inV-@A9=k943_9Bf=OH2$R9w7C2ieq}!-KYYWbzr%28b2|qLv~R zAg-<33E{2li>OvYEeX_mvy+*1+>Nl4dg5+Q#ujc^_>-^J-OEGQZyrN9%(wArKeupW zU|)?jLf5O%Dl~_HMs=t4LnHtYWkxDBvPEzKyTRv(s_uF#cbRZ`?o2UY-HyHkP{%Ej zM_cdv1yl-3|D_Mr8x&W^fq=jdS!5EhDEoie^+TSC=MKVD*&-lPmrbg_PMhMHa=y*t za~0j@xq%M@XRnsjEEX#md#e}+6i$@B~m?LX&L&&$GQmcA1g^;5g!meOE95s!$cOKX|ebfI} zyT#4Ah3>s$_hDN~+Ok3(S=_E$oiovm@F|0wqL^F^YmcnM&H`D?q6r9~<_Mpu>9&DN zK=sR=J$^ZjVto{d1ll#P&*cNKwfEr|V7(2qJZ^|fC_t`1VS*wnHOYi$dj3;*-8aW% zeB`0GkF^e~o)frwE}qncFko)y$S<+8YS7Voy}f*m|7w}Frdcedr(}?>lzh@Do|1S1 zz#9rYjZ(Q(pkGE0(wt@1k)*@ks&T%aOI3(Du4Sy*_K;28>ZNsMrSUwPGUNyAq>f~^ zbP4>{Recm z!vsjPNA3^B60sZm1JL{UPd}|QpkAo{lRkL1BE8K;ZH1rVTPjqk$3hL!Lr4blY54j` z%XB(fu|Nbd{m8$6EOl=CjqR7r7ME&z(J)(3)g|+clA+M)dX2NC5dEcCnVa%>y?Ryc zxYtoAV7EFSFo9xX+(wG(O1A(tv7i{ewucMK-+>Aoih2+Y@Ff^<N*Ivv_TjeQ9 zkADP%7EeeL_{>g`;J2Futx5w#TU=m~W{TJ({B6^p-PBF~a1@{w&^p?Zr5TADSjza?FWL5>aw&Le z3t?ARAN0nlvi$rwBxomFxljxtdI*qO|MtKenfFoQw1!X)MH9zU%A-jzy5EvCuc?=Y z`$G(NJ{;WmuFBb7xVXjW3(j5`@4Ev*C8QT3Z34+ipzJ=;G!DYdnmQWd%}6 z6&GkZ1`&?8Qq<~cU_-nEs%rRsM9ryyt)y5Druco+l-y2NMzOBvw~`{7HF`yrVI{%S z?hxv`I=;2_gNtfbher{?QJ}N_Z))y9u$F+-y}-T(AI%Gm%xK^iWz~q9E(rf&y83tE z(~u|}iUa)QNk@K~1tZ#DZ$-5L4!zyZ{52#!;X-4A5J}-MJtjy=&dVbZZt2cSAP0l* zfwyoe@O4&o8uLQGvmw(y{)%gCft?}aBLVY4x)}ZsS+3v?9hHX!hze)$#P4DHY!`YN02|FoR zYh}R=Z+oOhMM3q{?~}Tk=gUFLNS#efoyOOnXvIE)M(&~vBygsTuAS@GPcbO=__cVG z;oR?KZui=GER1vX23fH)`YTmF|%b4>Lz?#(-9 z1f~(UFAv4}3C>Ed2x0}l`PtN%#MMz%)3pNVMKt-LP;Ovr4R3R_f`Pmh4Z zYJrFrX(1vr0f~NjT&0CnbBq9IZyEgUkZBcaO@uT0O083zJ-><*A3JkF@eKO7LOT?KzF#e4P$3 z+{T|@yL&blW}e7-pQw5AB|lZYt?u{3^dsAVrljU&dm142qn5PjHBg7o1k5ku9(K3@ za~~H0^^JtenB2><_F(2DArR10RKhmYk+2e38j#@CjR-wHY>$ZD`x7b%*9!Txn#Wh4 zP4a)>OAzNvi9&aEikb+x=^Tuxyg~v!{AE>im|LLeMq^#6B{d#;e!cf-7hX*yH~jqs zK179zkIysjAIti!{l}BsX-5~=Nw2FsI_L%3L?|#_A^qoHdQ6kUzii&dQ>rzxNhNQt zWC3KnneZT^ce7iD{HOb`?dvGi@R(4Q^F-GfpO;#0`txw+5-ggBbHUnF+(eX%5%FBu zI)jEXevXAA^XUo|e5735s=0!hBdIQ@Y6MF3vl6m5Z@4>85!oIWZT=$iFFWo3bwoVz zO)4|MSravm8X?9MdKw-B&G^8cFQ^S%beqpZ53oYQ4OG!_&``a~GW_T-KS0FRaTNE* zQZuS5z`>UEki^7*fjV_`5Qz@57lYn3gK13Qoao)VpNRbfdGF!!h@h_5UnD%Zra zt%3R#Wp#;6#U!9EMZo8Xk$iEi>a5eu2BaGc#s01z3DoTtGrK6TYvP~PVtsj5UG+&B zpYI70D5u;S&SL{cfV%4ftqALVJq)9sLgO)i;}-PRATl9{frF5-Q!)~|m4xwUsX}6h z-LR^{+ltFWkY;wqcpwz~6e;~KHVUxR+KYt|ap3~{93Ezjq-ylcI?(U5_3?SibyF18 zX7iSu(^6f0fsz)hCOm{-rnn~*^t0jn8@v(G*BlQ}Uq(lsjguhJM@H2sZUFVwQ~UPcap*iJ6E;NIpQ@m zxS8NGYV8qnI`B&*m$%lSxEu3-`(b zK{=AA5;%tuGOzhRFK(N}tjL&}vl1N~FWEX?Q55Il7_Yca)Su-kjT793eD@hc_l_$H z6v5STuEO#XvVp6xGTFj3S6-3mbc?;Ov^UQzwOEt&G0%~CRLcTB{1;T_Ced5vZ=~3< z+72LNHtS)6%v4G?vJY4npkwBMc@qH4DgYzqs`#H&@2yftGXglQiedjPLuA4*neaRw z_+(B$jprIx3CbX=1+lO_t%|Ev;J=Id7=e2LgCJ;yl!y_qM&eUSl~8zqZ!l*+4O*2>k60ofa>d}M<0E!od%M`K5WAMa?``tY#Dc&l^XU+%}(al zm-N($V>#lt)-Qv8B*2RO^j5g+3kBB}_$e2Pi*ifVF*7NF@jgc(#|I{Lq&z*YqYA$% zp`;+TcoILy({m$YSDAy)CHO!iY`L@Q=@yj>zT95k@l`+o!1j1yXi_iBFvJSG2B^K+ zlyt17Mk=_fL&S=}fJp-8bk0;SS^u3txo|2(Lcnxz(HF#+DRtBgK|uuDFB#AnyRDNi z&{>D&b&n?eheklPSbWla<6yl7ER+A7$6A_>!T9f{%%kO; zy>4;L!ZdTcd1K3re$TkKg29foaC3xnLse8Gnkg5HaIn}m|Ulrr5`!V^Y z&U|OTIkLV~Fn*z5Ic7@x@i1VKsGonz0AjEuU$P~ip))a{v6`bfBqM4*Uo;Au(77Q{ zJBQbxOH|Q2p01P81M~Xjaw8!_wdW4*s1t_?fUnK{6Ha8_rz?!PoLZM;O8IjzAl6ZG zil(8~@-xy{E{HJZIqBAzXY(l=Ig18oXpctpZfF~s;~YZ>28Jpr41A|JSB(GwkRq3? zzvsp7l4kFyWvX*Ej>d0=djK(O05Bx>!9ug@VAM?osFYq0=}eDIgJ(GXz_exUc)-k7G}|D*8*EouLGj4|gd zuuD7zd(x3R-#(4qLoW$1t#3aBW?0O$D~Vh$=h(4+$)?4p|biF|H@bqfoSGW?2ePN%afBCIuovdf}@8fm<+>$#eiT0ovl`1FWcC<}`q zV~l>Q`AgIat=;?Nc@Y3aIG`#Cd*VJI%c>bOAl3))4jST=mIA}qfoeHKT&s0`9!Q07 zEj+L}Jf*RH9nT5=y6cC*2-eYdv%S&T^qRbStPwa@n;gDm0T6Eh2l2srHm6t{Y&`Zw z)`{`l-}*W)q^T@L5fP&5d|zr>4Jv&p5~xdwNHi%=k6yP~nZ5x4c*EDlNlsJ00~eTj zdf-bRKSM;4SS+Dkp+>cUo_`09C4 zcLXYX2;3jORPhz4=f9d=MIa9FVNGn9*G80xpLvgte$K^wB;WvoxuuHAgT$7-WYvY? z$XjYE^n2khTL`u+QD8vF^Pn@~S95~bY`t+g-IA|akpu$ISpUA-&;+n`L{lVzKuEx& zMQ4``C>n(OAu4@qG7L~p40!{9l9-C|Ohrd|!TW~i@V^Tm9nz8P za#ZT6Vd}Y&k30+j+P_k6`F%v}$5@~rff@j+3CeAL4iPVqQOXZ7c3=uuca6m0-B6n& zHj8`+LZ*6YFg)mfZ=@aeO=GVM7+OiJy?X!7N%H7tBt(^A@k08zmRT00a;#)Zd$`)> zENQbrt5O3WEZuLO*+02TO6X=fuM0FQdC6h`H)Cm|rhphodGo?|e?dg)Hlfbm$~4RF z^e@`#svFhg&$y}a8t$#=ce_;BXdsfrC=s_R>4L%>Q$cQy0st#N5t?tLoysAq$O6+5b`t@2t*Z{L^G0MQK$fBf-n8D z9{iEEYyCN(n?I`3c}{^}onRG}d-Xw9aIC8pK!-G*e;t66Xm^Vj-}n*U-dU{%{*GVSd$TNTB0U;-<$BlK{W1w? zdeAVG5soBd*99sDzdsk>{LKD|xF$yNkDv#bf)`@_A>B$K(Evye4*|B^2~r7!TfSS# zrq+7N7+1@V%?BoPkeYXcJZ$)CV&D~<2vg%-?aBlo&T^mR>c;!?uO8p?V%+zMsy@!R z<%7asTrzvHvvUh@NR00+dozkXzuIYuz?IA<$mfl+7_Zk~PXg_kM;hHFS?(84(M1`T{bip1zZUUY6IB~6@ z%Yh14^^#?;Y`n6)*W4_z7Jqv?0;fxpeOcKtvzt&a3ySw)$C{4F2r`$P3#+$ut(rY6 z{Lk0;qb`U4nF!5ZR{HRqv_p2}4tV;Lt9j1)pZ3J9>GuWWQN+HVN@ty-ntSSS#R@wo zx8F+_q|DDKF7$)-f2F$-ttv;V0v!c>eB0jUwZ!g3-3L={N4TGDN|YHVVmG{ zTK~Yy+t!0Z&!=qK8wQ{9Q_|y#1%5I}Zp;=9I)IHiqVuu@k?f%1HxK{3(!$3#w7z?gfCHk#o#3=>&8)dgFD&cm`chqKzw(hyqJ^HQT zL(Yfnf{%IM&isD^dVQ;1*ie!j7h<^x)nKNx z;Og*}#mb=PA|F#8B-RM$iEC4jaVg!;HLe|SDhX|;`21xBiPsD zPe*;W#xAc#Z;j}{%|)6|MqZBK@(ru{-};sW`)VFM4fKspkaj@|cg?jGZuciBBfPv_ zc?}qicU^IX{vFhlszDJ~`wFe!As8lqPA?Wthq^G9u0*$u6sh_bE^WgHno<;+AH1YI zwd-&>fVBEl@nf573DM#FjT8UkPT16an>r`67?1dM(za7B{4e*e2;XdIT5~8P#Bzwm z_`rr^KJD>b$$*z=p%AuKz?JOkC{6v}zD!B^mId{vjG*iucG(YAtjf8r%ABq{bw4Uf zc26$84w~{IBbz2;zEsg|4k#l%2TPPjBk1*O&t6@IO*Eze=;-v%AO;`z6R?O z_sEX=J&(D5o8+*7ZEKj^T3L6Tb7jV0oEWUXjM!;L5p3Y8jdE_R$hF)>CHMK%WWSf^ z^|PApedO}6~1^T$enC13vrO1WUm>5mC}W-Am!?&5P%^PI~&>yi@7?RtWas(opSBBGzvEzG0Uq z4tZ+@l{tl)rtTvSpE8E3|Fk?_F!=MoIQ+eX;ofj*@zV!!G??Zf|D#pY)2+j)nPE?_ z#QZS7g8#DV`-2bEX7eB(YO(Ajj_v>5YdL+JH(?@jr6Rn;Q4+Jd-h_$6zV@n~JX17I z#MKW%AJoeVkN~(#gTYp*f56-=_|RVVTFA+R=5KV5W>ZxQM0>MHU8j6Gwzj?yCP2Ln z06o{$jN*O?%Acdu&NwzZKkT!c=fAv^Lo%U;l7Lz1m7cA4n|3@S?VwOt@Y)8o#|RL$ zE)rKm6PKY6kxXWTi{YV8gXE7-N(kDeH__#R8#XKB5;@4D@5x)0^X9^jEi1cEP2#-E zXkR9DnoV7as;h`cs&51Ln66D?3WrN{f9V+BZu;~83c2wd3u8XpU~!p|wZVt%JSwHkkx$+a@suFXKtdiF?eiz-XU0Czf0+k(<16i~F66nYWOa?vF zvlJkFUEEaa&g816O47V1ZvFTJFF80N2(_gu0&|&woGvDy6b}HTOF;G80T(Ak%?|IA zH4~$@t9udvrd@Cy0Cue8t6a;CxE`iP7>|n=SU?Yv3{C!b3b{53DxWRXPi%XI#&gkJ z-1RVq9Q1DXr%aVZ+0>qqS;yofa}aFKUK}JpzV9hb-L0a-^7Vd*;3?$YkD!m{uQMxl znp$5VpYH~#o2Ih#IuO;hcUxV}zwn>2{Hq$Gqpwfp~MlSsuS#D~>U z*Js z1P&yX1fu~!Ze-(wOr_-J1aBTVm8Mcv9LON3#VE_i{t*^-YWvlopKKMw+F%)^jWPht z)({$#doUD>Se8QE04{WX@;yH4j&t;l*r40$(d`4p_e9a|JbaM=f1P6CPE;5tDD;X^ zd2QOGuzf=_F-#`>JJbu-m?tA?2TSu#Bw5P#=+eL!g_>21xu+PKFYe-n>8J{pLeV{s z8rI&EjNIWnDn-CeHvzP$^zxVO+7B|yXN1sc1M8HluE+pg{V`SAE$5I{)#*+Ef)^LClLLt-fB|g) zn<>i1sqN48BD18>&F^Hj^rJa6!IV zShq(MC=;L{twc!y2_j5Bfu$h;XxM`7ne3e~^~YRL%0MWF4hcvH*>40Am3>3`9BUpJ zMS!?Tg>X`E{xF;B*q~Xj^=wuZJ5GNRcFT&=E&ShJZYf z8$HSa3-T6lLxAwjEY~YPAS+yiJYwy+5p)|LRz`pvI0d=UM-0k1Jsg@lB1sv~PL&C5 zqbq{(h(Qc7&{g(jaF$R%-{ z|J|deGuyLLwyTQZ9a4ySajb7)oIS&vz>zEA5ZO7PrT~Bja4}4Pj1Sn*1b)+oG!s?Y z6XR)E1RcOVvI@uoU>LSQ#7L#3YCL!()J=rBM8H8tLh(|7&g)RZ62LH-6_^d&HVq7r zLfiq>`Vz>jZIU|>q{}z)ysd(_C1{Njoo=fDV-36{Ktn9iO{ysCV5%w18PKIwkvugA zX+{P~olym$V-YcC8$+cK8KHg~!KEZKcdv`)3m<%E;*}U8x+oYw3D}gKbm(sEp}B@z zyYH*);Ffwtw(Z+cGdiJmeNWAaBsmiS^^m6cf$o9qY{2`ZeI%Z{SQ_lk!)O?lUvX(P zdZjB*W5{~lc%KY!Jy*`z05t~?G&1e*wjfooooePCG>#tx&!H>BW9g0Gr!-M3r>dVB?-u|Q>J%R(tY-b=L?Bd-y_nxYC*f(Zjh^djyF zvsF9>@bSVCym0q|6)-_0V0^nt8Yn>lgaIep$&MZdxzC)U$r=>2m04KLvrtGnrK z0y-DU5NU)EAm?XzJTbBikSmJ^z|%$WST5901m4MkZsI`frApm2zc2vQ9RasE)g3}} zy!scCWUDwTg6?lL3X-U9r5O8jK}TpxWwt~bqt1=fA0$@YB2|4%Pzn=wj{dX@aVW)H~KyT(RX6oWYou`NIOgt6NE6RuK zOrL7Ew3MG9ILI6bfV%rCHp>(_#Kxmzf9D~+n@=bXs(_90VL{o37deQ1I`;q`VCyz6 zwi@E^t3cyqZzkUG1+STn@*6CqnkX3q|6GhJZr6%*SUskUNOt$o6E){ZWK#F6r{1X_oUhT4!@kf z!YpBZ$%*X29kAdmi{%wvN(X%K28GZGYhYw6CZ{H*Q#zGHvK>?fr(#6ZLKW9jE!09E za|YiW+t$oNF63{OmT|Fd+rf$31`q&7U<{iuQ?E^8M|4C@g#c{8L^{CQGRG4?G6E=2 z1D#M0;7|rmf&@r{Bwt;wf5Qg0fDh=92+?#lvjhfbU^HZt1xI7k=unJ06EiT=34uUd zbWs-QfN`60N510-pX3O~fDZD24%E6S#$^k}RpFyR3-yP7-*Dg!?F?qYJHu7s$FK>! zbqxR0;54*gcwTV}!f=(=;0@&6n>9+3~8zy^On27$*LWDskUF;?^*}>M*`mF7H_?@e`ZN zlsz7ooxbPmFPdGx^zxU#{2jRr4Vth9e^tNdk%}HK2;o5Jq+X9;%qAa!0-fN;SS9LOrB(n65&o$rZ1y8VfffHq;0%f2 zWjb&Vi7+5?HUlif5{ZBgeug;>0tFX>phggnQ_&ZFFauR^#V6teop21NOET!{lbtXE zOX!#^6Ke<$UC~u|D++neBfiUE4K1EciTAEVJ}b<|Dr+s+X^0s~9&6dN6PBUw;ZzPg zfjs4qzB?f+=&Qc}AmbTi%K%fQ5#-Arf0!L>{Ft3lApafUF%Hjw2e|PMU5fJbw(>~t zsx_a@-TaFQ=b6ZuLorYD?;ij`5DIHR=|=RKNdOLTfMV+x001$ARwo?={w+8t01B;R zI}9vPuwcOhH^-j*Ly#e30V>B5fq9UDLWT?m!hBov?_Y+M4}AEUkVyZ=gOwQ`JORfC z1c;IUE?i&(X9)%rJ27OC0xl2=l{-N=FyaBj3(vw}kyGeGBzJWUXBzvxW>cF4d=4;fbV4(;z&oO8+}0%(f<0 ztWNzqee1Y!SHfHiS6=(~=+^(ru~s=w_P=V@#(nqZZ5-Es-MWoaH_p7ubSmGypZ9N` zy7uql$AABhtNr%>`oB-7J74f1?~RmH;)4!4_{gM>84P$p0U7_`pn@xd{xLwM1uhhz zfDAhnz`}nnRLLL(9tdEDM6`mi0Ehzk$Ce~AD4+lZY6PJZT9UX>fg3My2AwK2oJfE( zjsb%ujc6>x79F5q$^n%g(BYp}IAe(d2OLo3A9VC0i6s;mszFH#E>PeYbkL!K0+lk@ zsS|J(1A!$p6S0MiV~+Y~Ckhg22Z^RG*ohV`WH?5M3&tAruA)xx;QwQ zC)!D5geql-3ox;KI0lZkR7eCIB%}aC3Mr~E!iPlIsIoFAgb1S^Fb?}nrO`$sZ#&sk zLykPIUQ?+!-6Fw56!Lt#1sPYWYfiP|{_(Ba(@<;9HI@Hl16C#RtntR0ZmJQc8gNyr zrka1o@unMf)OpvxbnYFGt9kdOcV4dMu`gi!@(XwwUygf2B?JGVgFHU?Xv9GS3J^lZ zQfqv`#u|++pu>Mc95H0_6c{n%2Mj^5v%debt#<}+&{mfCxob~08&m2|iKx5&C75v|VG7*QOJ>UUZfCC)dQUIDT z@E;iqnL#>+Aqq~+01CKZhDL-j6eJ>U50H{d6d(Wq0Ki8Wibw&Va11uEBti%wp#cPt zv#TL36toC}N*tgiDdj9`C!&A=x&RJ0SWRjUNSX)Sq$M{!00qZ5h7OdJfeU0H31Fzu z)ksn(bUeXLEJ0!tPLKpUV1RB{LktumXF2~rz<~^KfPxd>WCzKB00oru0HH=OmNRzH z8BZ9&2?U9P3!H#6|CoUxQ7{7`e1jHl0Yeg!@B|^~z!QNG!mW%z8Aqr}P@vhJu}nif z=MZZ(tofB}tQRe=oTfFxXbve+qb%Jdi#4j0*6z0RF7Np2JHH$c_QW%uUm>R#z1UWH z-W9+7{KH?)R7W|=@s4uz>wCts-*w(2KXaI48>bk<8y3bD1*wD%jTj&ZCZMGg`se`@ zc_%~=;v8#*zd@cmM<*Kmk$ykyJ#UE_5Tv zLLy+m6ufw#4^ZGt5|qLjFsLL6LLh@Jo{$75B*6$tKtd-_cadub3mK%Oo@$QAiq#Mn zmANw(^Q?6(%NpwHPPxK^J>r$eJl$~S`_2cy z_xOum@u?5KzH_htY*RnbFb5*)@Q;L%Q$cxn*gqse2SQH3M>mQPgdSo5=0@bW5z^=n zcQ{=pZfT2sNJJ1$TBGA~_eB3BSxlEes3qgd^te&;S{%YrL^u(OP<3)jd}q*#Z#-cq zcf0`Qe)5wHAhAwP_{JviQYVrCHB1PQ+DJ06iNsJ*BuFI5(U_=GDaeBi19r-LBj=P1 zAORhrm;q;G0EVyl1OrmZDhg15(@r4L12S-g6q5QE54egcGl0TBz)+b~|LiV=Dm2xQ=Sk?J#U`_K_VmZgj(o&6-ZAn;D=CU>6`WCssQx0O5E6TGZ zk7>|Fy=tJ=UDfEtKjPfx@t{^cxG z4pg9m6VyNkH3*ChRG@AcqT_-{wPS61uL@x3y zjJ)I}e-$`75rm6q@5`d;-nv8szI=&h&8eZ@DRo{`2?bOIkh z0tVZJAqoGp&q56=%em0X4tU9zVFI*q$eq*x9@UTycfpSAQ8MuH73}yW@)#}dKn~~V z4)ST2(>SJMN*D1_y7SCHy8x9W0;2dhv0&k zL|6k55CG=NCmG7805}0K@PG^`ArQbWAApen@Io&P5fbtMGPnQ$5P%F|14m#33i!e{ z^uiJN0XS5Ff0zI_poL8c0_MsP0|)>O+90TTfD@P-4|o6-5CmyB2FQsoJ31$9qFo2BAg9A43gE-JU&hrC5=!8UY1XT2(RCI(+XvJ3agGBIz zNTL6QQnbZDNQ797MGx{IT4)D(XoqirqGNCbPoRZLunS*n#YCV5bifCEpoLKAglSBK zKuASYJjGKyhI+sUv}pxpoJL<0$2;^zdH9ERNDcCEz@?KcoTDCa*`9ofkL<{-+-VK+ zaV+ZimGJl;r1QXk%#ObDKy^6{!V-m5_=j9LCY6vu^SGB9?7Guhrg3<)6_m5=IIVMG z7p0>YWq^e`po2W%Erwy31bG;@^P&fWnhk-72LJ#+u!UnV1P~|y5O9M&$RPuok_Dt3k`NET{zQsE$f71q+if83_X`utyBBv`{hfP%%5gix~)kXA1$k zfJE*)yeG)SDkuZQYz1fhHP6t3FaU$s0fRE2%rLNnGB^V;xXD5|hJ3(>Z-@gs_y zbAXo;{Ht4n4cHJ5?6JUWsz+b(26R!f>=A|*d_i`px_7xVhFlL5Bp;_EkA*xf_&Jwu zNC*9R2X*iUZ9s)FXoOH$gWlqUBy77zNQBAA5DD@S4+(%5O9CIDfSSBX6oCH_d%}=C zq@ucU009U9Dd>bQ&>(}@LkS81GH?VLc*+`R1xWw_oty}Spb3PSrvfk>cR&XtSO68+ z1RUauxlswaVSpJ`DJ3B=k|@hQaf+s3s>H}9y1j=xNIK8V| z2?X0TN89{|c;E^=brwuTs0jza# zGVzGO@3018fR;D|tZJw;6HF}cjGyu7kMHP)7F1P*REbf~wG)|v zPl%fdIEa}kQI#Z*Bya>HfCD2i01%i2T5tj-u?nk6EO^~fg1Q<9xB#D^qP_tELg^E% z>HwzDJ_9rYBya*Fh*SEjl7A&QChZfT8ZbmMBxCucGFZKTNC^<@0x;-=d(a1WcvC9a z0w;KaDCmMJ=z=q-f;aesGZ03`2!k$&1G^{#DsUAkDA|_%!52YrzbF=0yvWkfDwe4n3*9Nl!*h0 zd8ds!uj4|f0w{x4ID?Zp83uw7dP1p*c}f&`wIFx^<7!EoprP(Ekp*~4HaG@4*eUX$ zSF|+CdcB$jSR&T78atVrd*zDMZ3`qgynnFS-rb~)!%Mt)IF+~nt}2SbdD2l$vNBMK zM5u=@Xf-fkh-3H$JLrPRn2a@u12`}qe;IhF&NNh=_cUd0`M3>aKg%rGnUO4}r2HX~Yd?sjGAFmxjVuCW* z2$!lg7KxO)MWqk#WEbv?IujhG_gT+vqQN%HIs@K~-9iwC0R+6wgF=vmOc+ZA&;dtq zhH1!x5V24X*$~4;TmT3FA1(kh1b`YL;t`>ORw$yYjG+L?r-(2B2XH44xFIQcfTOt} z3_>oTVImB000!6+19*aPh?^aa*R?dF9jezQ(vtoHqn)bVpQ;oO2r-rD-6RWxDgZe| zIwY*Rs-NJhrr0XK;|fD^i(PYqB>RW!?E>mW0>flp%ItzL2!oV>Ogd<*KA?qZINd;y zhc!TgEJzqIh=Vu)jf4?Cm6(h><;K^dh z$8!nb-}ozX2@ge;z(rMw1_rbUWR287ItDzCbnse+WRJ2nt@A)tWlCGr5oP zH3OIdX`qFX;tGPQQGC4sB%)Uem;maUfDG_foUWV1y4OBZ2^~NJAK&=nSgTJ{anIMm-DC8K4tHp8Cmj5A~sReAwn?hqE% za3!c6kF{OqWp>Ze5bTPCpL=mFwpAu-hMynIm(^0b{Gh??ph0!ug+VB1Mf(RpNWzCH z1Q(Wsb~XSQ@Pun{1~EVgj=`XO4g?)I!%^^rAaRCDa0Xi#1w#M_bZ`U)I>0~h00;qq zym1C`KnLV@hU|6*KbRpf(1kc4i2~S4Td3~reueD*2jdAPKsl0ziR4a1a;?0U59W7&w6rH~~k;fFE%5e%66d_@EQffh-_{L`di=ID#yw z0vM2i6L|tp7-NdLJvZWXF~I*lF;E0gaCKPk1EfKNX*h!nkrMkRkF6jSugL)WX5%nf z3H*-fVE2ijB9y*!iy4@|#JUV3RSB_3>aq%Oy(Fu#m}Bju3{*aYldS@>Hd2!nMy|1i ze}E!qKzFVIhhu<7hLML>_=h3+jBl`oZ&*I+y>~@OGC=8q88Fg-7Zi2z>o^-FQT?l; zRhLmRrBygc!!qDrScQpao+Ed<)9Bi1w!m@;;B?6>VG`|r=@+YApZKsQRL#0&3gK_Y zANF+lhs-(v`3^`x;k9EJ;9B84Fw@~GiYNCaUV#+W>YQLy)Rr`~UHv_7Z@H`sJG$mDN88z}k)VGJ_H=md+P z4D$nOW3kTSC=K#ivcbwSzvg`dKEdK}mg8?F10E%JsmI8c>`dk5=!}he441Ao7L)%M zvb!^>GeO#tk9l-d`}k(kKBikhJ43Vi*3R=h0E9Kz0xtM>2oF4|_$f28`=4@xMCbz~ z*rO8zhzK4$L>T{3@Pda62tgQ0lt{`56dpi~0C9n$MGzTBj4`y0XdA zHA|STg!{UoC9ew-!(iCNIjmT5nJ33+{nIh@>A%pzgjExL>(*#tw?c>Z=FM6)VgF!{ z{#Ollwb;G>t(;IPKlks`sed=m{`vFjx()hw=HDA{`~%28dF0VZ9)RR=22w}_ zp@Rw#mQ~XX71pE!QcqOzL|s1Upwju-O8lTtD~SXP8X2B8#&7>YHaO%>|!qYfvSr9)?1 zHt0hTH3YHYKMjJ3AyQQ;1r-uOg$5N!_{8jxYWg=hD!0zrF`;&0gWmH+I{A;-#Y z!2SPI>u$xxS88_Nl%`yB|D5)lFvyKp-E^`(_nK|1hE`2>$C5YCJo%-UPCEavH;%UN zg{B_0)c!LMw{@`gMjm_=2nZjA@IfFTW1P`N7l}sED5CXtQO3MyaM8uT|88;D7Wxj{ z#lc)`!G#)U%-e+;^!7_H!WEzK#=KnIrEnKf6zuU611}tM6cy9E#u{|kfilWvyupSW zeYoMq8(^TpMu0YhfmNbi(BTBoK?`jJ5=0AKRTn77u}01RKFlc493Jfh(MlPC=M#42 z+eH{_u)&5`iE{WvAMn6)Odes-Tb0;tZ$SmcJ|EN0F?L9O_rQ%dn#ecb&@$+tvdRA@ z8npk2_w1^r^+McfzZNd6;Lpm|%i_5R7g{fTv68Y`F=`3hAVCSDUcTZdV?8 z;JKC@a;&-54Qt|Bo8CL>ywgpoq0yI4H|Yoih`9g?SYRHZCD`tP{}hr(AB9BUM<#vz zlgRa%gd#~OmV{DDDF1vve)yr7FMj#|w9?8ft%T-&E5pPezxwj4|4;k!&r(Y(wS3|~ z|Js+o_{r}V?4t!U{y_`*$&Y=lP{jj(p$b~4ViOhI#3oFEK@FB-6Xr7?_Y#o_5t2|6 zi7?^zLXn72WTF)d`~@wH5kp$gVtqAih4~!Phd@xT4?g^1AO7$|Nled#4pIN1Bm$8{ zhpg~}Hq2otY)HfPxzL3-TwfGx7>ZAPVv9EXA{aZNLi2&=Hpj`0YnWB4RgLB}nHx=P zRMRZcbwe1my49@~_YYC5f;FokUFJkb8{35EHQI?CX}aSbIFc$=vGSbZr1Pw1J+eCC zx?O(M13cR-P7}p5-tySRuH-TAAAC?39~u!FRh~x@-s40mVEGSA&~lcR2#qCDk%@nB zB7Emb1#5uGnp#+kn8OIoERH#ge+(m;p*d!Ln9+=EM57tf%*H>Mq0N6tBOB8A<~Omy zj%0+RoaH2^Im5vYbFyO^>x9QS*a1(o$&;RDyJtJ|2@iS7^B?y#+d2R6IZt@_lb~h` z%{2b;4R)L(o#BY*KOV}CZ~SAT7NzJp{*lphY?PrK&8W2qT2QmaIj*r{vOMF^#PI?|2U8YEU7>jwf=F4`(CqRbsVD_r zOTijW;PM}t;6z>RD%iOWqp-yMM=hMmO#CVHcF<(zFqn~8YceCU+SFz-jG;FbViQZuS`c%YkYz?HYC+9xq&BtTxNVVU>wk{QXSEp2L}kz~|{Gn`xwXgsFN8hHO?_qrv$Er-+yj35lM&b;gq=ULLoS<0~s1U3iF(mw|YrC2_6DMXdU;m2Y+oeLr!L0F&6N}iz zGPbbCbhfkq@r+@lA{*OeO*69HjDIvE8raUyHn8~*Z=VO7*z~rz^JDIRWTV~gCh}?7 zP1`kNL(OWYH@%mvZzc0P%26h6IQCglfEv`w^`LW|Su_Ut zj%`>&IiUg)(eY``>E3flU)kosubT~Pp30iipbGyv)65y`?UBo%LXc#>K}T?0IG( z_GPoinU5XDGl;>AZDc!}#K`6|thww2o@d+4sHV5OJ?_2VF5P@@Hzx5u?jc8m@Ow9R zyUES&#lL&;)*|=1*RA+$uG?COcQ?D!U0T*`zRJH14LrOVXM&bg&i-8Zd2&P3;s8rm zJ?%Mr2Zx;5-`u^T_{VSZqv=we@xkSx-J=gp8mq?cHd74`w`A9v?A%UPoVGb~ZsQth zspCFmRnL2VW3HnA9f)z@DcT#>U?= zdAvWw4x<+RuW?~LLuhORwh^GV{aDsnxNyETW9}a;N3M~0@2+iiXa5G771S915TnRb|MLCVz-qd z-s#;ZP9O@NM?+~8L$QM~cp$@_-Ym}I;4lYjl$UZyS|eRmd}s%Lv5Mvp;T^FTAQeX- z{mN{7*P(TT%JEKa9G@^CL$O2^8co&9`Cw@9oOn!CP4V2Mp-Ol`V>OV@D&zudC;TId%^zR^lh-*SkM)@U0iw029WZbk-$~N|GLuDOQe%SP+RR-++;PJhNlUOtthIl#4Xut*}9p82s3wYSqcQ7AroX&GZ(&|hHcvzKvO(%n?+|J$3JFw1DEe9T$ zOMrAA9~|Agyyd#w<6QrSh8QEy5}tI z5*?OR9`a!b+88ofOELUIF}xjOcAK|NWHp7`GZ3aDW*{h{q+}|loa$#b?PnuyR!UCb zaWPX5Aqsqhcfm9wIOtVw}D!Cgz8}Vp1s{Vu5C4Ct|3-e&7T?;I?6& zH?8TV!QQ;)r+@Iy%Y7_^~wNc{?SRWZB?)?LAQjTa)jv3*Gum0d9 zRiib02WH9YI}^z zaYoK|u|hQ@-)o2$d$FS^XaaBik?H(~9`!=1xre1KW5x;TA+22J%pO>Q6>Kmga@Los zcpUC!PR2RMs1B)Z%SpPHR4W@Qh$)Gfh5-U3Gy)`KuO#H7`*j^2{?+&T z>ozP_yhHp4_dLju)8WyNVqqN3hm8)eDqoHhe7s9pTl zox=Wu;Tmi+Y!;#-FyWR}Bl_vMedI@WCZj^AolbCQmRsdk?B=OpX?p|mhcaH6?!b?=q?x} zp$b>h4l8IvB%BL8Vwi#$NGhdLXdpt=*<&VT?=A(F*t%zn1(UCpF}m(yK#EV0B_#ey zTVn008$*)-rrp~S!y&F60_tzwHD=@PE8XoUF~sS97O(;yFlDx#HW_XQ9-`eUu(tm} zrZs)0XX35gEiOk+>br4h=7AYDoI^2uf}Pc*NX3zD;$8^{OKKb%TRB@!^cMw4bOFk%`*W)<`zGjiefk2na89Yf=4upUs={y*E{mzm#uJvoa@N@f|7Ym}=rYN0jCVd3b{zvBFud?7~Aqe@ghBoJ#_BO)# zp(ktnV>nyOxtjAon1Wr|U-}{&A@W!oGZs8I9UH?NrBW#^k zG-eIzdFU@dSb|r?QW$vqHHy|o@7C3>^ zd2C)ONW*ls6g;$1@2s-uWR6j_>`AkVZeUJxuovw93VRLJNs9_{W(6*UBK%XHW>A=MP< zjINOnx#y?`G8Doa(1CXIfwd9{tbKQfAqdnpo%7hCJxZO`ffyuM!kTxjxRP_(?$Uan zbwH{$eTH2-<4;@-Un5P+q4aXEa-Qi%R*t%DYe1xFF0hR=d0abB7+xdVasVn4)VJyG;73Z zEDma%ZrjF6xCdT*+pfje`)R$7yFd|i@XuMp24Iftud9zAVvzkZjK-CeqPVxbmvv}iF zoyIBrgLW&A^bUwsLmkv10o@YMl6K%p2{=Y`558P zF*K ztHW(0Gi(AvXju`N3aVfz{K9mV>~NrN>X}igHV#jwFntXncLWj_8p|5}gVgWG==N+G z;S|VSnzr+ltS}>k9g-w{@@$P0)TWMx>RfTX;_#;UAse_)k&Sa zTOB~&W&07N8N1kDzBeHLyD1P;;0r!L%o;XK;4oFs{vG@`?Afq|z@8EN_fKLihZ8Yo z)Y$A_Gmrn6fh_i~*sp)TMlQq{G9<~7z<_ZyR;(Gblo~sJ^!Tx*NRiBd2A#-^W5QvHf)@-#rwx<>_1_`Zrz&5itSof!X{QTYZmU< zRjb;LCEKgl*|TxIsjFt~-#Eh7stq1D&KqH2)sWqK{12J6#oiJR{--XZI&0o0E*{5O z9b>|q|9NA4IWgjDfj4``Ecfy1acDI~!ZZKqADxN5eg6Z*2T-0y!v7)q2MJLjNdF{Z zl$0scKTFm*S<3#e`ov6}zK8$EiTl)vtuUtgSIbl^jI%T%Y`8EMLWdG3N*qS4Rw{xQ z&3kI6W{N54mtO`_X`o@4amb;O4wUGoj#%mmq@<7{$|#m_0t%#_S}KO95u1sIL=6cf zu%)6jiVCU#af&7@Y$n2HtE?=6XRNet>E$0yR?&+rv-%Qdv40$^2CcmQ5k-`L?qa2{ zDbY%+8g&{IjWo^@15L@oAgl5)!~VJDHNrp}XQIzQQ_ULAVk=F}!qoIkBX95=&C4<$ zJ50%2Si$3si3S~1I6(g4BT?ZR0mT0~jhw6QIq166PP^$^BU5HURiLAvfsfXO z7`g~QW%p}n7F-?bH9?F3{Ad3rX0|R+*o00}a2N!U<#kwZhqCs=wSkKES(2L4h$#`D zGHR-jkdjJ9amDQ_oK_O+krjI%gGzG80+{h}2D9eigoQZ` zBgS#^i!#^bA~UUoF;Y`Wxdf|b&LazpeIn2@8tq8XC8Gq8lTAK3k3;!r^kqc)e_n-^B$o;- z&?S=w`ehdSb-lk=UH8{0{*i7{pbE8ZA`|M6wVE=L3?&65*ka%Us}-YZT3ok`^^Jm9fj%}ON|bWMa*I1vb`Rz(nE%x8~J`v)*65)q0FqZXxbP%H*= zv|gE{B>$72uvBrNR{SE8lUzt7F|w=v;pBflX`oY@)xfuC3a$hS{Y1V1TN+qZ_2N}&X$CUvW+-C6`Luh?=T`(le)2o^XW^-C`A zqRZo~VJ2^MqZ<@cO*u;wILRQxa7caDJDvLI9#s$SWQqjt7b8Ohgic-I;X=d!6VZ7CW4(4nlc0 zs)EK5BX$3r$kDnt5M=>UR#JfpFl2!rU<{)anPpn{jJ3&(#7`ysn~F&|t5U6DBqk&= zMofw^6Jr>KMW7@lMr5?N;Qk{TCZgcBassX4Mq?YrEzxtc!HnX{(kXtkk&Px2q>NZb zGyW>-;jFo)KbhqLY11sKF_<+^!?-Vfj!)s72n`i8C$6q3u5fx>V44W0 zaT3fk2g3y?PUaiqsLVJSDTHz$A=pV_&3}eL-iR~Xdi<4mxjTnf8Pu8;LEUy(SY+%#Kf%ey{&vKHJx3!6pbDQLy;1s#d zc(EAXipyjH@fNtmtzg|O6fSdlrIxkCMnC`nA^8La3IP8AEC2ui0FVLU000R80RR2N zGwEM4ft1ihQ8D4)2!a2iIQ&fw{$;W@;A!ht{#7GSl3j%;noZ&(SffP94Ku$g(r4v&7N%d4zQbn+rRSjgt!2t{~ zfB*u!2}DE@L;&%RJ>0Mbkvjs}^Pw$z{Zm+CeF@Z83wF`)0!T0TwOLmQ{*ypcJ2sHq zKj-{Ii$FpC^HwEAG*#JaAf*6N39PlI8AQ3|mY`^;1t-vGo@K^C2`tnWRBRAwz+_Ob zRUpAX8&r@XmH(KD+Xo?-@DB+bgz!%Yhn3($4L<0R%yg4XLWv$+2!sMvL0lomeE*R5 z&lEv*Bx$7a38WDj?-BA3A)e5e<6cPJl+;ZHBvpU`s|vJ$QltL=!yrfxHMM~SJ&^JN zg$WgOE zp42c?tRN}HYEU!*<+A_=5KVMa2!b|(gGE{3fz$sueQQ>`B48l`7R-njL}WqaVGcbM z!NgcJ{8P4ziPbn`jd=l)!;JqF5d&cVIEf?AZc*0vf%hc6uGy~6FMu$yfPw43A`{FdjPds-zB2_h^Og&q~}#YM*|EJ_g> zp2qhcrF%`W#}sk^rH9R){$t1}uZ(g8Qu^_{SJ29Xw19w92^4_`4nj0NL{mGntA$^! zdA2|v274AzTSHW1wN1hO!i+JT-M&CE%t!+ZcQ@a_-b@X-7i*YF7H6J zAVVqm7nLZZugFv`ast8JUNn#y34%le2@4&b?qrn>YydbK2*GBqa0N;*1rH_r#};ZZ z1KAnjcki(nV$Ks3+(86)a##c@Ug3!!1nq~-YYOxKc%~GdJWp0Ysoq1b_m83sffS#) z&;Zu7I2r`22uXrZU5w=+^4(=?Wow@pV?f6Btq*>A5eVFxl%oUeC@60f$o)VfIKlm6 z355en<=91|&|qm__%hs?WaFB|kg1S3U_t0)W2MAU#sXET0K#^IFxiA|112!RXFhnC z5S-!^kC25y{^1IUpuv=Nh{6p1k)4RpObi>5lnsM|g*5!b3u*AeKXNG$O37gghS)h$2k6jpLxrS8U8WQJQ@x&A~0xERzL&C z=!-LaY}}~sb(q7YKm?H~%|9xDQGrB@U>E$1NHbIc3#>^360l_BDxfgjpw3(&V5!9( z!IV?{V+;bpLnOGMf(&FJ2-_JMESbQOUM7Z@O!2}Y4zY)S5R(wCWZpEDM-w1c^&eLq z)ex%*l50{ABDm7ZKP~i+ya{A1Ilzlr&q@O_w)I^tLYo?25I-{JB?mFsTG!l`gKPnX z00%gLx4iYn9l23Qheb@@Qm`3?HcKwYde;6V^T+)Zl3<^rTUR`yAP}ML6%zH2H`E~r|HuFcRB(lvX%QtxZOVCU zSla6Vf@C+OVJu?>Vg)VOMGHx?0;KuuQOU3dON@q7X2eaeDJ_Xh z1%&8q46~mqZB(?LgN;nlwCNuyr-Y9vr6>>b4_ROgB3nR05I``38vG8H0?E`~dOT~8 z06DosHfGXzy}TtO6RRZu2R3LN1m)^szylEyF9gU+E*8v)2<T zAo47?-8HXj1d3z53Z4MMx(ZlQ`u~xe$dY)PR*B6U0W)`|4N= zBn>p=aZ?T<2K->Rx+w>m$wA^wm5<810=a+%I$&%lLSW`{l$M6dNg{g`fiCMZQY8!l z-!iW8_J~RAv*Z@Wpm+;3UFiy(4Jhs`T{HzwauUG;CMC~^=5S|697I@fnE|o}fX7eh z9;t*m{(|;i4S>7<+<7A#yQj8sA^gCIEf(A89ApxwD9FR35CkO9pOq4cnW$nY8CgRf zB0=_ePDKi0FabN`c|v3WK4B_90}-$iL@aj!+$0djW(yo}dKXew%a#K}&_3(p4oskJ zKtT;J&{p-uKGw2ERIyfP1rcTQJ^%E3AR&W$;cWt;VOWP76)@?ciNQX24f7xY2MVLGO>eh}dtAYoI70udiU zcq#y6D&zw-zy%|71tL*YR8d_1M-^!R3WwlSMphHmB{Y3V70DC;03e7YH!DrC00l4; z511gP5@k~V@eddP0XSfK+;kIWwKXx|AxY8$9%2{Trfqh0M$e*x>O^z`K^N`=6mNuI z5W!DC5iW3+H~s{7;Rg|K!Z7$!0iNZ2yLdnkc8frfeFcPkEAVw!7liAUSt1~XX=fWZ zIB)@nJ#>OWY{Dew0ufhGcu_D0IQ0*dFbS$45NZHpAh0nJ;R9DNG6Hb~DHIftmt4jr zfOuF5>VY&9Faj!|0w-_*7tjz#BN>Az5Q7K+%CrC%Z~`&_5hu_@Lg73+p*&CK0225Q z5g>^cICIqm0n<`Ga|1VJvqpW9BB3Z5Y4vlGge70CtOi=;h z2WP6dk5F(U&Bb6?nY%VkRi9{)Lji_9UQq7%`C<&EDDn)%Ml~nfg`{(`tdH;1@ z&+~dd9y{tN={1XrQnIY1&96SEm&-=K48dxl_!4ZvB{KW~6WkP0&}55@py1UEU9i-w z|1F1q#GT6GL$Dz9nNY}P6r{igS<`Znjn!!>fe8V~Y$7OEOAgE^v}ZtldCK;okR7f? z){(t5C=UM#5aAQfBkgbu08IwuQx5CP#z&?fw!W5JkGjNL_q*4oGCvLACEtd7GGH~Sn|ph))(s@k5<@y@>*-cL_?UzZwg&p9*!M(84Er>(>f>mLc%HC4=hwLEg2_%p18bN@cA*06uTrUCJOAza2h`wAy z?*JicCid(P1v?Xiy(0k$9{2|mZ?1t&JLMMmgX?*?Tn3wM9?FvZR9<;Xh6b3O7~DNM z9ilPo5LGL)4#A)Qv3FP3e-`-5Ewh*J5sTXziw!=XM=dH)!iul$wZ zHr2$cSF(H*^iKFgyzx-vG}zvj2eta8x2w!vqii2hCwaYACQ{-k>bzM4?DGC9vLRYN z0gR77?6;|gjn{Cn%2B|PExKm%)h2w18mj;CIKf&JWV20 z(Je-*jf_fR!lA4~8W~pzllf6=xU0-WBV%n7pP)yPwn|50m$sy9$>^)hoA--vW~|{3 zQM`p~=p760Q5~^y2AE{#294ouP?bL$He@;Mg`1bx4e*yb=oEmD zOiaKQkt`_g0&nQie$)Cgd+JL`3x=AIlHS@!IxA5oOPRF0x+NbYE!osmd~e-8)(Bh!;C~T3GkeY2h+yptW_Sjrn_3(B;<}+i z56W-X-KY-BZ}It(n%WddblYVIjO4vXAbzeFb`ms_E&IqBJlw01c*ydM@Q{JuIxZ3^ zxxhixn_?los5F6g)uOUWRcQ+xFW3?n#JHR?XYL2evtBuSEj;|RDP9Ahzpd2^0kP+F zm=h6z$^{dMKsp9z58%OlSrr$rWocp2WP;+Vlr;rS;sZx%T!W-m;RIx7*=CAMTK^>W znxd+L?*|>m9G#R@koS0R3px5siga1F6@}9g)KjXEyU*q@!bpxmGGN?YMgDbtry5u7 z`;8~t8Gc|BK~3T_InS@M+WNEHmRm@z|Y)?F8^XZ+v<6iA8RDRI}X3LZB?vhS)-F04YW~qN+_|ig}1n zy4d491ZNE~#JAP|uIt7JF-HwDz`4256f_v@g*R07^Pq$ zJfsEr<6h~S8H*t+@Xd6^+^Q9vyV(eF@A}UU^}%OV3!{Ke4P4ulDs5&M6zbH)%Im; z_WPW@B6$9v!@lO?C2t0G-&gw!&wBBlq(u1#_sUATIiCm#u9f?FLP1c&U$KZ64N-{n zQd$%Lp&uBlM6HE+8vf{L>m4eisrwOeP-UfD0w#lu3J-<;OjMa+Dc$hFop0N-aj*1z z5j-O_Mabo%N1ZhpU5*1IzC4Wbm9~czRDCGAgpU#esG>DS482azUdoaxVQ2e{K0u%KJqNLp9Yy!G$1*8>@EePyAh081U=z=0*|wlK?z}?8y1{0ZWtgC4 zA8!VaPz6>HJgh?C(G(D74cN!Ce$E%W&PUZV(Jg#bL(8I&zbNFPrnkgX8P2-XFi!z= zHx{bKpDSH8zH+1?z>e#5OhQs1B}Q2S_X7P&ptQu-lUHPpK++X0q_36BB)@2_JN0qu zzK5IUAc_|zf|lSWhXtx??6ZlEZBrW%shzF}!`D{4?V)ou6f6QYVhoyY&wxn2ULprI zI>WqT+NQZAr=*MIIIqsEu#dGhnsUz`1nA(38b8kwd-2W6@5FM@LV$X_)d36 z0L6M1fESEs?E1jGca^S7Vm1H-07P4gj;bAWNob6RrQb2DJ_CC5TPi*2mVa+hjZy;0 z)98#{OGeczc=Qhtquu{;ul&+8Dcu&i-0!4ZB6&8G<6w}|H5-E z&`$4%*D4MU$wr~lD7Sk`=9#ioXbObGpq^@JE<%L!qBaAA;gWy#$h zBaeS1f{U^~{6^x7lw4>HJ)V%X?m~!5uU9YZIeCS06dmEeVW+a8vn}qJxDKSRDGh zXVYWBS{^0yfxfv2>r0i;^vmm_zq2nui(}0NxTS|&B`MQQO;$K86L0c2wrx^QRQ}KE zzgHi>s_eE^yrSJa;5C3%6u$P|8g^6bOs$oab^@*Uqp$DXzOigb%e#;oza8-J`ow3n zg06F!*5^aSEK!WE51020{5louRlUkhB0A^ezil_QP;Gb8W=o$IAmyudpWe#^g7=_9 zzPvDEs!K0DJTi0Nww=YH0nU*p^4hwJRSf50lPod)yWeiH(U~$rji4F-(^ctnA`Ia@ zvJKY!#L#wmb+XlOYybMPlO`~iuY+Ykxpj0WUQ()DFnmg^Qm6)B|}T7%xBlU)Oq z_KH?Rv1hagQW-!Z@k;Wc7`1wl>?)SVjw^|HvgBE`+R4|6F~s_`bq|MY4LDoAyyxGY zFe@%P{gSCAmx8%A$AuttFNIcX>MG)DvXwg~sl?y_!{dReYk4gwffUH>;r*Yw*+ zOqG5nx#kO0Dylf%Ccm1~$FUI3QZlE^@ABIAibdZLcBVe_r-^4qwYycJr0n~)Yf$A-#Wy3}A49gt=QsZ7Uh#5#H^`WG$x7e`< z1i82NNfQpSbn}~|`jr!6NST_MNvqLSh+RgbRczNsYn5ngo#_75&1X@zNlM-v2!C6f z*sOd=z=TM{KWo*ObvaXsx?)@&Saj!?3KV6wMoH1$C7wgh)mJ=;30to;2V_NCv$W6X zEDUjF^OM+;2VS=h%bjd!T+$NT3f6)S`rE|4F@812BwI+GE{0NtawVo?=8wM{jpuEG zI#U0Zh+hOf*S!+@&!KqL^pV|kCiEk;$RkZ%nw7fzCkrMo zDW7JY*#Jt@Ey_=8K^~AiJ;8_*6-E&gM>DzUcc#&sl^4rGZK_y6$bR(_T}mUUMCx?3 zTIuk6K|0(HI94xo?r;LJsk`*BhY#`HoB(X3xkD>I-f7p7XsxP&Ip|nL9wsCCWMvW}0 zlu$AV&Fx(8u`iPUc{}QzT`%;Xc_NwaJ)&c2)YbPSMyA6CrpMbX3@+)Dt(_2s4ApVN zEOm)7Ja}hcEjZ>N1!LL>d6YuXGKQ-c-Mv^)BZN#f|W86^B zY0UM9tRr9Vn>ynYmuS5<;&AsF*Xhk6=hrV;1GU|Ltj%IVOQmz#%8S`5v6|Jq131&$aLG;mX6Y>4&hU#yc@X!Y%yPPCI& zoYXgro&haas^RH>guclgqBk7-`wKOr35RfU^fT1zTSAxI5_ywIq`7^vo*Hx*j!IIS z_Kjo)-szccEwTM?f$o0)9I&**)xCNA;_DhHD=|;VFFYPyti!J0NorrbWDs8!hcinA4T?s!4>!!~OS_&jPT-cDJm8XuzNPxt07qCtXaI>^3$s~@N?4m48F77`l@~QvK*y@PLXMX?FdS@v< zcW_r;&F$#sFKxx@VOGMT2xq+`mhLoItPGe#I*gEN%Ku`p<3v9Yw;P0W1V9f~dxZH* zOyp(&$6=4nhnxZ63Fef!t*MvFg%c$1yDu`HX4R8SR3KEhMO=(B?jZht^8C}_+T0A|psppw3@-`Y|>V6liax)MgN&FZVCIAb|lO8D17t-gnw{xyvU-3{Bd^{*?l zWtSS-B4q<@vX#kgMN$YgEV0@Q_1uSj;c4I<|lJmwYhh zc{9Ct9BoUnbKlK29f9O*wcIwOd9^v`3`m;LWqMhWL$OJVy8rSFy13!~attZSqWqE< zXMxz5>aUig#S*--d6!;8_mUY#+KNu>z2Bg1CjoLu$2#__2~jPW2cfp}h8=K6re&>h z!6uV(spC!*Q^6n9a98_(>A+8%NFNz=pijM^ONH_Yq!E={-!7Ti(Ail{>)1jZ*i(-( zmXK-z+!af1+va0Nz=w5-No2buFXRz=2KoRKX%3R5YsBOh!X`@Q0FXcyw2{K>YHN*8 zhacM1m>>x{=*mV9Iv1#X{V;QlsYV`@#b0atuFFV) z4Hy9k#^8-VAd5t2@tG3DCZefAvMC5Dxh9r50}q8hKKPfCPqx4R2eHEh4Dp9QEQW>7 zuo-3SsE+0oS*y$jBOf|E+{FHn4>CLm*{cBCnA6FTXv`1fm?v>3PhHK^X+M$CzH3gu zfWXw$YZQ^YbwV2?%`cs4gwg@nwT6rxb!EpP(Bc8);=OjXBWP|X8X!6fs4{Pgn9!My z#45Ld*5P8Ui&|3>TU$5j-n)dCCV@(C4z;4rFMKqgE>|$4Gcyawc1fV}fO$^g=d(@u zp_Q>sqHevS^Id?rEJ%u*zLVeAZs>YoGjz(B0PkL^IZGwVT6TsxwK;nG8_;9|Y;a2s z{6*aKK^}AL6)L)fS`D@SMYL}`PSB!0_=|X`#(t7El*zdk-vFE4x|fi`P6(xEVtbrh z07hORw~MoE(ULNt#ZGB|n%jj!8N9PZg1fl$XS1ILA*K1C7J6v`6CX*{RZIb?bb^>u z$dzZc(m>j17z@7%l;U<@Jyv^PfSIFR`4N--yw^AfN-GI?rvI5+K4**PgJeg#dCu1R z4|MiY#`8ndgc>uebIw^CrappOx6lt3W z(K&IWPg_yue|=Z>>vUdZbo8uYGRdr7{{5vmCyCHBWdaMsAdD?{9(v^6Y7TCpBW1bI zg=#G<`88l(Fqj$TF)wg8;^+k;A*2z&HW&%w?Ljz-NXVA%dGqkCz`CN9ztQ01@&*o3 za*>!~C#SHJ>72wFb|M*3Fv0fR>zh9@n;+Gi%0&dkQ+vbd52ju?o3kwQc0zZ+ZOc=C zxiGT@fzo!!W0>!-t%`w0-8`=Bnrh|<2-mW=xP>&ggB~hMAJ5o}j0Rmt_HFqq%WL7J zH-Tx9*O^_$%t_LU{L5r*+Y7Oc{caa?Yj0hgAhAh0hI$}8(&Y5}c4mCz<-R(rOBf8j z`~uDUZvf{;3&XG6%`47;;(-GCf$Urd`DNpyMI(@g`zTzEvQ}D9 z4V&|cv(3b7j7+}E2C1m;`}YPp`-dq>cqVWODqc2zBeBp)u9P)1JHPLI_}`oEU+d!6*6X5Sy+ zII;GntIV)3=LOV)?bH3+>B{f&x`$-jOVAQ5T?S7E;gBpWxwCwhiD#xuIzFub!-C(V zVOu-1FMy=VJT}Y5-A5%qzYgBzhrA&Y-Xy|I1$G1_A5)hPUH1XzwSWRQal{C)!I>9v zEex@u`|j=qzjq0zu|1m$gKYMW`16wje$4*A3~jTb&5T<>k#tl zx%Lc-ah4+o5!NnlE+`;hys*p(dgUj~H_u9U=_8JSTDmxXA*>!ZP;MW~{QUjt?DwJx zPcjK!7aqS3s(D!YJ_m)1k8RWGyZ?vi8lRKl`ABt+Bqpo6`(rFS} z`KG=zs0C4oKhxlYzwPdTlSU@>K|^CZC^pJHFk{G&H~G<+{zqbh5#@C8Rw$-E{7g^2n=xla&vLFDAYayvVCq zLGS-G?cjPka%Dbd2~xIKEl3y~Vq!J57oUQ{R`1?pjl@V|9P{0x=lKP(1yamyKhDq9qo<}3aR{(?VHJnr$rr!pAX{@T7E>bQ?)V$|lNh#9u0<0TvAi_(17 z{22}r@zTu3_~HkSdf~eI$1AT&M?PCUc)%p-3<&a5zUBCTtv|pz8*o(;_%+kscTo9i za=cRD_*j#c+siT1iu-4S;r9XKq-*YBlJ4J!lonP_>=tF3Xaraow>}I4m)$fv8dHG6 z5m;qzvkou(uZa}cUx*cUpP1kCroP*B3PBD?Xmifnj|AQl{!McNYulXbIkX z(Af4>I*?|!ukpU1XYE)nT3~~<(9kfyFZWQq!zO|8zn=^5UY(7TSyT{+-!7Emd3OFv zx?me*;INN9wfC*n%BE%5=zHY0>OaH8aK6uY7O^QvUiJI{UUX| zxa*Wp>RZnipzye&@B-2%Y=7F*=yVw@#CG{=^{%w#^3cW8T|*`)RemYu5f@GtKqyy4 z8~Yz%`yshCZu7{SJoq(1ktrcl5#**P^5o!6j=y>6413cVwtg#9;xER;!YUGwB)auX1$U=BfPyy?TW|X^%NCTCTXi!D6!ukH(AX|6~85XOeR8H zxXPNY4Enjlc9$e6^>_Tw*RfJT+aM%a<-@~^s+hD|^7Du1gn1^mBr5EOhnk~QatWn@ zT$i_7sP){9D!E;gZ-fsV@$!`kR_AJl+O0hWgn5xT%&sNM6YHrZK2%%(v>z&W%IAD_ z0PEA{)6g@&yWRz4plAR{DEV7F97Xtaap!gI;+sgTdTyE>+n<_4CR7 z%#&aeSkZ*Gp;RY`Om1|LT~GFObQ#o~xVH5I!8{8dlze+e@XH^2l)|d@n}`{=EZMyh<66?@Op2?tf9-yZ9^i=; z)n6jtq)^VD+0TUyPRO{dz~xAXcQwBP!khF@{m)B^Ukb)}OP8dD9O)!5Fow0+_B3~Z9e0!lBoFv*hEgy%I(A12TU!G08DsT1Ig9rcjE{aOrFun3fC}hasP%4z1 zerplWyH}a=H-61K{>>crx-OI@t2nO@%J3U1A8pKo<&vk`T|L`rAoOwn5`sqc<|D~l zO@Cwb+GQ+KLJdX@V55H?30uAunQ)Y z?#^$sik_8j9hr3h9d?O|m7a~krOOzmoTG|qbV~ktSQ5Pa!l1y2w z<`A&=KiM5U{W$mT*5NLt08+5%k|%dVL-w)AsA}##$0Br1ZED<_s>0E8qLp9(i#lk|9aB>{ z+KFxtgGxECU%HX8xO?-Tn#>uKQp(j{YgWel2q7O70@cFWZQG)3wfd|d)McbnjE+mU z=7k)hq$i4MAPh%pm;AhU4N2L9MUi#c{`hP8#B1RDlR#p^_ea8l7u;hqm(@kI{S2Q4 zi+J$DNAU-}6&4~shRf@_BGup)dGooV6Ob9z713aH`)s1`{ z_woIaokWhZ8G<*d*CRx|lHdSPN)e)grQB`#&SL+eB0|H0O5)T7Ywv3%fy@6>4XFvI zE*=@(H@zq;OIWJhv^Tyh6_LOy-e0)gI+_`>6eM%1_g!h9`Cw)TVL;9W;vV5#nv6}l zmZ#{tYf*OA3K%G5X=lSbyLW7}e_$tictH zVnWG#M*61^i;V`UxjG38_X=``MA%v&_1Ozq7yi-G4TBLzOByYUxL4qUS^^d}gJRc}IvG zkgE0dn{Z36L&mGi>ejz&((Gr!4Cw!8rqcZ zlA8Gvb1C8$-T9_j$#;U@nXRcAljC%^cpbOhSa3;e+(ltjnlz37#BD)OE#k@S_)Lu- zw?fLh@sg8!#)pgaDuK#@GEmC2e1{03S2d<(E^WKJM8t{1muY0P^id_Xp|=T54KV$V z!5!22F6gcn7qJo`!&#b!y0FCPRt>-wdU+9qcEI}E2ZN(|i?M(Vt)j@M92nJ()^pGs z$wsFIJ>+>o9{$sJNxB|huv6N)Q?Bi-P6$?aa5pu1vi>#sE=cp>{ztF?NyP0HMOG9A z6Nm(vy)1TTRouD!$>RIz7tVOd*pjz11T=+KecE;6h&BC;(*c+fr1bBdN@3SE^$VW) z!T!kJfp57g`il;Gw)_8+Zp_r|F?tceHM&Z<(R4Y(GZ=eZo>eH|g!PurY%dRB}7G?f5)xOM8tRgCKN z@q(hK0G#nTqZ4C$#7?en6JdltFF2sS^nU6C)}Od4lX|h_qkg6QXQgmgY&;()Gj5nC38<;$vD*yjHI|8->j9#{~6@E|kjTBo3~PTrf$e@Ei~ zq`q;Towr_tSdZD>&K3fRJwCS^BEqA*pmgMY^}k#i z_Nr2AfEtk{6*5-Yfc2j6K}5-z{DAv3iugJWQ+zJi^w^{Ly(6v8-T!5bGbMghNIehA zxV)DyLSxA@dZpv2*eMDLJ%B8`V%MW&zwGcETmC|DrCQ3roiOzhIq-0{u&qS24N242 zN;k5CWV#L-$JugRWx-m2O%D)~H$NOO1PXvQ?r^gI!;qwUFK~y{!)TaCm^Yh(`w{d< zVFMCI{1w!~&o>Y}dO-DWKWZ6+iQ6&BP_$tCUF0r5!pT8R_}FP_D2aW(`Q6VO6_kp1 z@VvpFb;2o&efALAb1QfbY)Q;xJ=}NnJX|uzZn@^SI=YH7RHFZg8X;GM^*m8aQPAr} zAgO353%!1+UvXbeX@>HiYY4_tLr@3K|zV5v3pn-R$|mCgf@Oos}m!jS9;xfk`W6#;U4nI2?S8Oeb;7>KcLt3HPb z3_znJB7R#f+D%a_e`nqj$y`L$QRt3o(U1;uW|e4#cXGOST&7yyNT)d;h~i@^;9WsO zzV_L>;5ChgcHEi4{cY0cGY0*2hQ4lG!b)eLwW_{+>J2xCBAY*KCbW8UGq8q=7+-rRZR39Ajv?--h(y_fa)Lcc%uYQwWme# zbCd_3bh76;%uijON;^QG*D@8E-sX0qXEv5CgVAT*DE5n2?KXk0qCx>9A%faf&oGcY z2A;A*&e%aieEpBKyIwn<(3l(o)sZi2iApyeSUj0_4Gd5QE0pbjbKXY4?Uck~L?>>q zITpsLyU97wMyQ(R=T3DMKGKB$SnYq67ya23;d&7AeMg3<(!N2MHeV^?p z6c~ZcxDwuA^vXOzWvH(voTGJYFZ`e>1?K;@aa!-Xs>b+v*Uo74NC}G?(gUPvCS==I zk_N)ZQ6;sGC}gMIP(CQ873PvKrRuoD6FcI*>w)Z~QTZDO>xn!N~RfNe(NF8Nbnnn;*U9sij=PP!rMRXmzmZ{iQtZYlfNSlxC zg4`ua1gxr+=82E=M$-13{U~%D0M1fGcG@C*ssiJJGyuAWV@dn>qHhX9&FLS;qd)gx zB#Oy?`qiqImgyZ zWH7@Krl`=Q@`DWiSkgkWFNJ1a{=cBZlx?mZ2^X#N-1aPAQ~*ek$m%U=l$NyAMXHkQ z%HPvP({mdC_G!#}_PtJ53&=8{dz_bmm6;>Um?cO{Bf9-mI}hw$@7zRf-Y!*n}^UE9Gk5!9^7qVGC}(C6YEu z7ws2_tiL6ZG6W)c_vDB6`hQ^{ST!m0z$n` zl=y24J}{vDw;%MfSIt@*8JE*7msI@u2+2nE6`Cdq%z#!{V3&?m=>cuphJHn#zN4eY z^QSJD0ev1=m)B=7lT4Kiq5c4~A_oPt9&d5;9x{??#4_07MqTkbyQwxcP z?&>EifPL=``u1lM$MOp!@DA$VV@YCz+hk)eEi9P$NF9JQha+0DrQis?`^Szl0PB1F zc=IbVno5?BnxV)A?ee%imdh)X)7bfU_0=`n(Us{Be4JYo>5Z);gaN6iNc_?k76Av#4Vav!kLXM!dUzm%q_r6yB(EM94c zU6+mX!2uUczc!&?8wt@vLUw6E^j5)oulo#wl6LXIx+E73;emxWPZz{4!S{1`u*VC} z#Npo$z}p9oOGB-Y8TzqN4y#F_0)U-4XPpsvAc}P&iiP(F{r8<*arcPvWjlV0Z0w6` zhc+41DNLaCJCKzcG)hL#(|2^Ba~L~-F3Ajj+M43@GuhQ)=r#95FCDq2?A6UjOkF3= zF2I8j3LkfS*wJ#~g~z@S^%g1~z25!3yhN#KD8A$1$AK~L=!-QB3aX=bXBrSA2|@b< zGE`#IQe(i%THWV`>Aag7GFC~cVG+MYct@q7G6Dtl<_-$)r}d^O?HyQe%|_#UTKVN} z*N4f+&F)-oPfOn8sPX4=#^1}Wl9^NJ_Dap*USp zbY*?`e52xB?P)8W6B&K)_FS#Oj9m|}s7Vre#N#YfCyPWS@R#}as4^y|fsLW8?-MVw zew-I@cztwYS)T2VVt5||oR3iqMLK##y8tl?$i=6PCIdaXXT#Rvl#-+H8$kdnsJ}QM#YTBvV5PD>`6h{Bi9~azfp`XfcGV ziHlx#GdyjNSp8kUjq{Y%-Mwf5Mcf1>c9sI+Fn_u^++e*As}0|XIT$9sAarf`*{fFo z(g?NGaJ8u4*tV2cs~@Jk@Hln2y7|AOXDPn_@sm^P;U&AU!JFtV0QY3Qa@80y0|dNY zw9E`TbX)@T36uJ7EtXm;Q@j*hn@DvX*2m%J} zs%5>aJTIfaJqYMrB8sxB!w1O!c4!BRv?L(=&FT2~SGN&Z3ZKh=3hfapg)%5zj0~)p zKY_~3sB*r|J8;T9|L&dxs8l25&HG&=Y0^68R%_xGw(mZS79X9c>h-|j2{9AP!J+BD zpmrKToZ$2Uj#NTfc5NAUj*^ZIQDtR_n^Pt-@!N@EFd0{DG_}Z7Hcdk@!Ff?7s5x=) zQmQ@4vlbt`(5O{ay_JQpO|XEK%NG5w;@ktQ)Jvy)`+Y=NLl}mrO#S-%nh|1K z8(^mH>~3Z$mLR`mrUn9EDy{#KOgbw4{axr3IhYBZV*N)GF)0EwxC5d+dSd!@C_L(p zyHGBnhefj_u%IMv=zTO{laP%k#}rmU+0PBS0#eZn{A~Ckff?@+roiW$rK}^Et;08{ z%OrdoS4EZBA!j+#s#PHUiry+~iLKaAeE7jdW=5f_7Kn{^#b>J*b;aA{Nm(w540|(v z4_}lBUbmx39UoZlzsg-lH4)^5HX3$CpChAWijqpu7YV1bb#De|1Z-aC!@YBf`VS?Y z40vrpE@M6R^JUGiZjQk#%_@I?EI;}Ax28(6Y*NNJ2rUZ$IM;XIRi4iw19KI(BZ%+` z%Dd)FsSqB^l&urI0kgeUFaJK(;7dMp{@gQ@DF}S-%gVxpgnL?^<3D}S5?@)IwSG2} z0)`{WqDEleqT`ftrzS zszu#@!Gw=|jU5PHfn#<__Cm!vCctph69p6%QL5)-z- zV6WB+O--;`MS&%bNGZs5-|Mj=qnGL|2RrHi(t)x=3pXg8eb~Gei0UTaWuG9eG=rda zp`C)*D%C-DI3x&LJX~bcXh^-HY_8hVxe$L zNnL#kzpe{$yP9!Uv9Cu$rpd~F1rDT-3kh<;r!XeZh>pj~)Jl$aqkk@+&#hdtx$wq# zc1t#GuTKv2f|4<2b7p^OLSDbK^{k6oOYKE+Yn}4%&|xzHvHdiuUABL7&|0wyU@wUP zq-arGg}o@ut6#~l#RhCw?Z%kvN3%B_)ZyxQ>WyRizUhIM64f?E9oX1!6-SQdytCnv zd3#^WjbMo_rUW)6K}l=mw7?s?YND%#)1h{2(bBMSl>M@gkoJ92YS?thx*-*Tpa7H<3{7@O;hpIy# z-zA7L#O)3pl|R&n|10u5|4xdn!n{DY`1C0w!)P@n-OD1jWrn%_dU+D9j!>{9;dW8n z2NjxZiFDdeaoq_5{Kj_;#S$Lu$zd+i4~vRU_}(j4Ep2|@E+Fyr_r}hic=O)0g&T(> zU)}qc0f;yT9o1tfvt>Z&8& zS7np%RySZap_06t6Mt?bWcX72F$HgyFChdb)?v_3P>hzIpkzLO{ z+bZM2BTf2;G|dfjD`|a_Ia+{iZ2*(K-56aF){9{g9GEAlZNc}PAWR%=1Sk0erRdV|8&tRI0!tP7C6!V#R#WBJD+LTL0ZZX=tV!Mfk= z^T|0-KG*;5B8f2DMjpBCbpV;WAbH@EvY>3;8g9sOq*Y62`|mWZT+^gEYnn~kPomG+ z$UpA^I-A`-?h)3`y_(6n;|hNS%-J86QsyP3NMtu`+`j^qy|(97K-E&7u}QX3hQb$^W(?@=fq2i;A4j)M*X=QO^ifh?A2MsF>J<9klit#is4; zB)k7N7CzQyxOro+gqw)JJZIFl>rJQI?}jY%ZO_8+h()1hb;}=X*99cxbr)bJMt#8{ zXg$8PpyERy(4#{6FG$-*3cWOY6n=c2M*fcZ+3|~lq;oT#&1NUxHm82J(orqQAqLrt z+fOJ`x_;dAhOiW7n9uaoEC=?87I~!HEyJ?~Oqf+LD8s$_xk5A3_*y&YjHl}PmB5o! zlE*e$m4@H5$j4Pw^|}5Z|#QOnv ze`sFacF>a0dzc!bH*39+WANnWt!?L)IEd23g7QA9D4a^7hlP1n$^GR6pGn4FY4oH} zk8fGhdLtsH?_f#HsDcksJvy?*+c3&OX3Imf)sUnLIF`bn-E5* z7-UK164Q38f%t>eb52!bx+$NwSdh#qNMJ(2xUG#0GRqc%o6kOYiTFDAk1Ba^jom_;|7Fv*H{tsbG7B8KT>R-nm0X z#1@uxd4$v*B968=biL7p10WhG5Mi&<21v}c$#OjV^7yE-WR%{UDi7uVtyiqRp)@G? zavsr#zJ1LD2moAI8Wl8@mrY2E`&@56j@v)>F5$Jk|A)rg57>V8@Ar3vXOHJWOVsav z)3fH=?z3_DX=U?kyBF@Buz5m|?0)3e2JPds_v+`hNI&b7@8&`&v)9<; zQgkt8w4YZ@oTDliu*7V_QuBx@$>((ghr|KL|D)*4!=dWmKR)|rn8h}hVTQq28~e`8 z*!Q(8QDe)JYC@$_W@C^Tq9|!dqSQmFN2TvGmSoRbDhjEzQ!16rZ+`!tf6lqCbFS-i z?)!ewgN8Xc!U7)kDAgs{rp6P=kw7Y0^gk4Mk=X2?Rf8o{n5%9+46x-| zx@mmD$s;{#=?t|!U08G%&PLWZ0w_@XvF&2yJY7z+OGVl~Uvl^29?`{Z^e=gv&1!Y%)?s68#)ZtXt{F1C?B`hTezGdk2DkK}lFaven=`l>^Zq%v{`E{Fra1R z*)mIx#891g*ScM&Mh~HEuz)B|;K!vBh2{3stTg(?ekCOiUA=22tyVm`d=CN1lMl5(cJtBsboat?&y@X!C!qYPM#C#!=>E>!;5(ou6p0sUn z*d`yUvu^S#RL@mwk+i5A<)#yLTA5-~4%VTTkP>4a40kP8eEcAN-=6F1|k3;Tbu|JUT?r}#m;#8wkrKp5|K<@%R zJo;y|dw*M4uhAhBr&YCy1+}|z@y05eN?piJvSMhRN?M%yqPwQ-+)T$hzV8jR9MwKnlnp!}B_b!r4EjdMxRmMnbojo{-|gtY2FKlX^?u@OKjf z(!WRi8huMSIHkvpZY*+vswSgH>)tAKGRSethETy?2S`TSs-)+wIp@K%?0(% z1;9d}=5-)3@emM82hd)po1Q?_bZNB@sIU^~0U|`!8Q2lJ97|WWZ6Bny4BeNY@k9p} z%F+B3uzLx4gnJP*>cJ3Q%Ag2QmP{9$Ess8z6+VWs1-D$7NyGgx`xlK1lE#!{d~(%L zBxbPaYeL{;~k@wuuCFYCtvt&J)&cA~TX) zKN@EAzpqNfDew#IS$&gE!*7_WKsh0K8l<&iedn-IFY7s{f4e z{fVXC9r8XW!NrR?e+p0~+`YFjR4USR>edl|t|E3p8{35^#4Gy8Db}Xqf6r@QKTDpS z*B)b?F<;ld{fzerApez9HSKksNAW%$;J6iSh{MsG`p#UBpRszQ@ik7pU!o{Wk7(}b zQjx@|XE9V8x)g2V)K~tW?XOb8Km!0k7z?=E7~=NIcqfZ*Hif-wr+yx+F73jaRn#`~ z5zizl*fs4)K&p}+BVakNZya{!Urj}N`jQW6Ie)icsLd~F#J9llHtdIhKMW8KdFBBWz2d*-@rF`rE>4An^=zjXhx-ug0iJqy(odxi0tMyc4B&*?7Fa-Rq# zVHZBgs6Z~svve!E zdsm}j+55nogofY$`uH%n+gd=Dm$e1+(DZF%$F7u~m8iXc+!NsL4QX+If6pcxh;O;k zwMxRQ0Iq1n;QblOE(_Y48;Ukv__QeeCK+eL#S(Z}(yY7*0lT*@!D*Hiwq9za>g^Ka zsC~wyJx(Qupc&1esMspW($j!C<(V$wfv_76uY;ud^Fc}&0l~^TbepxN-k{Mm|3}mX z*(QjNQc~Z~!x7KtIc|BNA!=N|;-x_Sluh11uc75n? zpxvFsfA_oQtUuFQa`bkfU$m#zvjsx%!h83)nR_}F6>(T~uF5l@0*da~Pzd<_3);AP ze%2H=U}|-m11oxyy<0b|`s4E_@p}pqZcDQgE_|(hk(d{b{sxOG*gM7_nly3Vmg4mO z{NRau3k03j@3GsgFs@ApJMfDnb-_NDyGUacMa8}RU+b}x*rn_OJ+nkz$EWRTOoU52 zw2AckBmJC}OC0@$Cr>!O&rK0P#s}Auv2m|~HF)Z}c089Cu%OyV)=a6E9B}D!2DW;| zsrPm%ro<_3a`-O+*ujU_*XuP*ny>vm;CH3&DDULEJ*L-w2B}rZj-)zbFW3Bk3u*_u z-nU*i+gu>lGL)9Z%KhR#Q{C^t#hr5MZkvDklZ_{JP0b6&vR6yWskcw4QPdX}gV z95d2a>+`%tWb+KcjQOQOFj`?FK{~b{00LRcclY*lB>#a4w5)X@5Cp5G%QNjHr}T!- zK%$xJXXYg#FntqjFlwQ>ODwI8U;jDz!B6Ze-MVf7yymL_dvtefC8!5htn;ETX*aFc z^|slz4(#eby*v37o2m{R$gB{n9a>K6;`X{qn3Nlw1P_FQQ9yZ0mk@Wxdfez*ocoWU z$afW;ds8Q5ks6YF4K=FhZ|SM#+XWG>G8rn;i&zv z6Mv`a93o0@~3 z(+|uR;xAA!<2gjN#;9iFYpMB_jaP-{7yT_qsM2EIsFk$+>m&VK#Mg&}loZef-`58k zP97n6{CQ+HopMf!&w1bWZ!_bbcj%z`y!e$uigdom%z34AXw)`wW4#mr#m;xTQvZf6 zxc&&XOf5LIBSqwR>e;IjOO4G04;1#61k}3)WmWE@_+==;%d$%#0Yb2?y8SYICQq%H zm0_W8^oU!iX8LHqP|aefQ=sNITWmSXFfn&~WGNm4qMYLZpmDTP>{}zH`PRTsnB%RIh^BtV+F4UJ+IXLJM zR`F7~Emr_PpSwO^;y-d4AvB=&OPlIXn0LO)A z%Ere~8GBi!V~a`yy$avfFSi2=RyW~RnM8Ke+!pC4>}h9>o-Vr6LL2Z7@3gIeY8B{i zgsEl^XGy?UnP0(}V!LvqBe^?a@T!v8kJ^6X%Z&GhmPg)9l6Hv=_w;*jR}mV>ZpH&Zfg7CWR$PP@h~0FO-~-CgD!12Fh1#eP-N$|#)6)( zEFgELV5i+b6cgUX1ngskC00GO>YRZZ`6h|%uP~KAiXPf#ucf;-acQLNs2 zQ5)6MQ4(jOa4g~U;)h(hlDO zUE;zr&wj$EEZCEHbmo=z=NVzzCtNWvwqHe5BtEZy3M4_iZ`B!3?+abw5-y1$hU_l( zr={T|jVX6zc)pD7VyIE@63k?#YkydS|B%^Doi(XHZj`b(=jH5caWd6^&gnPi{>LBM z+1-=!fxk<&OiG|Jdo0Vpx~)`eq$6aQ6Da*56(hw6ACu z!L9qrxQ9g+S#B}JT1B?M+4jFxOpAJN%pyr(m;D{)K*3-*txF_Q*$MXa!&Ah=#-5$# zT6c>sUlV`ycA(W4`!^?OJ`T>e$T32Bch)I=Q94@u9)`bwoCC-w}V0CrmS!%f_tzTROIuZtTnHzmF)PD~u0 z-Vjzg*Milv4)UIcBcX0`7G(nzQ-4c@a{CxWe{CPapT)%Gb25#WJCW5V3wE8Uj`DIm zHLmGXcJPzcu$yu8P3=3M#HmsVsHnod+B}liAG?`+WZPfuftc=oW6(i(eFI+l{-qfF zz3R?)9|-@bb*$(dZxF$dGnnIE*G`hFdVKW1-yR43jg!v~a0mNa*{@uK&9ATl4@I!% zSJ&gYEjy5Ys@;P&t0m@#pY8hIJGq{3>cl{>?mH?s6hYKcaiZ)@y&l$>cn6=T@2%O-)xp{5*_xMszZ3QK zdPCk0)$@F}TQs4*z;&ah4wRhenWuh3g66`GTILNbuVao`j3l*Z-v1E8w^OC{B(*=P z-s!)1z<(qWC$E%h#gT$_a5y{c0omeuQ5aHpV)x%(6;at1eX+r{gSHC)+U`FDw&Q>u z0q}T{9diX!%fS4grAx&-iCj}l9(oYV9|XuXksL1K<({vE6ZU&`Eb>np=vEv$e37=7 zScdt@GLcxARQ!Y|vyd-!H4cc5G?KJmc4=LL`0lGTw6k&Uye$7F#I)nGp~Vjj{3qr) z$oEU|(RXapM+ponAt}%RfY$Bi8QY0%pz$So&26FZb*3sK;JEkxfQPykGRfFN0HIFT z>V~XN1_AXfO?8t`^;;M;1gqo~8VZnx4TUvL7KThvakll zStt7O848qCp|%3>_yv%m;4q>JFdgV`4^UgF00TgfU@OKq3_Y{bj{}m@q~Mt5%ANLL z+x8$!aG|uJsJW_0?m(gb6t0+!thWnMyaZLWh96`X?g`_=}ckRqQlWw?uyO@Cu_k>}9F50~-YYFA2z7mT0h<0>!X8`R=| zOE5&7X!)^YI|sI1gsi3;q3sa`^dkvP_@-6jIi0c(3tB`&vvc+8_t=SFab_AmT9XN; zaJ=So797~>XI~ERqZb^kN-^4h%G^*_8|OdOi^G$F4rIn&{pftE;#*{ey}goU84v=f z3`T-N?kJL2*iXOAf4mED_M!OmDx{WIKNFOdw9E-miq*Q^h&u z^+y#6;fYwER!}$#F?n_iQ)2g9d`K-J?5ud_N*y{CCEuwk*TKR4kA{{6m%cqHYq+6g z@YV)8>K_hn^;tt_=wXk@OKLY@Cu?VWL)8GUtdW7?H<9^#bHStz#62jzAyd z&J4)?`y7YA*J`7ueGw#g4%BJ&8220tgVBybg4NEyjzndj&+|;}+}dPrd|#b^vqW97 z?t4IpMl-#fLYllEGGG<1aYeJo%Bqf`vQ#UhIfKU(oyfJ$t5<^Kun|K|-DmNsH*Nk^ z2N|^5kb_%P*dW%zf19%UXIF;)o>N;6XXG5wFV zy+w*bH`idKG+ zO>TSk7XQLa_TQ9B$|@m_9IBlN5-^Y0{{(kiggN(WXKu2mxpGNLB6g_*{f2adfHS_= z-Lc5VT=h_Z)kVV?3SBG1PuTKO?47By9$1`QU;W538$;EM|9J@!I%s^Z6?tg|epSPz zF}R`hm5~&{YUOlPJ#GE(ocbR1d)dnOtg7y16rs*5KA6d2|2o~@uz_ZUO*_~|5$@{{eTY=5#do}hU3%oTuL#)KDd;`Do6y{rkVi40A! zq9}&qz}5ZqG(#=1zhLnoAf^xPU1RV=_A_WuwuI!=5DwET00PWuMtv0!a35R=gJN_?^2(pmpr|)Gjq=H zad2XHtNa@gS^`9mkD2qR;bNu!l- zk;|mBlXSU+@0er&qA^(Rkwk8A1x^3mo9f$k-J+vo?3smi+i%T#i#J+-cgiKXn=(9& zZ;heTSu$Wb;J>8{-iK?&@BIeO&vw1_%R~S&XDH(%6m7oJG*Q{R41lEF{9+04F0262 z03a6Sg}m#bnt;hC6E8mLA~=-%f`{c-ny2A192SyB32AR6&W`bPTY$?y&hwVJ3FF{M_Lj-g9C>`_u zt4B|cXTQYf{5M`oc$<_p3@bw~aOC?)a`i_sQo6$E$1P(UnCr^uvN@c=57a0-9>!I8 zlaG6AVe*phRLGKBB$-@nMb=?aDH3R2FzWMcTW4^`m{`l>u~Do`2jlFcPQdemqxTp^ z_x?524mU|?pKE&-{7kY3yXY+5Yz82TD1xM$lXA=E_h$cF@*5`R5E#o{aIr<`i@9r* z&1Q-#=*pAi34bQgiYs>gtLqx8w}lHNv-LKQ=@G?x2|A4JJ}s6c_e(8ORrLSXP0u2?Na%QXVPzf?%N^r%End>`shiozF^D- zO@8i&jPy4{Y>S4qVY=w3QS5f+CT28v>GU7WnUe~YdrXdt5w7XjBAWcD$h%gA;7MRj z4>f$J+n&?3#)8>>*YW)4uP@(6x68?owW2p`4HG+BiAe3QxJQy}fcpa651kjnz<1p1 zt454jr-@Y~p!oG)7qEkP`Q);JD`RtD`RUZ$my3YxmWl7FiiqHsHPKec!4GN)l}hSX zM;3tpsxK%-52CdeV)oRF$C zEu!SNSJAw6@;~V~e|H#)Au6Kq)*#pC{!5)``0JR2I^7&A72`DFBQ*gj*0*rsFh8~a z+|F#A=HR!NK|2QXyMI4yHyo=b{oD2{nJf@uWg=4f)i01I3bBqgt!$?8<2DtibVFIh6zEGlIb$}2oQ`uVIK2i z4A_+nL1U=|XL=V0)GP;pwm!4&nwExw*Pq-y2!x_yE^l7GXU)r1BXAHv5E#KDY$hEf zd)PCS$e^Gv*zEkrugAgQ?<<<%oi`1<*MW42e7|(5;WFWLRLxP+|!@B)WMv^fd zbkN4HFDh|t+=_N0%t=p@3)ElN21!F+D=|1XUMr(rvvw)DA=H=Y+$F4_&_t8We?{)6 zK|F=e)(uXmIk%0gRcxxID)RpK%c~q5@XNp_vt)KaGxN3&yy0Ax6YX?;NwjZuRZg1G zLS4mp-%!&3YF=wwAaqWQfNw=BHa%Q&QfzWIS2-s}XtoxQGC#B^S`L$zC7-VJ9|;ST zTi#(DaBEiSW5BicH^yJ2Hd2RlHXdcLK*O6@b3;{Y_GsqEd3>6J_mh)CT5J;i1XOI&&eA=2H2G0Ik|yqOmYWL;B7T2-_^0y$qq9`- zL8>J9f5jGWIJ}~$Ljm0{tY_>R{}caK-bGDp0j5bzFMtYFS>YiNikwf}FZED#Go(Ub zgiATj?JawCK{wrwsbCSjix+RtDoBiOI!pF6KY4Nkthp@iceN=iNVOAT@&G8Ky^SZc zG*dML)9`R)cgdmji^&jfU(nh3?%a>vhUWHzW|7qYHQ2pYJ4W)GTccdIIncOoFEebb z&KMVw^@)zfYDmF-`Q|sx#lajM1n$nk#?A+Z?3rJ3QIZ>#3h83wefDU^($~^%>(AOc zBnEV|jw=gsk@74V2>icxqJDY_#SZ9AnpXZt!h!54V-SC?pY~ENaq3H}X+1vO;f7)$ zPKPb&C?7V~{V3kn?AN^_<~Yn+*Bz*CcT@bhg1I8(T!EPSv7&Lf>E#Owdb8JJ!1iQgH!!2cx98wYaKMQ?DRWWfT7h=^SuD?%I$_3ekM zi!+MXF}i2l1G6paai;vx3az3*0zgyYo8ZBXa6QPjJI`Sg@weo~NHdMrq9=5aP${Jj zX)FOMv;c%!^PJ`YNtbK8M4&YTfFJSSdTF^zhLQc?G!k8Ni4f7;SGel*=GT;~!!7Uc zbOp-!MWwcb0Px_gv~bbAt5-k&#zf$H5mIlS65t%N^s6U3f-+FJgmhOGA@A#*y z1jfWK%;{@kS{JhoOd)OgxlLTmz1Fu#>G;WWe|3q^v*C|6#~Pwxh37WkURKmzwK!94`1Ipa zk7ewq0H$&pao{UUu2rw;Km|K$kU8a0KOqzAR4caw;KuC?IelGk)NQ12&ym-eMOUX9 z%wB{1fJvng^Q(Dqp29c_rCF7oA2C0@%oN={{HRZ zD>?=5BhNrz%Qg->bP*c;oG8)y3DQ}QvE-56;?=Tkn&0L6jJ3MSMa9y^8fhdX-3N#M zexHfn+TV>k&f;rtkmTH0%zX?dDh;4Y`~dNFNZs$^k&{vD&KofEpE;pqB!HXMCu`he#g5~c05H99{fFBR|Gec za^&Dg#spd^j2BWCQ*r6o^i}<7n44%E!%O4)-I;EA)2DQj!8BA<(hReyJ#(1}H4+2t z=d+>8uQ%gXL+S?MzWV9-^!e4U#zDk|n;Dfzor{yH_J^ebttQj~Nq)er?#-IN4=Xyo zu-~@v=?Yt;LlW!c_7^0O(KddC!{6+;Z-dVDLPw+Ww z{fyCfTqYMDO#h+82JBLr9)GUb3qJGJ?`+B6p3m)hTVC&-z%Fh@EBm%n{EFP63i6aq8Pgl+}ohs@)WMSSBb z%ZoQf`F4=xa?{WbZG9}?7;9yYB~o<}ndKLX*5QP4o{6`)Xud2L0kLKAU8*iyqzH1G z@@^fii!E^E6*&6;5q4zORaMhfWwyr-PZ`hOJ7!hfoWGZV+FhV7&dv|*7v#?K zJpnzr^9-@5)3ZZhnGV^qA@p16_CyH$Y4?J?1^!h%!K}_cIe~3>Cldkj@rL*;^F6&g zJps4v#C*4w+V`v0Z{AtQ9PO2rrhKDio==Ut;WEfDn`hMDYmg1nW%GjkLAqFw0Tx7A z0cz7g+E^Ybn4--FQV=S{Dvzj^e}0l||4abkH_xiA3-{Usi8H#|1dMmx7N0v0(32j2 z%9Roin-4}k*qW&7an~+2QB{;;Vo1_6I$$R zSE^TZRetJvY1ATKu&TOy<`h1_^?C+NeZ^s5(I!brB~BYQmXD zlldxw6;9xU4Q+y)>N1_xDL}jjJBr34+fciZ2u+i56)Hk{z?A00XTARQXb9u8apY-oGm^;n67VdV zcorOxe~Yz$3y9iu%io`8Kz%T6#iOu!PfQ+!x`Rgk2n_&WUT`ff+kG}%%T213ljmngaN? z0RH>J!SGg+wiA{-4E^`&*bcU%2;2P@RCo-3?mdcXz=iFjSqJc8is#j}-0y)yLj33X z-mQFw9abycO|-(i{YMzy1U;%=PDVntH^#Ln1&B+&I?(c~CQiHp7;ns1|GV!s%^~(W z{Gc5?H(qFTz>misy4&D)gj8^3W#R~8GPenyvo3sPHEH1-Chmvlu)=JKqp~Y@d0`I6 z!fYQ1kETq%nhkS#9cJM?O1mUHD2B7`g!|TE|5;7#qfKRc`+G^9^Y?M4ybeq~aLRw+ zFGwI2J_7Qr`b6_DFV zD)Hnst3Ze)?rpsk4fk0ruDo;OV8mkzudl z$Eee})UD61#Xf&6%%M)t&c)7sij|eq3%#)md0`9p;m13KIkXoJu{+*OPu2#6(TV4_ z=pl|sgwxJC7R0a*d*&*MVdm#|h*(i)qz?!yTc1@=hXrh%w1+;sU>be613t`)HrJK? z3&N@(sBKtS;)-f+AvBWBzdi!Ga`939DR9yHbP>mN7tg|VNZ*4d_T@OZwR+Fui7t-S z>sNUP_w!8Gg;X)vHaN(v39KPM0Y*lkt-Hy+gvgP+y&SV*?)3wD*8998ICmX6UzxPT z5MGKPu0P)sz=b0KItm=PT|BBEfbHjynxMNR0@Gl=SqIo5l5b6bIL?D5IL~}nBu+>`TjZSOE2tMz!xaM#UOxqw9H z(|WG8mzbNcTpz<-w%e8p72Uiox1PTHDp$Jz6fg|Z?N8C}UuHk<{7-&%2?UX2)*U*YES`Zb$Il*s73m1f zsssajz2SsLAA(F$jJ>7hd5^Xs^zIOBU0+l<4wxx){e=LC`vE5U2rv!66Jf{&;6tQ9 zQ)xCRBn$L?`9Ut7JNYVwSLKYd`%+-Dk0(jt5mI@}tdZv+Bgq$q+kAJr*_CC!Mlrwl z1lV?Ln}`Lzb+dEUX-w~fdyo~+puev-tkWGKaG|-=SYTifgh9LKj>zkWtOjED4-^l( zi-!Znc}j<--1e;Q0|-@Zk?baTRulZNc>gPkyY1kT|E|Pa zfjgHj4H&NGC#}GCOQ799w6dY5J4l2FqhJP`ANM1A4gKae5j3EW&}afG_P6fV0P=!? z7y@_qz1z8nj`hY7`RiPT&u@0?C1>V;`fn%@85>vJwP*SFr5-Yo`)I%eIMg7>~F zD&)X=F8JqdWAk#e0f3G*zkk+1*_^{3j_+Xrr5PZZU}8guXR+aq9kP~UOR1TvXnsw% z?s9y>t8SzsxP6Tj)`uLR*rJgTM%itBIak36dnyIJY;}SmI_e0 zdKLxX!x_W5**^o>f|pkJl!QM6I|MErkkR7**8h0c9hb2-{ofW(Wv0LbYyvIwoTO;O2N%NocVP5US+}0AUjx&_i(Bh_s}U; zR1ogv3_rY2FD@)C2+KDS7iE9J&7G<4Ee@}4lL_mEN2TIb^C4y_2Fq=S?Ox7KAtkn74*Pt)1w5>Ndz#e@z%Vz)L3`4Wgzmmr4ro+NuMM}CpCeK?tUumCnJqFQ3 zecz&#;#2s$>U4!eNXp~t$eWu_jKY_BC)HP_1E&xg*Mcsj+``XXf6=zH&r|)Phac%~ z?mXnS`j4RBUvrn2tpE90y6y6O4&>PpvyyK=cn?dEXb(B(-=t4v(`JM>?q@9 zl$tbmV?UCA!MbyWH|*NEk$}?UZDc}a6RG_VaQ-n}y{>Mw{SXD`4L)Dqrb^6%N{fR< zC~JaMInF>VR`Kwj!bV^fwo=Aa_)B$tu4UQne%|#V(9Zo7EC#?|5#2^dA?0H9DMz%HN?f#yc7%Q9>PDN$Ddvb(c^U^Px%je;77 z%{Z?l^UV569v?v3}A{k0&bIr@mt>XG6HE%10_!gBn@l)l!2Gc@tiwdf%_5 zsoX@ow{Bh5SD&VnO-GO|$HshjlUm#K2eg};AA9K^vzmBV+4Xh2fS5zt4JY>NXgqXn zf3}Lbv0KUAi!9Oqs=oV)FE3i>q~NxG`tertw2tzEA@x0V&k*nhsSdV&7!SNurMnzG;oSDI}(+Vx^dh~{+ETun@Z#Em-|bz!bU2*6S~JMUFP($+DaP<^lE7^P^bWK3YLIjVr;)F zDJNAQl}K25bWRCDb2B)$@r}NK+BgN(ped~cRl}B(;Lsft)m>-?hh~5{G?`JNNE#~y zA?h#EWUrBAv9OUP2vPfCI#)r8u1To6kQu3H@MbX1(Oi$CZ=0 zD}{UBvJi;>F-|zWS=FcmbVY8~^|)2mWx1v5I@hxAsC5RKFU-3e zMaXE@qRoxp3NBML-Mech(TC>w#3kjLgtZpATPBTdMNz~4y$0CbO?n#ZoGR;?1-$eL z3hJ`9zu1D(xNUFK^tI@a(wwm#%&i++N%NKe%@TvR9^7ppg&ZvA%74`O-2iu3i7Ux) zqPcFGi+VnP!~8bs(fGmWWA0zL5E%@@jEYPnvd=hndfWVz^*YEvNAL znc*3`hxXSE$JKp+bQ1WEGKDQg^u9sW127bzzm&kCi`ZyK4gjJU`2mtM&$sI{j}GnU zEUaYl&=3HsP$F?PqjLjI9-X@UN2r$4HJxh(h3qPTFQDS#Mg^nDq--fOESQEqGK>fn z-G#VTjYF-2{k*=XeDwZJKOuC7=q#)Gx;^_W5bd7)d!F8{`2L2V6Vagf!Tyo!%w+754c$6q&m#tO zag4hOSA&{vfxCJa=58=B`t3YTi}&s)qZckpo4N`Qs^m$vx)3g{Q6n|jybfA#GKl-m zHJDrE&{QV_;}(igU)#^w45V!VzW0vS37?&6z*T#D1TRuWpc;B9G?EUSm_iPe^}In4{?UX z^J0HvbZn{=u_}2gyT7)}srsbaPa6u4`Fa>1|Kwf29&emQv27_I=tXs`&Ig)^%*)6R zg0})t>q3x)!zgq#`!ap`nkI1*yhq9b*`?#8{hW({5UO}`&bz79H<1JU(&(-8b!Iv@ zr-yiO{)5PzTWXC~fohEb9csvV)Kdt^wG^R&S7@G_J#Zwv$xEjq-T}*5&^S2rt=RMC znbXFvw|S=w)|N@WmM_mMRO1&Fqqz!308JAjEjA3L+)i7o)^Xr;*Zy?s&vpNRG?Xu% z8%wCooms3EdFC4r8jIv!JXG(u%R3x#tZ1`XpxPl8@K8E1i4?1Xy<_Hz-(>4j-7le6?PkX|j9Xn&2n-bcFM zz)?g1(P{w2p476FWL0;>`I)Py6$NGHF;zab5R*`JhmUV;WkVyQxQd}Fp#lYGt~M*40Re81w+)#? z)v}m)&IK%l=_0_7F&G9p;CiG$F}y1?2P%=*Bv7D_Hrxyd!?ojRwH!Ac1TZ$IdXB?Y zYz1np2QgY6dRGylC;^i=mD3r^fc772icq+&&TD;UNR<)3nJ8qOo+eL|@WNHp?O|3Y zl>>I+HYC+P?qk7m@@vB+kJ{JM3<{d3FJsm@@u}$$Up!8&ymulza`{?ZK=zFHTDO@y zvu(LI=(j*+WMOU!c|JxGqLoCdr#l_=2+IaUE&Gvz8BQoJjwP;pa50IU-{hpCOzFBr z>0VFbDv2&B0X$h0#sNKsYDxq(k)bSk9d#P0oUP}c#V{UoGp+*SO}L5(?o0?bmhQ`L z2f4?nH!_M83(h~XiglrY+|cEj&Co~g5PUn+CCAHM>=Cid!C~hQF1e|qU)5fUjG((8 z!1-UeBYTo~tVu7E6|XZ)XAW~vnmSP(bieU#-M?;lgaDoOK#>BJx_dKx2izKGPUX8#U8JTejYbwSJ7if7txjb={I=#vqYYx+qH&w%|1~hl1tX);dQhYqA36D z2_HmrM|!=B{p+~D{CyMx7_=&XqaM<5g}(}fQ{t}vVk$7I%#Y?XohYvoyMxi9HBr!K z*(e__LjXlKJrnr=A+pbg1I#IWRZvD8t32~D#9&(>Y%NNyVQaKn{JWY_L@@wQ?Y3

^Q?oCDTamMEGqRA;%C%3-QX$<*6?4d<{!Y+(lkq}{U9rk&T+IRx1Y z-&0(4e++aL!fde%at?o|x5gz-4aRB0h`n9**dSRNw(Hz!E(_wR)?T8N#W=dw6^CMc z3g^!FFqE@^8dH3iEar#Ed`0xz254hu8`mYpxBj<`KhV|iK)2on=puG&uK==AxWue9 z^H`or3`jEuq+y4)7=}ZBfexN_v#8+P)A{!QN71>*Gxh&*d^fYrurc@BjNC7CDR(xv z+>MZ=zH<$!Bnpwu{ci5$Hj+rnwdkT5xursgl7^EjXLd4FE7 z=Tm+X`S`u^oOxdbXn>W_?8r15qDmsAuk+2vC{$So`^VPW@QVQr!-=0S5ZikuQF%4O z$`&ebz{DEW_gBnRO12YgoF=#lUGz#%``Z^ob?y$Oqk$U&2u#6423Txf~u)CnVb^=WaC zdR9WXenR+R8l#tpC4jGbS-jWi)892WxOX?MONCI>*SL3_>7b~=Vk&Pmt2Qvr4oCMx z#3S-GvRR--u>=C<>va&3pOr<}eP z<30kFgpCrQw44Red#Gop8Cv;kr85nLoO!momqufe_^89y_SADTX{z1 zqmU(YCe^|xi_m4xa`nIN<-ef(9fhz&e?A0SBoQP_(dT@G!--M=5SHO@gX!0xi5~C& zjLrIlQ?M!LB8Yub(>h;+6ClCm%vmLTA4h7VsRu*=006cd8nzgKl|SM= zo&cm%)G1W;RYn=+wTC<8_tk_W6XwGGtv#T!Ack!(2q3C&Avu=bzl>gR%OL1ezKtiy zK`!(e5KX*6J;MD`V+k3;knNS_7q^83NWB(VkCR-fs0)1CJwqAuuZq0qXlQcfGG9L4 zvf70DypYOwq5hjtl4rwL#^+|TSipzE{dRom9B~GQVr@RdnxwHXBJrvYK{IRlt!s!WjnLE1 z{vdh(1Xb-s@s1g!JWxzO;V?D7NS-+yK#$>GgZ(Ko`UyJwt&Sp;+rS6k48RilU<73t z+>&>!FMY`dXsFLb2Khol#I}3@JmMg^nrub@SioDddJH6i+;FbPWa2!ql8SOBOJAoT z>iR@2_o-F+IfGaQc^BdfWvi=VSJuW((M%M8Tc=;Hkv!XoW1>3Mms1pn0GkJiazP_z z{nV=w)SwoiIz91Y)cTQ03&nmSZmdrrEsqd325nw9dwOB+`(X55g83A;?dmt)Cke=} zY^D9ag&6Zi^@}%zh-empV=L7EIlNcYSXYGgy1lZM0AQ-Pl@sbfDnUs(WQ~-|t)$BD zQ(4lsEf<|nl{$4TwCvXvTLR8QW30j7u*S8joKh0}MYAa$?c-r9Kq0D=ADq^&9N zE*0%9DQ~rE1KahJN!)(Dr7XhInE(leIu-xx8y`ld%O`}-$Q-sddNG#V5T*&;LXZT>s@^^^9WH83sENk1~9k#s$1-}Q))~F#tAiMF8Nfi zH^!DKvMVT=C@=S4Z%?|XkR@OT_)_-Mvw`6XEW->-kge7zR8 zJ7SDIplE-VuO_iUYOh(dntwM?hCb+)y=%5n{;@%D0B(uZxA2$A;yL~0?qjCWqxW%L zH#yNC*P9U#WS@EHH3Otm0uJ__@0|Scyix6gKCud-)YIOucR+Cf6U*MSmmJmJvzC;%_3m^0v6`j1IyStDe8F(&hsC~t9nxLL zey(B95i4G2#E4a^%{fQ(PD=T%&>3mYQk4aj#$sPmRPR54H!H~KfF`WZh zpI@4j;>XBUYO(nc1si$J!PG6iN%%`e`zKMM>2>y|UOL75O)C9qXj$3F*or)*Pt`Yd z&e2y1+cF4D(DS%*bU*hiLBoS_OJc`GY^{#}0gw4ke_BW6IsM)eSh`?EC?^6IjXMlP z*T!rf{GGlpsQxNv)!-}UOSO*j%Y*2@Cv$epeC}VY6i9jbzHMeYR<`d^pt=1pDYJo+);_ox6SL{m^VD6K~oShQt%RpjxG{JZ z_0YA6?c>w=OadZ|k+ni+%w34hoh1E*q{DEgZ8A1t=5wjv%&Ib6nW5x2sIAuKm#uAR z@THva7_gKTf7LgU@0dVzer}d--)c2}qbr}^^bhPyg0R^f z;R(0Ba{|Nz*`=rPJ2T&9j8!BaXgi|DOB!qd=_0HV*57mFoyAwVJ=+3&r446e)}V`f zs|7zQ|HbsG=3Dd0+x0?E0~e@SWX2d1;dp~?UQ6;YSmwrcaO5P0U#qfYxPlIj_W>U3 z*9&4S>i|KhLyJWMQa+Z@>Q_PVNkS(E0*mRY$=(7hZg=Inv(I=TLqH|8@A>+m}W^{6`fB>rxy1px^w*ETO#eeDDA5 zD#!#q&AU)BcTqM&$m5`U`F(t7JG6^M+x!-_6u*6>Yaz@a`QueCw10m5T+y|&wSZQU zeH%u*h;i-ENjcVLzoLpr$}DHlj3a+P)F)-Hpt|9r@OMsq36U>LfFgO`Ql&HxjuNGS zq=zV=z?8Nk^pOC`TCrD1cCN<0oS^jyF-UN+Yy}rXn==GBs|`qq2V6*!wJd=tR?GI`rtRE4c}CY(>^my$%4(uCdABh4 zF$ro@pdd z22>u2zQrMpp(3NqG4zZS^ILspe<&g~>9i09OCpLur{6QlR#J?UMGlr0KcTbgLS%Ww zHS=<{@7>lQcCMX1p!OTfF4QLyGco7ek~AWQH7vU``7|0jZ${u`E|VM_VT%yeQ}~l^ zT@X`{i;!8XEYe2_j0wGU^E9VrvAHysSDP6*yZ{G_%`(q#?XG9IRBg$u{7KdHuaR(X zNQkK~4NyOC3%A222;3aA&`j4()7rNv-F(k9OKYsONi)a1gk1PrSjMww+b$v0mr0qZ z@k%*@^yLvGQ4U%&j%KCuaYoe7?$Xk|=4Ac3*bMkVbOKKV1I*VjYo_7}KpxKD#;wYL z4+)usI#MNID8aPiGKfo!4mkG2Udg*_E zXBhgG9sc%67JvhPu$3todSq&3s6RPQ8Ek5vRU!yU;FV?Y+&viQ(<}7hb)_INK?_K& zgQXL$u%w$2q!bl27vjv2yu!7m;dh>8N>vbNg_18PUGoK^7i#BKRF^Vi7Kr?lMla-@ zd=Rx^i!#qPZa;&f9llutHA7gSKWV2%N7Mv0UmiN)HwEcQaG2)ao^t@M-t6~+6(%xm z!X{nY)n2bwxSW@Q36jB4Gss9j|**EiDH zL)RIiW0f!+no*(xI||Xk0)>s}s`9%8aSoeUAkCL7uel7joV{DH**48{@V)s|eU;)P zE=li>6s30)ZJVI;ydS(X3vcZtm&Gk0OeU#3exepqY*K_Vp!RhTOd)VvGTHRUo^d3} zLTdV2)vrx%!s{f@(67e}qx}mW?!EkUu-{&N|9PTWq{4{!JR90{Dw{u%P#|c246MC# zh?ZefaK;N+^2iQtjKF@fXsNdBz8qRK=spm`0s!}Q=t!gs&efGzqznLSqkeH(KAfju zCFL;U@^)!SED(3>JK%A9h)ETxBhi(?{a}FhP~9^(*7Y>dXg5`^uI1v>4mp>oUWVCK z0+fHZ7c8=S%;mi-1jQ~;z9(S{hZt`GLxM<{xDs{mC=^*&s!&{_9*B=+Xf24rz>%Ft? zy*eV~aciZNyMO>+0ojAWUomVlvI_vw;#utitWt8?+2%$Lr1i7}$Q?ev(qKZ>aA#X^ z{CeM44^@Kq`EvPzn{DYz1H_lpWL~xA34uDH17Gv?VZe+k2=RaZ*$;Ek+|2_Ypjs~C z_!~{qV3I-_f`>7EFk=CewDYm3Rgiih12!~p za2J3$kcAskc^#4w6##94L+U9Gc}VAL&r%u)jfzRUFWe%YtvH&`9UUTf=2*H?vEo_( zULj9v>^foXV4+R=n~WX4eVt%*PeVi+dgIXl*SVN##`g*uO&VWJ_6=>hF;OEj$>Kfm zfq?_R6UYM9Ewb>p@FG7yJOrZpcv zI7e$66-@$6pqD=jkP;RGU#IeV!?;p@B%`vFJo6N8Hfxh8Ky3in;j+*VrdYzdh6*2K zAArP@p(~3J^L#)$PW(0if#n)*ARvlcXK94vRgMLe<53r>_i5bvY~Ph)ZjrNzO0m5X z^ep^qJU?O$Xy>vmB8{H#ZIWe!|z&O z2WoE26Q%0XZycb>J>O*pWQ{Y%%K7q@Iv|^Mgl$j%u9QqGCziqOD~2Z_h9&@`NC475 z)gY1uv7u}EGmIh$#sL(~Uk^e@ZRC>(+#YpmP6YM9e!)44@vtB?765f1gViWj=Jdrn zf{1Yq;(Iu^O@Qh^yH*8*dndQ&Cx9r#K4^L*bm zGys~Zn&-pr3XTCABUr+h^7co4p~hsWA$g!ytHDd&`rMAe%UYhX8kQvpzwUf;1=p}% zgP_stW(c;u902JL!S)*Hula;+dtTuUi^8EiC50TnO=q6TxO~9~5BqoW@n-+RvfzZa_v5sKLNg!boxPemNV4LeN z$w>_WiT!D?bT+oS4oP6aHHA)S3IwskPUx`UTI(P@7JcHg#-%)~Dneu(K$Y)@RvQ3U zmCc)$z^eoo1JmS7bv+iaXi4fxsY6C%FACF(Hs?hQkp*X3_#-+`D%+wBsrma1UIqch zT#z}yA|KNcZH&)%Str!Y5y-JqK~&^j?CEywX_-Y$j({tJE`_>8v`RMlp`H0KK`xL( z=igcoUHqx$kAsltTI_a{Jb>hTA|kdy!&9EK6|Skvm3aU(y{)3@OkbQd=guMS(*THK zbNs)F+zEF?73)+=0`HA;_T5?!ViQ-Add2VgX#ZXxxnZH>obIpM=j{#DeKkJnlz#Lg zQP7iEl5r80aX&WUxZc)CKD$;@_$c)Ecv*w8@7-X@0iv0Tg>(DbS6C60x-BF8^K09E ziMp$(dx75$fCdGEwIpn1tv!HU3kV9l0Hgz0zhs~xbZJc_4`cFxS_yGJ%pKV<2o~N7 z01yGLRd-?O7Z60fLSk5e^emKEJ4$FS(1NHb5IVU93flq+T!wf+G?O_LVa9?)9ofV8 z$RUdXRsqPlf&1J;j3D0nG)Fjf(96G1K<#7E#x>HRROy?Jc}ki(DCfwCBH>Wk3#2=j zb9%*I_oDSv^EC!h$K+7^biwyPsRLl7QhNT?BH^g1Q(#&2eG#c+eW!$ok zpv=I@Jdo9?3b&d?EZ+ut8!Tzl@oq`7yV&3Bl2zQi&&SEs`gW z9$ULM9ibP>_r-ecfuzoZd&H3RTFH?Gsg+(5&o|T6r5k^COpkS$P1J;J-U{I@@(ong zUtanukiy3l)||-+Rn6uXSTqh*czlybQ|&>bzS85@V^nhm3lIkYH?84`7)nHjq)OVP zrhu+~8l#hx)Pvu^BV?9>hY+y9S}k;yW-2}-NZ;MgV`Ae^eMHhop~Jth&z0>Sa~O#1-O2Y?A7gFU6`9pvl>?Zl zgftw?koq}6U)|EbTNIW-q*#QlTs{E>8Xj)YWm9}zRSe`U(gJsAl*?i3A10&rm|L3W zy}?xj!99-ZqB$UGC$uSgE^TY?EDUUCBR*cke-=?6WOp}kQHI_7mq0+^Yp=Wn-I@O} zTVJbYqXPHnzrrDM@qLHCXL8dRFw4co3Igu+5Vyl^g!gawT^6rGbpBx8eZWjW_O$rb zFS-N0y3Bh8?1Z3hx2Pan!H7ivZ(e$7Wg{L-59(*5(!L1O%Mzcxmh7LEsPhO}^GkeE zqxZbq<@wdaSEgcItn$&G%NTHVwEEcBl9J*8>M*n9yFVbBkU;IC1SmC~v&rF1L7 z&H++7zv3{ow+I~yGN%{K{5m~-?1<<)qznOo72`|7&eC>kf{5J6j35kw+kcSz7@9xv zk+vucn8kv8{suD4c#q%tpaDQfGPvx<>#WIw^$W@7Q*$sEHN61nS(Q(gHh&Wk;0ng^ zzs)j31oVQARW;>Kd9IQ_1I?x)J^GElHt}1uN%;y5$1q_GY(#mp+d(g^jxM!NhB6qo zMM<`Cd;Ye?QNB4V9=4QbAE1eC)OvF1^3 z10h~PYk`d^zdKlP^Jm(g%f{LS?luB5GcSeE2ZP?44LXA-FnaeKPi**Nc4Xm%4Glq} z%n(cCFF+Ct9v1e7?gP57ZLkF-`!V~oPiwql_wLU>?r+~{Hr;Q&^%1Qz~C zLaDd}Fa#k2rv~?mTqp3Hay$Q%eU)^8Ju;%L4{7#3W_W)4i~(ZQ7FD zciON#zT>@7e>AEBpre8ajL(sUZAd-~J}PL#@loE<{+vWXV!?2`0M!|Qq)m=3&{HRm zGguHS_FyaH-1A%KfD&`9TrCAtCQjth1ueizMV`-z~PgBpQ zj9N9I1bm>Vp%o!AKnVa29RL7S{MTO%-`jmM4F}*E=6oZ7QSkdh`)(%ywr-U~SoriD z3FQ*22x}oj!KR_cbP5B@jAzY@&h5V8Qa|p6kcRZORLY@52$K8Pn0ctvZWvKF1F*MZ z5tc*-00)Yjb4B%Wdm=;F45Wz0K6nB6;(4WYFa2+-`9K!h(PT6z#&_U3M@%td-{{y* zHAc=TD=kuYa@b4``qg$}Xn>CQm$(G~`{=ifw*RAxn?m&`XU_P?{X;-Q**`up9(i8y zzunlT;Bn9U;{~;i!vCIf?uqR@M9+4;ownqBE&ih$obxQn{j2o{R@cR~gXQUe&QgQ( zoDr^*cQih(#V>{5`EY?Pb(|cves4U8F>#$Bniz+N`ggbWasP;LRlEI95|JMgQ$_+*A5(Fxa40&XOr&;E&b zL@{jLB$zMEk_bIJuAOMdsp}^|MI*Xut)NstNG4Q^?FMx?gDK>=uDWev5DwAwKDhiY z3kVa{=K@J=g(ZP7rt)jp_@DBdMMUc%3KQ;@e1yx>P;xuhGtf5AQovgBLqwH! zH{(ry(NKz(Q%Ix1FK6=g^&>L@_aM_ERkaIy@2cX=ukvU&c7sHHN`trf17o@uOO7tr zJH7i2LYIJt;N>0YrfiU6eR>un3OAzpE%qMCh|*x}Z5W$Cb3hAXOl- znO)~cyrFvWX6`_u13#sCJlUh1}J&AmS1%H(K$&1U}V!8_H)Iw-_cOG|6)FRNH zlZ8#h*+d1S%AhWrxiVGqvdI{%gO1{ZN@tK;G3&J=3k~Yg4YSXkb zPM*82E$$Vxgt~TDZ<$u?bu_K$W9Qka9d)ly(4QaeKTXL$^3rQMa&9j8(ZQ1UrLOND zU&GfX|Gd%WkZL!~&W9CpM?wQ8j*A?vVD-WkBk&g7^?u0-+; z8F1y&1&&Y=hWz?a!+v|(8Dv6w(eiTAI;I$a)tHTYs~ z(HXE~moxZMe&<%Z+y*NFUS4vdfMg>ApV87KOjWy&GKg_C_au{cdA_|=daw&bvo55?z05* ziRpb2(jVx4cWcqit+V1oR$^GthV`A8<%;O8%hI_^j?MT%xZgNYPW-Y(o4zZib(H|s zMrER6Tfm1aEzBAP5@^Hg921pQi|0%N%l1W!;}5;Y{;OcIX71^k?&$w`^}!=z+)Y@!3*Jn$G=3W9L>fTTmOoZ zO4P*}#S(}BM9cJVQ7j-#opBg1r5ye*an0s2Pjg`bKiNMY0Q9Mc;R66_*GDjcK}2G(Dp9IiwtMZ80_WAsO)c~}q1W};mKCQlv6hso>$;2D?%!3uzDF5ihVk`h=H z;mLSB`GTJ_R;S=zmge$j`qAA8OB@JjZ#K3lI>@$Elx&@Il4qjFzV#oEu}^WLfTcdI zTB<#I`B7-Pk^d7d)i`J&^~>uv{_dR%k{cwbktA4Vb?iI*3Cm@+3WW?%0_$v3`Mq{} z`EGP+N-z&8x8GBI%ImTn=*aDoXD|g#i$dNS>+PHKqlx-aBRHR5IHdPXp_%%}As>Br zs}uh^8qW10MbH7Ivvbh9RS88z)Vs;~y1VX4l z3feAE1l|L5L?CmGFHObHSBK1|f*cb2xcJ3DBcKCY;stRoh6)?SO?hoKlKJJuwp-G&-E+sOyI|K@W@!0y3-83e+v}h@eZ5 zk1vN09^;0$rvPw6Qgg!x`6R^(c$v)6PRwJ}X{970qcy@pCQvHPx#O1f3F>#VDhY4S zg5e`U#(|}(*CyPHwKVM;zIq?r>+NOpZZ>C^+mMx+8*=*e)2)W%CI{49sAh&{?D$(h z1qVkWR^_;Q$g7cXSIjv^}B6;GLPXMp` z*BZ1gMPyA;&Gbdi%%v>`Zb&}xvJj0BwN%Pa_Sf^ib1&P*Qe;A>YV+wiheKnQ44zCu zO1@M*lG`0~ke9^O|JKxf*K&C3>B> z1xRobh|9ux#38Js@a5tvt|XxJInJ%^foiU@l0p7^9etOWbu26&a`fP70&Y2y^DH3B z?D|V>{X}fVG^{C(DJ($E^^2(Cxk3e3=3{O5pP9gjgaq_MREkbgt@I}vDC7wWW;8=Z zG)0000(T*bEj3b{hT{)AUV2Pt3#V zQ%|XE>J(w3??ienEJ1wHBx~RmomLEC5u+vNoTnJw^vgja68VJ!ab`rXQuzK5cm^;r z)Za_(-U=iRFVylI+dYW5PP{Z7=9i9EdYWnlEpa8* zSte|WSP6x(kHtb;FZOGtT|QJO?<%JPMdOL$iTY`EwJIvY5*-W5jsbCgmMSK{e5)o@ zi*E51+30^(v~H@Ea9dVxW*SKLUY0E=j&L1@SR-JxX(+jnxX>OVF@EFSHh%yOwnygK>~uN9 zPCAd5BDX-}D4t|W42LD>4uIx_A=B9oV;E?w83T@ShC>qxQnu4-!>+J0x{E+KBpu7GLOHhzlmQLNeC<8CftL9Sl!^NVaAm_| zX~44z6dqA&5rBq74fyaNfVrBj;fca9031vha3%^UIL1BG0$nNv{tHj|q$D7W#^Dx9 zpSP;3TR@E77Jrxz~17Vlkaq#Q2)q5!AtLp-gmfViFGG*(u50u#Uji?yBQ zOmXeGo7Y$>HiscUjpatTaK+KjL~ik)qj80jxSY579eq)b3H&2vF}oD^%SyiXKjRXp zVzpSrO;mx>o_KPaiqW*&JV0OGRyj6G^2wHp!0+p33VP2{E%Gk&7@x9wK$IM5RsE@a znV{_MobGNC825x|@F-luvmcQc2Ib&AI28YY+x`-=fa6%cLptMYItWO~M9yHlm!+LA z8!1wN(gct;!>|Pm{*mAmPoi?TXeC@&4Mi+CVGqNi0(;7+FfG{t+mq)N0NO49=`bKa zOYY2IiYgkHF`Mwa4SgdzJ0l(SkBNKZhdM5RX$(gB2bntqME(MGp;R3S*R!3%vT2ZVLGEClugj^^&#p3%VpaYWXfF z-d&59d|a#AlPbwm684m7-4Eo=Ye6Fru-Ezi`ShG6AMgWwP1P9CcbKxW5Dz5)bMS~F z7V?ntdPuE&2;k$SkYz0Lq&~|61cPC>$UL|3i1Bs!LjV_uUSl1IVovFVqngjG!Nai7 z3@kJq3w=xD0-_XNpn{a!(8t2jHnRBd`6cg4OB6)$4vz)MF*lW5lP+!EagNe`P6hEK zMHO+&oew7nP+bigLP3%D2 zN#?tF~!g!wkb~Q;-%orhS?^$Yg zqQ74CQAy8NSn<1~mEMAA8X-P>+^R~jP48@#xR|8c6>dER;Njq*bU!s)VUOoN0B#)k zIE{-VLCf6lmA1Tmgr5dEH3sn?gP0wnFlk(e$Y+1@5x=(cD%5xq4VDsyq&0A*y1|;?pNd@nJ0tH^O|y6t_42G0`uc@{yj!V{^tMp2gkix$0|*b zoxqi*=Xe9J)Sc6ka1j;?KnXrUcgeL{ijwdr!962ryVd$YH1}(0Xa<2No$ful4C^5E z42D6;c-~KJ+La0TAp6-Mo|jP!Yt;4nMuVNz!q}jqJM^rt)`+l$~oQ&j7Q z3gPdVD&98+sEA0m#E(bl*fH3pF?zH?_QnE0NK50NtSmtv4JMofv}Emm0Yn@DfUcM* z;&Dg>3xEC)fSeO&3+hE zR9tf76y8@c(L4>5k8zgzD`BA}@6=1QrV@qxQQq41T)!a2ToNBCy~hM{@fKU?5uH=!BiVKx_^D3#A~k$WhZ^w;0kBSge+hf%ndV#$?$ zah04jo|3O+ZTk{2D>mB1O0khB0G=$=YrOksrSV!q7Zb;|6fAJ_>_-Z6A7?LN}yDLeW55tv42ra7enbZywBsZu~;$ z4Ik?83!0rUD{C?CG=HVF-u6vf`-73&;Nk7DReg{EFNi7EdZ$m2LpU0G+?j^4kKJz@io3OLa;|f-eFf@AN9EH&==8}49cOG_-4PQ&d>U(=_pvb$7OfQ*bAFP4 zEZI7asz=Q1tqnoJ_xW$q7?(|y5n1^Mr$@KatB#e8a=w&Y8Y*!eLUj&>7Kl9Tyx1XG ztef^rkHbLT%*CP%979A#)O@Dok0&xOFlWRaKc=qK~F=JG!>Ox7cbCi+7rZp*Or(52M?C#tcH~JO`qX-Q_#B`fYD? z7moeq?aYMO(loRIjL)Z1PYT`b{IYNtmv8YzgcCB!%N%>@{NZImK2NT=wYyvDk*Y;? zkH$N$uL#zik+6=qD=KLpACR{w{YF& zj{04hSiu|RtQ3`?7wsY#Q;`kvcUq}=#MKnA9P)iRM4Bt%&Pwwh^u~Lxpx5n9FJ`au zMNzN4uD$}ziH$nXfCe$3muOt)Pc;#%J@4YT zTzhi9_V`*DcL*q8Ene*!-FoG&km>D*A%l4-XogT6SZo@tLb_8^~QzSE(Y?w15^WU>}-DIsisTB&>R8k}{T zKHzTxC}rmR+_0!_%n&z9xhU?f(kT9!3Y*<#q<>^sbu2v0S(14CM!>ERvNN#Q zG45yrJB|cvpXSa-!OCc`GBO+`gDA$ri(CBD@tgTHxWFa2z^Bbd5~34@kgZ1GWDvL6 zaK--+3YWI3$q1$Y5Gwy2UcY@C3qR_-sbaQ$jDIs93$I~sYjL)vG-1_jcsU7CjzSdB zchX6SbSyl9z2o{HEFI6@805Z6`3e4y9gjWVc9k8+HtMtHX~&1!a<;4KcIS$IUPWdGcrsj?rhr1e& zJnY*ZkF#%AfIoe4+SHjRQ4`iN&3$?3dVJzV{#up8LxG8l-#!||dCN=KPo*?r5k%e5 z{BhqUvCd@IUe>(GyS;{wRTqa`R7>7lv^(fGuB-Cner#7sl9sJXUAPOM(I3B8dk2JN zhe_EK&t_8|`4kn+U)sWR_=Py^Vj6;9b5r}!QO4nKWE^yoQ9u6UX~y&HnDO)xIKST@ zNAbu$HV_|JsmVEnK5mivyVs5zt40I|7)`TFbt@;@Y;^|Q-n|Od zdviu8dLv{w-|2SpXC;Bp9{Jd7(r=?Tit!Jp?w)Et_Wi#{A)J{UiFb|Yd50)=~->=?w$Y|me>}Z}) za%yxf`cq=3rTDGl?z#2(GyA*qol$SZkA3~5_Ge3|uUh83=?{-*99~su54BWm^*QH; zvO>*&rCJK&!FA~ar41bMEQOmH3d|KEuS{%+MKC^TSgyqIkD}aF!k^U2D^C8G#iN*I zfqGGab$J6(tZuEU`0#%8U{v_r=AdLUMnL*Sn&sADOmWtoDuqh#Eg7YfBv)+@Pvy}E zA!G@MQYN5%MP__)D^Tw|sOjrV&424nBAVCNth64^JU-Iaa{zo*e$l+izID{5?`!i1 zHiY+)&I!R~JL2a1Z|%{(1?pqtuR8{GI8lGIi*z*prScKBcDZR^5M}obULb<&4A=Iz zolLf_%4rJi^!Pn^^z&-Ln`ghSW?m}&i@EZq=VPgIfH05d=SF#y83b){Ut7h+GS~{4 z*Ayg__^|&Yv%8}%p3^eR>wU9=x7GXp&C0adz@isQwO9Ivt!MLt4x4n>Ia;o**CXAs z)n6d{w$x{~5*$Rw0asKXjSaxt@Av#k+J5i?bdl%iBfP|*diJx{$>x52aI<*M1;JCT zGoT|`Ej`l{o*Ex9;P2zd%`$m3pZ#)Bth{y*{Y5JB>cP3}_<8*Ix6aqjTTaH?oOz74 zkspTSH(YxuU-Z3W`~Lbz|9`1ZoBF`NWPV31cZ9xxZFLg<6XIzp4iX4axEXjekHqX= z8aVwOAvgDd*~5Vvheay>`BmLJZY9~zocibC9VU_L?R}}UQ}}&wpU+_bsJq*yRn$yb zN@eWNsmqUIzJ6@g$mV==-5V{wvc2IjoHSd|@s3$|LT6s|gZgXjuUi8TOf2j-d}q$S z+?9C?J$CAsw%COomy|btB68PL`c}R2qLmQK~!S<;)A7lVb7G{tsr@TI-=jq=& zZaZwFclTrg%Y}cw%St=R#o?&O+mhF@3OW}jX3A*PmQH{Jrf74*EXGA-HaIjAq2s%? z@6PM*X`yTFmMRwAj^}(RguA}|TI)0I+tNi@(TQT7_&pD-xK`(f=QQ+5Bw6dec)Ac5 ztM9(A|5Iw4bire)+r7C9N+4Tph4uRJp}eTYx@=UtdrpeT@p`; zhh0AHngtBF#`9Nw5pRqv+`U_G-fgVp>Al%waJZffaz%~z3q6y(gzFiFUg|pH z7^=J?@=UqXmvrscyr|33^LDTLW&}hEW}zBhpwZ_&Vr6H&l|?+Yvz}uTwMNHo>nz#V zGDLp~FK-rTbjclJXZpoBQGtQIOXtqehsr5+mp8^pvyiL7No6?c0)TS%3@I>YP-}vW2dHCc! zQReQ1$VV2JMtgx^zZE?N0 zC#5E1p8nd56YHYLPACyqYhKg6d;06?$2HT>(-t$*RkB77!tZn!4p&7p9n2odi+@m` z6pypbKGRn6-yC}-pm_cKZT5@_)V?r~^G0m)AjJExAq>>nAPD#pL1ogn=c8l|J31oeRibpmechHo)? zey2|x$asw2ZF&3t@9mjR6_3`;Cs4nco(y;2K6fCe-S+IyLeUpdKb!JSza*K4y(_Uh zQtXoRx%`h}kwCb1p)xEK>D&FTRWh(BSZ_kgM(^6h}9^mL$ISpEom$r7;%gz+D zZU6Zabyx4hwzYgrVlef`Kpe%!Yv0aD${uqgzSbkl#{Jb%8|i~Z&Z;v2-Y`6`)*{C5 z&1$lOyLpvMK4w5vxUch`uMqZ2sMj;eckcRJhkH^YRoLcY_S~(a0ADP-&;9*6xZr}A zt>=uc>kVN0*`zqQwr7UUqb2#Wjn+l2?;o?zR99I&+pu=-%f*#`4!@HRRPbNrt$Jr= zEgte9P;dJvZsKcctCDnXRLr7W`Ok(kp^=o`#y;HZk$pz2+d{Z9T z((Mfl&KlJ(s%SLEWMq)CQo0Jgy6WxIxi^4?qlY}o=z{pL_OEC`Q!U)^D%a}UB=@MS z!8D{tl;YK`Qd|;mm#3y_LFL+};Uipu)@*)1qOd3J$>Xm4^-U4G=%xpWQpQHovtyaZ zv_w}!AgiRCx2Z{c<2mN+>P@V&XP&+%k#IYs?i37bCm=DhRFn(uh0k@myyr!Gn|NOD z6$;VdU#D{m^air!oy|=!Do{VAUQT!UlRcUL^1ByC4AwKA*(gl*l%2QPo3AW)UR5w>Pw{z|Vv37s zic9tev3B(>k~k}(|x|xrO`;6SBbFymg8*h_dK% z`fFwzma}1F>`8p;X2Cz()+Gw27rA!MqlP#rn?1JBtLsn{gb2g5ffslKZBRAuvDmk_ z?QLn0i6N9f?IqYY?=yz!cK6F71M#>#?K6YfPZLN_4+8Mld_d1FWZn!lJgb{^u}()n zy8Ssy>_`IgMVIu&CTaD@1wS*aBX#8H4vIRynC_RQma0$Ih6}D(RNV-#oKWkFSgift zZe+iQtpA+7#4gPGgCSk6zKs?_Kk4JVeZ59P{t4Cx^$)r|DeMYuY}qsM2HGHFp^5v9 zq#b35O3Hb2lad0B|EQLk-n~`K{@)R!(@*?+Z=l?FdBBUg!la=`Z}K%XzKLcA=uJT#r(%7h}2q zXu06^qK}?$66K*Nx2$tYDd&O?4+XFfhb>|6hZY2SQ+A9QkoypWw+9YwLuw8s5+()U zwnyE@#><5$rpeO#VhlVvXe>__l$C7v#KtI#--=mm+;djUl+CsTZpu@c0E zsPDSa5q(dFGc4tHDH^e7x2;A7wSKZ#$7EdQA4g)eOV-_o9!}S9&HCu$)~c zo(9IH1ZFeh$6QaU!jRhK6CZ{^HA5UM>hzY)3jpy_0OxIA*4s88hb-N9N&jQ$yyKz% z<2e4kea<=@an_lch3v?kc?f6kI{VC!N~)i`GtM5N%sP7}Gm`4eq=Ag2)LEsCB9+|F zzu)g)-|y%9c|X37&-?X$K9#0#3*V_-z+Lbq6kHNN?^=8D)&Um1<6MW7yzD47#_+WK z4LSK9oxg`-u8LmO)O!mIs^?cdq-NruR1*nidEHZ!#2%u-?4rkzcC6fFwAy{y0y!*p zg`M>0IChNR`Q2ZV$v8+T3jxd(bD@>23dK zv6G|2;a!Q5Gkqd-cUqNEQWaN|L=zJJB=5^>2ED-JCe&wDk1G=-vUV9xtkeXikZv{4#qh`8>|*V`YSoyd ziv9lQnyuxdINendTZ*A!m5!UXyou*+^3hA>QeC@vuVxx* zYGYL^v&s)iHi>`4J!&SiR_Mgd{}XW7^ss%UQ>N4%=&bMX&H@MZa$w}F_w#hP|26(S zwj{C72*2t8-16cS`=wr9&(2x+xU9@SJVCi)J}lw=`Mwd>vWeG;(6iRxQqIlm=`@~Y1M$u#KWPP3CuzrV6)!GSMz zTAo92jo`AkjrSG?$FSH9A~UE%XpUo@F_wFq1NW|VtU;|cK7>@)k7HT}phrsms8 z{@sQrerYM&Z+vTJU(_r<=@t{G@AC5)ovIf^hAB*UMn1i+dolz1^kH&q^qPayQ>Og$ z`_*B~Y4gVMGUeS%nfLDZ4crpT=Y2G!A^ zcJBS^BTa>Gp<-D&8ZkdVJ=d4R?R6xwAggU5*;rp+Uu@!mB(uiqJ7#@2z&1VL#Vf(G zA`}eBfOVwv+lzd07yptsZFCy>GVd7r7X@~@BqiMG;3IttU0O*0S@iF1tJ?!k9ldSh z5&X5!{*&o|fo|BcP>loj+Q;weWsz)=3o>8j1of`>OMSUm{3)Q|zmGy`>d(|Hs|+h8 zPskiT@onSQd(&?Noip*75Z!DouLgnZ9wSO}7MYS7o?Y*E-i@8pd!_&Ft8$g(W4ZuL%-1r>3Yo7aPrUeA);$ofnghM)B|7tl+? z-l>Cp%pD7kkmI%x_}TAr%A4r2q5l;(`sNh7?9KJ``AxB)UXe|tdvT}n`;fvlUK4Y?1cp8mVQz)3U*Mp z2XtSUpqha!rI^7y1ji44D_K$Eb3R$ zwJ+q(A5brI&iq^H>LYGj>oMIC3PT2^myqVUb$y?*)djV64IU1xy(S)-^5kU4qVI7G zJ%+Pg{aB-XA-u=7j!prtpYEoVZf2#>!!GW>Puf0u{UTKLRsoxDeq=Jf&Cv zXKC3l)BHcAp7^DD)F9?W4h zV1F608<)0Cq=qAld-685wf0U8XTD99`tz^j>cooHUdo`p)Rwrxwp2 zx>0W)rp@7+{?OLKuQr}zQJxYtsQ~MTA#M{67J~;dLoAKRW8>b=!+EZSO9C>Xr`bw0; zjRa(VLt!X@1E?J&u7Ety5&$3%yp>CYr1w(~13RQX5`=<=A98UlXW0JcDH(~J%Rc#X z@81dJCe-=d-TH1-C}-8WcK}LF%~unBOz!uuPGxZOiR?|62(5rC@JZi>i7toD5#~p; zNH#zHrf=fQu)BKQjT05zv4;4JpLEwO$uzHxjoc)dnLiTG8p91fl;WffKl4k$cI%og zbomVqXNcfzVu#Zn1j zVH0^f%@LD?b=nla=-t2}e)lvPXXn7NxMY5ncnuoJgx6vg>>NXMag`K=O~Fo8rU{vQ z@{TNJ;+$M(+#LnFU7d;PDfzJDH`)f^A?cc|phyeqyos68rUPH(UVwvw0qIBX@flL+ zA>|s3-~2UBfAlK1t)-=BV1eX$&){N(HZe+>Bv9&Fb;Xqyetpl3-}2{Ezf?gRO6Bl{ zsk@54XOn|}IiT7G-j`LTh`E=?nTv&Z*Svta(?bs`BPK2{_SH;eohz;h%5ljPx9p4D z;E(#Sc}<)P8^OWRxj2(erkDg=;G}$^hBg?9n;|8kbhP6fc~7hl2sldWoWs#jx+*dvAWi`}(ng0AGz$J8hp`LPR; zrQR2E_zr(O<8ZrFC%m(kyjVA+eCX}0^JzPu#@f;*&;_TPhTf5~tUdxvyvv<#p>xty z`Zm&^gR#K;v)FVI0REzYIVsL? z8^U1BkH029MZyv-w=iN407@10`vO$usC4t~KUNN19-^3wEOnzlvMeN4fH*df zI)#U1nOj~oA0tSNBnx2US~R8PUOlQCYtMM1E>}CO+*Kkh0Yt_3m5NNc5#IGUbUXO`xjv^ zYkG}(5uYX9g;!`HQISjm5W<5HfUK@fR3g~g^fg>f6D*OKWaT7@Wd-zi6 zuf?c@YjkK=heaB`YGjR{bZvA#W#=T@8-#f2rrZ21G^|vMp!%T>WK5z9!t+N=q-ZB; zd)0gatZ>Q88W40A3!#JKl4)3L8H^T^dus&Mo1QE*#t0;E8ir+x*}@C6$8Nlrb;sN+ z_KaOS27q@XDCuCY$x-{8ZlM)`%}?QXo6jNQRQyJ&xe=6u!@*t-5%;(19X~4j^^8MI z1(Lg-H)aG1f9O|R<|^o@5j@0;tCZQ&M0x-M7ndX0Y=O+;Kp z_w(#Ct-Ft80|`rky{7zIV#)YVXc+n=0COY_Sfb6ZhHE`<%i4Z#;_?PFs&c+8l{Kc6 z({}UlduJb4o?Dn(^odJ`^JYS&(6KY2#BhQ@8s29u zgMELcR>k$Y?z>vJ7xc?NRLu>q;*&?va`*Fj=P>h1pFDWw{1rBw>zkUSM?Oi*?^3C$VqYo=P3PRRoE<8DNO-lv6!%pmXm%&nET2%o!VJz1hZ9VnS290khFOXuLvrmtMhnfJ0C$#21+a{`%r(O@4p0+lOuGQc>uk z;0*x9WWvV`e|*)H?;ps$qi1F%-YsUqg%Q$~?i1N^^O|kz zLDTu>8!B@C8Yn#C2T!EPjY}-d2Rdxym1QFswr762J)$ijp3$JQjZv`IEBOelk%* zAo#6}WY*$4%KHqr8I+)$v0KcU|Ew@Cio^`2#ps8z)KL}DDfk_L#+{;oXKH&*gM(yG zTX*9NF1T8o*-D90mK7+Xzwn9;I=5DBH+4>x{SpW5XulS)sTm~HcIcWosfbg>D|BOC z3BV{MX}BLcGaB6rgolw3|NY=sVY^#u*qO)JIj`E;^n|OP_YcKIs499E;o*M>ULQ@5 z+b2iN?(namMYxlCw4-^GizP~QQsj@uf6csfCPsKwu(PH{}PT#H0SY5SZ zRG#;*G744n!MQ?1EyaFY0u;`2+J^)3{iwouMT%_@&GrXanH9}=VbVR2UQl_;M3~+T zR`%&sPW=T++YGTw2?^Si+}fs;EUP>sTokdA7|thPqivx({1VAzX=oTKSVEqiAW8tQ z`9Xa&xSkTFA4gv*0L;ah&-!OO;32xd#u$@;`ViM**4Ipr3A;4 zlP0_T0@rwQ*{G2k1To&-kqr?QfKJ50KvjZDn`VndjI|ZU_T{y4y@)lB98o`{F8av# z4tlzg@3#2Gm=?rJfSb((jT_Rek2rl$hE2*IVgbZ!a)(I&NoWFGYyi>*AW7g~ z2q?J<`W(+|frq$}ku_u_imUpD*cn{4Q&gAHvXGc2iA&8;^ykihpU$3Gm-E$`J#(hY zSI(fUPW!OjVUsDx1fQhLsY(|`3Z->3+N+OVaj<|t3w{(yDo~YdA>zWrE(L-Wpu)^2 zezs9IDw^@StzEHYFUiexxci3GOUw<923IQ;Noj)}k_qQT-$=wE0;8ey0<%<3e%zA~ z;oO8ACV+}%f=vVfi4Ne;l=|Cc&u5Cly1J>M#Q`Wb=y0^aVGXcerK}!ZfX}N^qzPbu z00i2E$-_V-&Im#RRus?RaYUm(|K)EwN-1cOW1V(JHLF8~hg8mZX^CoUh&78jM~^4B@^c1a{0_5CPmTh8JY{)MNv1f$nX{qHqYbI%TY<`WDcOYY&s%v|u zT91rTtRH9I)jVA17$!4=?Zb$7R!Oxfs*TYRn!k`uq#I#v$tLa)?+t!S++}V-Sk|~g zY0R>FrkIEW<+XGs;%!9h6D zO|aVQZC;`sHb!ns!f2KN=^((X0YFIL>`ky4JLpf3{N@M8m1&Gek^PLbT7t6p)fOK2&^3K zX88&unAe+rTqkuoL9x&lbv`SImUT7-aY6&EpsF9G!<9xwreI-2Qu_%s%mx^UyIdG0UnoTOqnzgvSHcE{#_PJ;E)9s8G z{LYr5*fEPcDf!2&`V<$sPb_(Xi7`!zHpT3T z+x-1|>v~ma;~}iDMv@V@O%{Nt0uV*K-VNQXL1b8TH7YIzPTkf+nsOE3r27$O92V>x zAbkUm)M)QNEztOSwOv{9W>(5Q1`fg4Ky`cHyOTNm63ugx2epjmF<;_3*(SZ2>{$#8 zL`8#i58{$|L21y+W9S?e4yi;` zv$=#!ujK~dMjvJ0*Uiaaxcp(4zl)pC(uns)p8ah+(k^EBAW(nzFaL5L?Bn2-&A9$U zWT-Mo$UPcrNxDAxBDd$qxnb`>bI^Z?&Pa4LAnny`7~`EG09o-aEg~Zm1QgH5mEv5D ze_ktnz2ak6*>W0mhc0mI_ni|^(y`CZV+HfKcv+wSRfOoMrw_9B3DQpev$vM z{3J$BEMEDa4OlHC2dtdgRU^l-RuN&3OK6XaclGl16Uxh{9f}5OX#$F5h(kIVxg)2z zk#Hz^5L{U_r-m#*pQW@t=?`&_-#}L5kwSF4(8t-Y<2>wSB96dOw@F;ntMI@)*rBR+ z%>gxd`@N6D&z6};;?g;{>jC_1(CV8Js}0DYzd*MkW-eUx=BJ1Yp7WxhM_nk;0UVAC z7i8}Z-mx;)q~6?N@#YsniqO@Zy{e)~xLX@3SW#i(Y$5?Q#=GeA^NzO85YfDp3iONh zwciQ40Nul5ez0Iwu{~9lceEpYZJHFJ{Zp~QZ|aq7+8xJ>>mDm~5si{Wk6oGW>HrCD zJQUQP&Kf2xC5(%6TtG%uy&>Bm?t+>3x5%xJ4(#?%G_AOL4!M_m&)&-tC}kq(Y%XtK z8F#kF@yocw@Acnl!~CCJ69u8FHE=_*0(=4jV)L9gV4Bby&SiezQ}jULc!J=F5T`eP>L7_>2V8l@{PNCIaJgZPY+ z;uOa-Ip!)llH>kG3DGug62KD~jB4Bu+c6>m8H?)OL-ytI^lc!^Gu@N&+~cXpL_F8- zv)RD5cbC}jPRHO*{|kS{6H%a}e^5jZ<;w`PV{H5&m&kL=%x7Cv0XD!V!si8$M~>NA zNN9mgNHBK*!Z#)CK811haoD6RW9>1A#)Z{Qjh5=VEMIq(#;)WLpju(bsR9UZI%5W) zezHfM2!n|dPzC7=S1@Det}iG@Q>SMycziqYC{jg^&9$$=zo)^!9}T0k`BcLW@j38% zQ3WjI6jW8dN98;`bj3Hiujg#ti!j>|v8QLSd4R-Ex%?ww5wx33AjqhrhNG2K%JFH{ zR%*=%2eH-NkaHC>Xw`O83DR#{JJ4BCxI0IK_fqFcVcWi*hlY#s+ZIB3$GdI$%pJdo61Y`14yL9e9_O5sr~)4s_y1?w%%^exJ$Pa>e%WDZ z8Ch^&BMOEa2O#K2q|ePonrHmZFFg38Ir+yi*wVOh@j-I_cduX{gALFRZz3}71Eu+n z_8379FO9st;ZA}Wlm685pU}<&YvbTH8qkw@@>d4Hk@DCKsjaepvGn5-Syw61_HNC3 zTNyLA1UCxL?JNocp!5@qyyzd<{Y3GDXI{)ar0ptuR^T1HKr39dh|lTN~7tDGHfuMMlrj}3h;M7kIQ%mI@u|y4TeQNpheI&Ei_m*jGQ$e$Pvog}> z--YvRS|ZmZU)=FF0;CtBniU^dbh=PLT-SpFRMMb>DGFWZY=22F!i7bixJI07d(O`> zzsx563JVaBuqobsL`dcmIM1=3WKOpS4CXj+Iyd+S@q=9){)g8lad+&w+7(>pHzvA2 z)3By{(|CV210_M;^WLPH!?XiNqkmsmJFVs05og?YLNOy3tFu2#@*5^zls4H<|E)Lj z#6-=bjyY0w_Jr3_#?N_uhm=I9#0FHDXYVkb+vtTdJ<$kcbvQmw(;_cXp8yrWzZEsh zPPvSmKz`BJvZh`|Q5)rjyivHiYr$8!08EG=pjaV6V$opeXci5LMus(1>=T+`BE?Q& zsx6dw?&cm=lgfC@H7s|tY`<2fQV)`xsZ`Nto~e0PkldolzhA;&T->b;#0&0skb7oT zJnVXBqb_~bd@EJ-Rfc%#yu+Do&Z2X>XdYC~n)KB3~{F90+-{O~aUnN)s}gMJdcB z|HJL2FPbvCm&00e^zJli_4KWFSU=t^44=HK(ne0g-oc+?y@?h7(gPps&$eH_+=|ny zSpSo)-={*fgYfASvwN7_%}i}~{_ZavhgBPkBgf|C*Kv-`9^8MlJsrOO?w-G)JKJ98 zyhets?Z^1xEIFIuIOA6QiuC0xY=2{rD^hmj6i3q^C!V!ixp9DNOJf%1j z)IuR5aB}Q?ltBkmPt-4YLNAZW&@G)b&62Wd0QkBlArd^5P^{9dAN<($6ZdaxIy@=y z<69{nO->E9Jzj{kA%UMpv~i%ac||Qga?SjO)KJ4vuLW6Q<)!E_NTXQJ8GE_I^9^5J z0-C9{+UwvI^rNFUh@*1QN=e}{mXVbN5e=7xbnW3#mO!5U(`f<3JeBKD$=sS+y$}bw z4wmbsCjHl~E;dH?I5e5>x~oIOidN&6NEUtjeyHZje4)=($zNN*nTf1rOufd4vdh4u zL<$%k-vBw(*9Mb^^2v(jrGfTmt0dI%RtAoE2kd)*dv#UmY(T~^v!m$HB(>1~@u{Ld za8Z*-%Oy`x<}ZI-k?KeJW8Pa=U7dDnpRFrv)os~6-FO5F+e^ZJ6Gcj3@!41OldwA) z<_g7=a1L+f!B1$OnRwS-`p9G>G_}oCTm@vWm^@v6QsX8iu)B*lAsF^@7zA=}qw{O= z()fA2xS}Vi?c1SR*)q

+;$)>1f^VP2P&=E1M{VNV?d;ck|eWFfGDgoVR+HC5f3f}Wk-d;Q;mY|)PsJ6$4D47 z?ZDeOKX`2?o9jr5U{0~6XgRx(UBYQY=%|1gsNMkSu`UFK@PzkLn{M~5b8v3?HoDDT z^_`ksHJyx$!eQo}f=dw)p{h`rnJ68@(JnkQMo4Fik7vZK)bYJOC)KTNN z_J{AW163Vo2OJWUx?S+Z&0WBqS zaF1HOe=v9rhvw^2=*d$p#%YnNi(cH)xo)OATNOWc|^`_WXi6Bqu zfnpRxq#ar(JXB$dut0od`cqN5;uuYa1-c`Yq~_f!i_^w;YN-j)Obx_Y`dMm5S&M1e zn5G7a<(7;WT>kCSdn$LZ8nU85z9cD^GpGsn`dAd+F8y~C5KaR$`Wj2d`JP^E?APbl z*h(vI0BOdw)&=XM$AD8rXm8V=Z1!$!&E?y0v9&J3sXDFwr6d6?j0Qpm@|@@cX8ef*-9` zi7v|$#p)l+@@&!K=7vk89jiv|h8`>O%RC)h!X1~zZJH@N&mbN*xIjsQ_htz>iCc^1 z+&n9$)t<4?Q1I(F)*B@ZYXYj2vnpf`$`KWU7H+sGb~C?-%fA%0%fz6uFgh?;%wu2D zfUOcYyeQ7Qk$C_E7&XY_$g0RfRpmtS<#HQixqA%Cy`$g(qw*$c<2~8gfNK`>Nh$c; zoHRg8A8cwgd}-{yqbSTG8mc*IZMKno^QW~#9>ikO+JW^r6ks5i?%VDm;&u>)13)kvxi>(%(Mcv~sH+Ct7s&{1OF55c zfJP9OZ|<)`+7mTW6N{ner>eh)H#OsIoAD;wpXGeY;aX|H$odFI4WLl*>I456~#oNrX+1NCR0~gDgL0 zMUA&CF$*rqV&H>fuSv{PrB<^Ps|)hjxwh;{67g;^Q}JTq;aMl{UY%3NvsVDVV(V#6 zVw!MQbQ!Hd8fpMiTo_y41N~F&q3QR~$lwp!66=M}%dbuxpJ_Aol?%8_R|ZlNffTcb zWb!WJ9e3(hd9rFW|Mr`MEkgvC(72h@Tn_*R(3J2HBUVz}zzJ6%H4#YNpMfR(Wt`7L z#N;t9d5lN1;A9f)zhh78Iy6zUJw&vdV ziDrwn)Qi?;q=OV>rkWeBK5WSt^{=gnkrtD{sE(8}A%!o9G`Zgl4RLzZ2nhd?S@W4D z;-Mv__Nba*^@67qVahu~=_j>k3=>}(3JCGY+cA%4pAO4ve$5)uALAsH^Hi`4=gGH+ z@YCX`Y+XAUUDuol<*Ft5=ZOaKHzlVKig*eQ?HAU5oDJp9(> z)R45411w~4S}JD`5iL65Rm3=tWUL)a*_oEx4KS%drzGx<(>!1<_>>>_B)iRF!P{J? z3g^uKB52ndDwgS zlcPq3B55>P;K*}dP;*IHBHC%s7DTw1IU`DJX7Ix9qJV|SvOjS4qQqB)#?QkI>8c5yAHL9R zukdjffF?a;XUydIaY;4+ES9J~C(Ec*@N$Sw+Rg(324K-NUc?yiTOv)2L~D3PO(ZoZ z#6Mp{~Q-(K9wfw&6k?m<_o8wLm~mc zh|$#0yjdEHkw9fco^adph7re7ydn+-QDH%%$h2uM53(DWlm$rxL|C-D7)wu6ZiUd= zlFx4KBn3>67?p-N5Cc}YV*fV{$5PY1=*TRQJ{vOTvWf;_)*&o>0=pF{KGt|8HoE22niqv7y* z5XiC+>o*pXvoN-z1;WNP9h zJctz%$z~_wQX^TZv<3!k(m0-+>N2ls>~9+tC3x8_)g29iM(068sYB;7QgUi(#H%3^ zEwCB4(z91NN+qa?kw;ib-xHyhRW z_3{px88<6rJjZ1=SMW$FP-In%M;`kMSunO+yBOc^drTM!@Q7Wtf)o(bK zMe4cN<*#uuCwM-G^QQ?3r>*&m(Fj+bAfq=lV$ldOnlzfRZ((i*jf@tB>Ge{9Vk-9r zAhruq+yx0mNB`-{K=4svyPuJD)ImP*Dbch@T}I?vI7NdIqJiM8`;y2C`6Z4FT8Hxl z&n8kC^=gv|`iuzCR2RxaoO|l{jW5k?wtFilD}j#jh%gU7@z8QX-RlR#qISVUowqcOtsOfB`o2t&sm0_3a$k97 z%)`n*Mm?nyOs=gzdm8YFY}Kr>di)0Wz=qHQ*`w=GKg~o~qUYvx7&~BxHK4>K?2HsPQ2h}&&a^$2Gge9em z{A$B|@!WwwCjZs)r-7nuXu@faYJWn8fF*rU8l{4TmL{X-e5GW3(ov zY`kabbB#WerMHoo%J*YJ-&}dosAm)XeQFVL_b9kUiV^GuZf}bB^bZ{mfrN^UOxx!& zE97Z5h3@-3|ACV1@=4EC?Z0_Hi$2Vk=b)osh^0NalOY!mi;GSToljOJfmr9cKu9h` zJ4i^sfbX8CJ}ibShs*`1o%xl5efU0(0gvo`j}7@UdiYm7f9&`jIRA|=t->Ed(o!RK z8E4Rw2_7F3k&MXqVA`C{Oh`SJ~==4!z~ zlOT=7Z_}43O7G`8<^))EwK0sYCo!q~&G-PzTi~x3bKVS}z?$Wovi1A5+;#A2`=G4kahKQyOOFtUaT!zQ!y;G2;f6Uh%Zf6+vYBXAHa zPCQhQXe=>h7Q^`oys**O3!tN1N8)8%I2&~FpZHnM+v z*gNW+g$wOs3$Ki?0!PW+-Q>^e4}QZ~msp~Bqm7=q&AD4FQ-{|u_qQfXMNKjZ9~a%P zQ-4azvrQi=uS$1(**xsXHQ@GHDskLvcLITuEM%Jq;^%WyQ;QQySbjoGz+* znO$Y0n=CJwVb!27$S2jFOuB0Cww;Uc{95ng#;q2OM&d|ffsP1wDWPlNuAKM5By^3!O_|zEGV@HiASK(H};9&{J=e`#%<}M_d+{K6Pfnaewj!_6aud>$xKeGfRUV{L zO92{^lg0}V%}cVJB8L^nVv#p@v|AiULo=IPB1DQH&aaEOli5(cBLyf$%)R`a(G_heb~U)mbF`@0Z#;_Wj3 z`wrojQ0rEi+%gd^Q7wTKeTz8Wbt>J>3hWd~hBU2QGc`xkRbt67eTA?d{bm0gPInvsf2z%y9(@Fpz3vIMuiVc65~{BEF|&?xh;ZHc%BSHP10XJvAQ-Jt zA~MP3G6%ruLLeFCFI4vA<9ib-35rWDC|_H}!u5*nd3I{TPag-)EOQOPVO!Fx{>VnJ zNywF^ueiuvhD^ki(1r4(3uUGP-<=?8n`s}8^V#|PnzJfYZSpG-)`T@=y>fbaea4TE z*4-P^1^me~5ypPR6h-=xTt1{$RT6a_8%RU-xL;J!i|Oa|!5=5miMZGh0d#CX64qG zXY;rKansqLh#5f;x9n_^?9V_xgy*1OX8=UvVD8kq#SPVTX-bf4RsE)~q0576E)W0k zEctjfF|m3?&tWy|P3fDnKlIh{7mmoP7%Njh9R$L(Q*y+a?hZaZ<=oa@Q0zKL0weg& z+Me0?_pwKPWrEO+Nj)ov`}2cN_uJYP^%EXL&iTsou*=ns7QGXfM&4=5@LdH9H}7t% z#BS#L{4_jt4_&ZVD8uuo=;O5{$%ta~%EO}eiQ8%q4wB-Jm#bu$q;wbukMf$7jl(WL<$9kootN85$_1Yj44D$a!};0T)n@Sz-C5N})d!xd+TcUgtn`i0qXg?nH# z(Pgb73PflDw<+VXy|q>$zD)o&v1x4sj5z25X{^F-&T zPDitl&oQ#X$ZVd$*16fUkXN-oQ>0!()uUNZ@6Df!08@^-qzcU=jDUJ z?;{Ze=Ky##fWYAa2=W3-n`nofAC&s0Vb0gEl(n*F=2pZ^73LTs&senA=~PT$A6$At z+TOdQmq)9ge=v(nDP+U`NYM2+vz0|C1lKzpgwFmF-i;)oc=cVmc;vZ1??w5aig zgl>A8%cim7>GFnA;#sh7k0;7A&-(XSU2OX!f*{keQ}!R4*N1oxt1-YURd1k3@q?&0 zuF4mkDyX%(q6)e}b-UAZvyq$C%&i~2g@ZZ$l%Dx_(@lFZ>Er3yiw?Ua012fjwa{3IC`1Vl!r;RcPVvP( z;;zXsb^-Ib3>qpS3>s`=dy@h#W`A(aMn1FrPy|P}a4b=10CaFlpKOD9I|^bIU>2qy zN}BWB;yW`%6&{1P@z9Qwl4M9`!C!71?@bcMGmd|OY-Cpo@0MUJo|p3GtmREn#~58o zPeiiw6U_N8tyW?~bDoquqw=IV?Po&hj5e;Povc;pg+`8d+ zidX#im!$vMy?~&L-Hlm;qO2c0OtAeTX!$0DDZ5|t!X>@v*X0zb4cmkXel2M(Pa(r& zaSNfSw0}eG%0ovmD<()Ytqkka+rp>^u7i5*{?s ztZGpY+VHu&mzPr$aANl#dkY?^q{O>Cbmc6W82N?15~?n->sAwZmxkb0q6xOJ&={H^ zkT0kK;!_$hA-);$^?4lX@}suok*~Ydj@ZpmjAaHYx;%UQmX)t|au24ea>~m5OTeXG z+uwv`!bzf}D}M5B&Le8&`O2$GQMvn)P?gvhoY2K4-$h5X#SeMd z*2#1`ZOQpY?0m{Z;ZwZ)ftS6e!fnptbtE%kqv~(VJYmDDt&)zYGCj%+a?qATDFz&s zC@XHsRnG%~*#WwBBD~Q7XDTfbi*DTd+2uuyxrlM(Kv@mRhDU9w?lMdGtz47@09v&S zoU>>+`6@R4Nno0kg>L@sy;P(==MV*sJZq6-4r0VMK9aI|p78V4&rnCpNR06bzLY)B zpVrWOs|+}i+Oz(KV3ZuM!(IDPVs|7*v?h6K7UmWKE7E2|N%*q~RRe0i`@TFXZoWVE zq<7V7ov%`r7;3hrgGHm>T#ik~iWf0raZdnSX0Dg>a;wUDT!sH><2JnKXy?Ebv_l zw3NXlP#oq1dR>4s$$W@5G4pgr+s3uqB>B?&ly2np3Q$^Cjn(5TnC<(oZvJDKfARbg ziVpxA5PpDfEV~ z15&(Q?2|_VQ?r8&-~c-fYG4gew+a|IHtG2l=oUE#-*Ubens)%3MA9Zt52hNZb2I*H z2r%_)NX{tShE`vE5?dmFHl~oc#-LD}0qXXz7A z2~m<@v5M2;tQf~)OWuK_PK7iLm_P|MJB8hr1qi%4VGed6g&NrR_id@rJYpr^RdJ-Q zVo*?J=#RfI?$gZ$QxOb{ZGeXcA?j8|kap;xH7dMTNI=^yAT;d7tVN@gIUmuE#|0no z4Z$!C2AAjCR?6?_5b4aoUi6#)b|V~ISP?nn1xJuCu8!jipNXkQ{@fO`+4j3FNR)cu zv(}c0>>5TepO`AC7wUQ;T=0l_0eMApr2Zk>aRk=^*Zd9ZZ_LEC=kJtaWzRZ2jx`KP z;i~<$r#h>Q`=Q1FA*pQH+(9Z!WgViZokg--bo!s7^YEwY|Ks@G8`s|4YjrcPy}6fb zlWQiSOIDKXRLZ^fESpeWBMIq}q%^LXO1efes_!+^wvtNj&+k7tkH_bH&iQ;^@7L@3 zvH)DhSe@5|6N9*9z+!Bxte?X1+V9VpjlB1;@1L$|+G*}ti_sJ>-~sy|w=e&c)7%MZ z+hV8n!)b@Yqg<%_Y5B$)>RZB<5jltfKfqur!{0VExX%N*d(>Q+ol)o{11;u6;5o&p zX`drvL4jqzVW;!aVp9{g?`?55GqFUeTZJ!2bAOC}hu<)U6ts?w$)%H&>PWbBST`wc zKX_aDwquJfS_nWEioZV>^lo+loL#3pC{KM6MAZ@43H~^FTYY}7pGxbKrL%{q4hbk_ zgb~@5(x&C!wiM9TUU1`>tTa-s9vsJ=VJQ2+=2WNqL=gc&Hp1wJ0d#PQk?epGV=6Sp z+o;UrMxyYIsL!zdL#d-n_!dvo0(1wQ<`|X#DvG4_OJ_gt^x-BzMl?*Tw&ur0v-O)w zS8qn&Y5ClGKcBDu$V@8bN;mPeKOJo`Hu2YFm>f@sQHEthD!xpwpYDV-ie4pi{^kH5dDhk?pNpz%T(F+xwj+nu)sso+}YyrNv=xtcp~ z{wf{yXDzdGzLU9O?+$)G+_feMIj!el8!p0tor(}R#(~CoWB-9G)?@v-SbpQ^mqR45 zec)PimVaZjf8Em4BJCtTfh1W9y6jrxPPMuD!LpyQGT|L9C+|Wi zkez_LP8ZM*(qGmoXQx4UDKo^WNgC>w`)eqTBi z<(e2eNL6qJIPcfXrwK!V2(#Cgh$x=Fmp@BNRYB*#^6bUcmM7Z_IG?t;1ezv5_+Vbn z!OP3!cOUzd)$;&|K02Lvf<}YwUn67D$dvnsEC;)ci~IkRJ$* zaWq%n)Rw2{Sg(i#+(Y+L!4x`QFj>r#fk_9LD-Ne_ssA*%E7parHES2WlVE&4&dhBy z+#$|a;M?S-R}Qgpqbl~hLz@=4x8JJxK8FeCyLayIq>BXfBXTL_l&aFv8yL#n|e>)4UxhW89Y{d*X}3&*mUC203_zOxlhUW0hxqa+Mv&OBMDUx!pAqp|a*RKA`HB?_L{AEjok|LAgFf;H-oX!)=7s9#m8!e>-(`o~;e z)_z0;Mt`ksWut>7si#XL0p#ZHH1n@u$?w*&(*be2{T90==cvus(MKX~DL;V5$S<=IL-Ap@m7YOHskZ;oAE`Ui=L^^GZ$&~YB^@;o)`+BAuA@0H z`wJNR4Cc$QA^v5v!yT*@%Czb$au9_?u}-Id1|+&Z(vl|MN@q7vxTjs;&11vat|5RO zQsTqrmhxgXw+Nd90fpOJ|dr zX!^~GpJr!sU(aR=R~#}DkNc$jaOAtK@PD@H_Y=gv*Gn0)>z<1o5lHvwL_bs-pftTa zTS!6{vLA9h5M~3`kF+vLClsaxXO3k*jLt)6(K8T1DI?M$0$fp;pogF{;K&o`>REu58_Q&Kt-m

    =_#UHQP3uh1^DA$oCBytbdmR4U=&fo3EP9#v>$~PXkEhbbWxKWi zDVjm=n9nYwGth91B}54T>3(D>1F>8qDXjt^Tsl6S+^B zlB+(Mw7Z`k?+?CT9aqnI`g;yWxR92lyKSu79m!5*Ioba_hj_$K+7%ABqV@7g z@8Q#H@$;5K3XpPa+G77xY2Ig}D@6=Y22uo-xoa4>^c1RuuA@A_Ev3R$$mvT8#Nnq> zpfKi+xbO|&brqiE&Mz*|QACv@=2_zDGI1kq&nEYez{LK^H}Tt_oB6zxhNout68^#y zTe_lx(5iC}?^Wxx2VE}^T}@~;h_-~`XiO#>D5e2g35KSy@=~!FglsRIDBXUyH(sWJCD(kb#y^Csq(#(61YzTBS0KyH(-n z=%`#mRn6|d8j-D#t5LUOS3rm$r8cHeB?42ic2zM-*dU%2FkLYI&rOwg5MM_d(JvAd z)mx21_98`{s@dx8>~UL-yA2KQl6Sje(Jc|3v&-uDCQ)D1j$Rx7A`jUBwKAXXMfAQZ z49MQhkXUKZg-HeNWM#_0Ue3!~{DxR@$VgGWL$3nZng_@DX{DVLdq9oxB`Fp^MBZ=4 z`f6-9wHM0h0I}bDRq5jGw zb2FMR5h8>T%KQ$XPf7v|p%_p()yk1s?Z=C6&rB1eja72jKMtd4&Yy8?$mV!6&@KK8Xt zF@u(`*Ch(4Fm5-BqD&4NFPoWsy0Pedh&hguqUmvzM4P^h`4#{`2+i=F)Q+ME+{2%x zrZjI)^dEiGB6Xj#!$=9Lr#@qQ#q|C=RTqQV_3bv(Ctut6#Gd$HF;D*HUmJHCY8Q=I zdzYfx%$?!Lz&8cQ$8A~a#&y`#5`S9yH-tY7LqCtE+ ztJ?DY@w14Ehym9FS5HkpKr;|B*<5)TeJ|^k*lN5UW9b&I`pSL}c!C~eC5H}HmjX(M zvnl=s0AFb~z*$$SDEXB8Rbv3yQy7RTeFgrbNQO#mT#kfh?~mBuT%b)DzySOpxWwEC zewuaL8%`!Iuava0ZHS2E>K{Mq%385*-P$>Ibll{fWi(X&BL$sX&@Xd;?=FzPlBu8? zpN)93%;b~ECjZ5gZHzczqkZ+2*CeB~SDTaiNYlnVDCcp+TYS;G^-CdYh<=G}frS|g z)bI)e6$&sDv0)q4P%|EWTj$1eF6@1Izs%HHW|qfXszvmWK&uBNOr!#V*btB)tmgA@ z_NKB6SRwUQ+&>XcJ0Jqcc=6F^Q?O|T<9MAhq?-PTd|6nTcx|m!gG7xVCA)k2zo&<>pW|UMkdT1XVvop78gBindHQ{Gq8h zYGnwi_3Z>`C61w6hZD-WcJ1_^D2d%0#%O$EhnOsEU$;{MU z;^rH4_F_*pLG&^B%;&17!Y3P6bd62#orJ;MUOcE8lM9iWuvg6*evSuAaIBvy-|*5N zxg_TF2MCjOW4?A+h_)}}XcQnPD_F*$f&3t8EeA6mptwD)+vZJ^hPE zWZOMe6&n<9@`^JG*^q=MUI)5T|TYj|34}xWjgIF{;kJD#4HCBD05G4C| z=b6RS`qF>X#}TJM`PBw-%i;ViouewWIwJx$YOiqQ+Vjzv?xB1CY*cGlIfr8M{u=q& zZ1sGZ^rlF!bxEi`i&)s&ygn#-FzD*p%znx0GucqaYIfEUZ<+V?Uc*=zc}ZS@9%kx< z(-aoDkFSoqWg!nK`q!}5fbOe-?N?`}wBnHF9G2IE2nWO$Am8!qt2jw9g>v54S-?N# zoIcpCVc}bWy`26ul|p|k8XY`SHpxW+Ns^}X*l^n|k7%s&f{5BEY0&kvc7x(OstTFaRa0m1ZCCK925e3XEOZ0cYu0#FKbszx=Z?fn;Mrndgl$%U5xYXoF(nk&sf^k0Cjs5 zg|iXg7e}5_W;}?zqya0sL>eP=URkq%g5gXsP=QLXi@W`&HO!*m{Og)}iR-9jdfhyO z-&*Xw+uu^dqYI8_Ev$fY`q3k1 zjw8>6%G%AGo~)Avk|_1E-@9S_TMC6B935&`-)re!__t8$?)Kp}6%abR6Q1e4(7;El zk-8C6kaE}iX2RSX-?`cSWxHpl)Z036{9a+Dw`H>KXlr&E9VeuwFDN>kY=V<-bizH9 zLY$5faQD-$in;z*>HI6NTw~!<_BeL_snZ-`1p-p=2K2$?WS#%VJC_@bWj6EkwwxNV0<(T=@~F!*hk%mN z_xUTYH3jSGK=Jgz*+UF*xjVD!r!|6y%DSyI4yl*Tg5GbbUI@75un>W|(jXn> zAbE|kUsf&O$q`Qm%4{&CXs0uMQp1$&=?%6KQuexypH6h;-_<;{;7f8J%Kr_+Wp#hj zE&lQQB4Ol_tx>2AScnUjq63B$jir{o)m43+Uq%c&7*)LnZP;e1C?6TBH@T!}d{Mb> z3hecy-o0_L76Qo~Q1*Qi5>E^xo5ADM`9wX!!4_=D+NZqjJ7VHQ{4oE7 zD_N$ZSL(%>RA;Z0A95}LDakpd=6&M*!Z*anRJG>XvWUhqnzK6h!23=m=ZC#_HW+dp zfilcqneuswT^B8}{V+8h5Y2^#bD{fx%SRqZPHl^wi{#f*%e4+kIA;XfN-XYV%r`Pg-DV74|U!j^s*c-R@3%Cs)?(EOJ z)NiCjhnQ*mOx^jES#J?eh=zPLWjQeW7OO>Zp>^qaKJALLAx9S%UxP=lF-{ zYqMmMoc6t6ecK zUtXJx>XY#gm3#!0`dp{UQgRG>J{W*R_S%(m_>gZ--C4R_`lsDtR6%-+PwpCCvoA)P zJulzX3k$si*sIl2(1Gpen3XoREjko)N{D6~I>Zefo#UM{_AZ*M5!WV^19c9@4tJWR z-D^|v3DtQWIU$?WFSge!`)=NFkMRP515&JaS-MJ|iara9W}b@KJ1WKji$;BmLVUN! zF4^R%qmDFL>(?kM^`R}e&hdRAVrtDeR>*`&ge5^qA&PG0e9CGck}%6wh{hqKnKHcn zz=Wrf1{eD-F;HfkTjNMgXlUd+08t?iVxj(B}pvUF)414-*ah-z-}_guc>v ztKOu#g*qL8V?s=YajxWdQOn|x6?>+?8d zYp=MOh~&N@#35%({>8jC{IzJ;3t}9nn5hlt&D*{&Si4As$F0GPN8nGjFVG@2d>tq^ z2ORpWlqT!iqL5CV>05_j$va+7*9t@04x%?}<+}Exyn)i(dAWhkXWk@3=?(lb06d(I zsA3mXk&smYMAYRg*pMsPja3nM!at;U6537}^ap3y{vN@oXxpEB^rGv?^WT~QlXlzzvzp-=k^*!6e7l#QUXafZl$Oxy;8qNKB>xBMCp+Q9wT0y;? ze>7n520@`dlHcd)jfNfJYmmA}GE}kx7Cu0O>U37Nk|{-G35!Y5p+JMLVzBx*qA%XS zdC1G_6i?hh0%bnYK4(%Ydj3=qJ}Lt*edM6b{Q7xyq-~&{-tGBh_vg~{?f(5A9e(7h zQ9$Z#dKXL#ot*ASUt>7Ukfnn>(3|seOSN)O7|!y=%Au}7ym`apB=oyp170m(EFEDx zh$0%mJK3mnB;;0b2Le%5x3aK0ptz|NVtk1TE(?3~>Ex)cyM)bo7w{|Q>KOa&$J{*g za&L>=?8Cla^jXkI9jNB51IxF};w1Fz!DU=(o==1(Zyw(CQ$D#@x;0QHx!2dfR)(iKVVxr~ z8;+I4R%Do0X2jIOmJ?zAWZ(bF?H>oq4AsiK2+T_G!F}j@AO8IOY?!DIUOa04ed+w^ zc2(5-&pGc4$~>RPSsunc>B}|0i)$5ecD#!lotOVimcok3Z;<8Kc$s*Hq;H@|bO}IC z45;9Z%Vt7l=AT1Sr7{6#&o-sL)(WN8vi8XQN@-HLxR5V;#J2o}L*;b$sSwvR<@@*f zA5^4vRJdI(2Yl~Wt4{;_;YO_h83-kB`!1~KzxAb*BJ*X~8%_2|V zUQ!irv%{UM75B|)E0%En@J(!5L(bID;hUQy%Tu#yKy5fkW?C&SHLTWwE2a359?a!ZYI$3$zBq%RyAP2a`0$Tv|FiXEt5VCtce!;eD z5C8A;8D<~Y&-Y3`$u3WfA|{`T;}jPxJsgKotNk4z(}+8~B1zH3I};+4NOGwCkVQZITtOnsDu?prJ0t?qdcjb5;QY^99FF+~@&nxIxL{7p?2 zt|Q*XVOISk4W~l{?~P24oGKJEZZvU!SMhW)^K@i3P>EKq#soKIk1QEld=&4ZuL7({ z`Vt2WA;D%1#}6xg-qizK@v^s6f0gHLPxd0Ls7r|Rc#D>fs__zQSN-Ux>VLy#2@!+1 z_ET(_fl+$Jd`+!>`k>_9!5Lpuk#y^L`;>RmPmbW|BKEg|>1BR6+oY^GpSp^88P6Vv zSRgYiDRi~q&g2n^bAdLed&^CVmInPeGaiLZ6#U61PF+qF`~J7wP;L%aPPTCLh5kawss zhaQHnM`(cT!x4X#yxWzUs)~-|47oM3^Vqz!Z?6!H%=ejguHl(5MGEMnz&V0@gp`H* z(2xN%PzRTL*%?i*H0YM=KodP7ouUWg#ZA%D$ZFi=w|k3^1I|n0iG6I zv&b7x`yg%_R_qqxT_?r8aQ4H6lVa(ih%O64_N+C?;)>Zd<+qYbUy{wC)818c_F~$n zcDrADH8#Aepww zxSyZn;oH)XI$5Q_UWIwpSfFS;Pa+@3k=+RMq3NDuLPr4nnzj2*Vg4W*vgs2v)e;)c zOu-Ob7lN1Zqg z&Ff5`Q_*82eqSPwV=3)}?Xwigq|RGP=8{s;`;haZEQQ@o20}S$)e6bW#-(N{vsqT| zJT6hOz{)kxRS^Y^@LLO-2!1z^VV??~BoI|{Prgj5 zmK^an33;UxS)hBv)8o*sNnf9T^^JjEwUc~)!IF)yjLQB&-|m-vmW&#Fs*Ibay+?qr z=oG$h2n$JEICzD2!D}01wlf)b$b7YP--x>Iv*D`L^mY8L?#^Sky)9t= zcgY%}|EK%-WpiHl=4JCP>ha5_f0sUIi;hyQ=PXB!4gC{1%5xyBHpxyQ`MLf0D)jgBoKTbH*?|RXx8`xjLh1107i)}pNwJt{cgEfn>)^cGjjo8 zVEqgFSwSMF*Agne`}(}Jj}JM8=eNWeFsdCWf=PL@$q1R!{b;EH$EWy`A*b72z%svv zf;24UF7_*ML`qKnFn6bTztCMENqllERy z;nfJ0bq2P^4b^%2cNpqdzqiy%CehD5Xn|jQvU|>ubmmuV&owBY2%ttRS|X@M%tem@ zmT}=G9Tz?@x1X>VGN64pzwx;G&IPfZzmNO)tZ~Qe z^f{=>$oxfi0dRioGo4G-%&5RG}^Hb?h_S#d$cuRDc zIHzkg}S7P9??;eO3cZ6*-@5cx+)2j^KXp!}s|+65OR zSU`zX6rAuGRNW?4{d-L($C2%6oeDM2l~VOLB}SbKO9bo-!w%vY8;up|wI|3KQ%N4~ z&OYS>&fhXgMYHaJO|cgLK^e|khFWw?t_M5l8jA~6$NUtK9dgi&W0V+nY9Dy?dzosQ zjv*E72wRUWYyFOFB1`K_SemEFmY8Jf_Ay`wmg+=jNY6~7@A8#?wc<(78D$EUv1o8G7 zDSeg!A6}~%&X9=uB;l$`I>cqiF0d%)?p_heA#bqqncPeK3C9u}B9^gntprk9h)<8$ z7_2TF6Y-;W9rP>~3@-^PNL$&|I8akuOW4n~?^^G-qC(^VZg9QbMZN@ye%u-pIlg;b z{4v$pZ=tbIp$;M$eZozS8$W4hNOR}3jJ<~V1H+4ELeGP|CpbF81Jjja& z!%#Vd?oXARk@pdM_j=CbY5gMnu24v+{EuFAz-s+61BY|U9NSR&s!&>!ZRE6LH+B&KZ_Tfd z$SU&1^t>spPM4JQtST{N#vBS*QxIbjGY)e0_1k>%eO}==%h5FfP3~FD*|vUZN-whX zU21pX@7iO#eN?jfudKYHw&V1Tch*N%R2IW+dFD&&d|`A&!T4~fZbwnUY347vg>Ja` zkA1m~eMbNG*@E`aO^^n)U#zT&BDiE!T{BK=q%aw>|H4f*M0yiRpME$&k?EhN+d+jC z2RBjU>##UAb1@5T4B z+P^2-ZH5{T?p`KdkA4u;W$W>f92!Ehk$$uTKY%6YxMLUOqew`poqU*HD^NLUNY{}a zovB(g&qqgWS*8P3AF4mpeC@U1Pv;>HJY8Gk1URAm9tw7`cNL{7_MVCH7?)Y~it?-7 zwllnaW&-_}e7!wi%Tv*-e^=lmqC`sq>q&p@mSum$w`(gRP6Na$M;mBQ(ch&`p6}m<|Mw zIMQ_S`>d+U)e9HI}F80f}&r(-x3LLd8K;6?|}wXd@35Mj9tI%P#Pq3ojPdgNk$ynWvmdM zgB_<>u%s?n_!=yD4aO_vL9GwR+hxT+jut+nEj~0T9?(d^+>%M|lnQyRZJGl&EvS&a92UqiI5oXtx&+diXJn|AYXZ`=me(`3ftH_i3MrqxM5K|$9?I5qE}FJPbqD~ccp z{5m8rsI4ui2sB_E7n?Z!D@RybkStYy-k|red!w$RLUcBQ$5b2FF3AwG(^1e$1)Gz= z0Xafc4wyQ&?5LE}ju1JV8V25Yz6Rt$xvQvd7kg$80nnJeIh1OwY?y5GnF{Ons!U#-Z2r z^(~7zP$0HZZhky4Cm#DPHSlBY)yRzE%_1!Yv1{`}niA3bdq+TmS@LyT;KvNH%J(Xi zA(-@2n35wbrK??3X=$1%1ZWmk9>*)jo&uwIAu?mS^1>Qp2b6Ffpt&rt3%88+K#0mr zv>X$;iDPu*7&oEl_&?T68)QcYf(}6Z4LizXRuG>dqgFx<15rZ&fgx-P>5o8aA>!Ja zz%?d%0>Hndiio1atncT)kM(c)6ccML9#`U8_FLdFm(J`!8|$GnCM3rTQ7z1>{t(7U zA({t3)&s!0*?^s<%i2s^%?$AKF^D70_>~~39c*uuq~2kD^CigCcrBeOTGT5nuT<{& zTGDK_-TuR-FnB{wcP?seJPIl-zoK~h+f)<;0K^l6ihXZ&mJ-Wzg-RW7VVSqE06aR- z?7da8M~=23m?S%4DP^TFWyd3H)G0WrfQ&fWMcu*a-3o^#HGq>sE*#jEG8fN6=qN1~ zpLOx3ekfrqwy&^NQ7SaojgP5}X$ANtMIXJFA(Xw}P)OR}ASf0)R3i(m0Y^6zkz?H3 zB^=apMEyQGv7U=rV5a{1i2U*gb{v3U#wH}DS=$9)IR{i#xOXSHr{J9r-@jQtRy2x- z<6k8vZejWFvCznB^oL6R48eUV>N1ZD=$fh4CP5y|z_wZZg!!~gx{Mgh|&Q7ZqLiE;M-4j zf*R-!JSvhxQu{(vppBngNB+a^{D?8EV8**oP5rf&z1*} z6#FzKYz)nQ)KB`)m4(gQ|**^ph)Bq*-ClYjkBoQD$cL(new}=1{ ztl7Bwt0$XJf%Qay->vs)z@2$H5xn5TE#SamVJStI;R8p^!NKW1*;95*@ig7Zx4OkY z@ftx22V=pY1~czWAqOUOl@{yae*ZpN^aGcyJsE3vvwR5Z%VMMUeZXsnkG8LdKEe*3 zTtPqLqF=N4)&K&V>_KN?w0k6GIy!~Nz5NWwzd%Ig0{9o0XiHVZxixqy9lj4SR201< zYA+g>Dsh%<7^^G(0{3ii`O5n>KSKNDi^A4yA~KiAceRKA7xm5EKAw$VFj^UFMgRif zbg&j1MB&Idl28rQllNiXLA2+SIQUg(zHoo!^}8l+2vdCC6D2iS4f+cm_rO1?8Vp$n zsS}>KHGzCOGLDYSdo^8&?YPW0LzuTMF@IV1PoO*U<%8B4CnmV&)eMjG@^K9jnTE%6 zfQ_;sUE1vTxY^bMJYfv@=77t~cG*73GYECGLU zD5Bh*TNtme%!p%gCpEH_3Zkyna z2deX5qvv&oPw;zcrI4r3?e{LM9G zqmf2Ts522_a0r4ihEr%TJsQjy=ka&hR$G6@&}}Jt%eK5=i7-DM%AGFwC*Uf#T;Q_o z#lKuhpUyL1_8eVu9Yy4t!)bd0aWvSYf5KJ@E@KtVmElTP?6V&ifS(o!8Ihn40Qm1{ zl?$f6_+R;!F+BtzI=7ZjO}v6dN0BfCH6&ysscN0Y_Zo|SLc$R6XnYywCqUpi?!y3E zk5G&HfEud)n<@t7&s{?;0QiGP@=}a>4=KJL6R*HGwi;r^LSx>`|dq!aecYYQ6Mx8~p)@F0gtof33f19rCpov1*- z={4A~(c(b`bc(iOF(?kO6v_rU6@u<3&+tglsPvh8yS5IOmOAo{E5RWzD9rn}@f{j{ z>&XiE8j#hRjKmq?#3%t{biU&Z;pF`g#wcccwo2A5~-dd-`E@x zQ#$&-1ArOYC%Y1VvT;20ubU{hUGxW4{yXf!i8Vg0Z2m4Ta&!$<9Sx1!KAPYSXP2S+ zp$F`m58TYNE?W=(!zUtPRIq|IWUIZ$%C@(FRvgeZJGr5ZstiE z=m&uenLqk}PRM}e&@UvVQ4d;RE6X2}u@{|VE zBfY?1HhIe3$JEc(7TsS|KIPFs(|)(}RJp(C{+r4mhjs4onF+Q_+TagmZJ*tK4fDTf zG4<&11}mQ7{Uk95-`zRBC$~k8Ugq4WBd<%1!hb@`L6zu{6ROBpu8_IuXw0Y!uo6zS&diTzLydJ-*_xZ~4Py!v; zIT?G>ZT?+stMPng-ek8;-dr2O?!h9z&amF{r3|p16j$dSj@r`XW@p2lbhD9=5GTjE3 zb1%*`5fn=(t1DQ-^c3u>i>?5$(@fkcQL;Gb)5HY zYH@Bt%pLKw<2iR~mF{&!_8#E{g2ausCARO%JMTT9#Elximf3tE@jFX!%WkLlmp`v0|e;)NepF)1CeC<5H@kbx5;e!Q+wE-0W?lU7BQcYBZ z9Oe_Ng{1KWdyf?WBE?AJm0E%D_c7spSQ*^pW8_iW^bBkM*CL_E-PIN|#C@pb!6hG9 zoxP}r7e!Q5JM#c+6{&>3rAV=IFTS)T5KUi{``2v;xjB#Yr7zk)<3J_4*;)1bt?mhc zf%1KBU$FY3s@hlEDrb=cEf;POsAIMspFPW@BZ5`ccHy4gpi)coMcHRmG7pI50$>;b zxKP3CL(NX5)4y*XXD5aeTX&iW=bEEV8Oz)V8d)lTH1$V_O)nFB=oCn5wxIuncg1TFsG8{RG;}c17?I6=Ji6>Cz)QNNV&@ z_R`IM-Lv&lo`OZ$xpf%ES{;Fv4b=*o&)ZuMqU-74@Tu_)my;pY<;Iz%dkp>yTh?;i z9hlLj`J8Og8^XL*km^u+uGl4(xQc{OaJ9npzgm zT05Eg2d)tTperi0gS{HU-D;W!duG0QTtd3q-5E~(um@?G4Dif5H65_FpdYnj0@x;NPzS|fO?5WMK= z#$lKS{k%k9Qj7&iKH{E87fe@b!3#^8PHQmEDG`;~-oFJTD!KA)8*eYz(};MQmeteo zR7H1I+L1jlhMRsY!^pFs9;X-~B?zPHx!0v659_r1*z&rTkjDuJ!ym3?gXE{#b$Zc` zyl}Y5tYB|Sa=-BP)fMRFF}S8r5cc%Gm;l@7Ivm1UE3$T;?OrTaVbnaYloHI&PVPfr z=lqcOI})Y!`Myuf?xN)E8by%D7$COU9Ziw;6S+I>rtUsg@AFmZ{cMmspvKel=D7VL zXaD-vtPD1|`Xjl*9)R5iL+Xn$Fc^v9=>~|B^tolb`_i*Rvb;Ahgs><% z8XtNo2+wz*U_o>uX&kKYo�jQ56v)-z;Zu3i6e2N=rF>Bv`D&oF3KkhtUTr2PU8H zt$RyC7=v#M162a`d?g@2l)9AZC;RIs#9OEJ4)0?|SHy0#zq~#2cDVRM3IUfd^(pE< z;zPO1vo7^w8b3tUz*n5h#JzHlYTX%Y5+i0t07}pLF?ccDZG)&M&%Eoyu+g+pBWWOp zX#kF$rMx{f~_K+FG~mJWyko4f{b5lacqYVW<~ndj zzs!Lq_dAhOC2c0jx)_}ml0H_OUbVG7#bC0bCNkzio2ibvn zDl>gz_1&A6+!_)}7U{rqeG%uz9#e|nJ1$ktO_kmt!mRp*u?5+Kx}IG}MK7GfmBSo4 z(l6iHr0}JXW(GB>y{Kznx8)zb623L$4&HxoK*W{Rb3yF;V(jH9v2Udz5WAD^KN}w% zB%W~VYWos!IMi!RR`k9=;lLFkT;DmnPnKJIzkVCc-FP10bx(TaWQ@nqlj6<941uh` z;gbXXx>JbkH{ZVVcRPZwXuY6VGa0zM32j#@v@bpQ2$BGd9D8@y`7UbfG7ZUi-tKB8 zBvT~N-}mDM>M4|3bF?-qEn9q;m{n1`j`1UGN}50I)~vx_I@x+$_E&eNZ8Hz$p8rlb zv#{S1qm43Y*_D3`;Tc1E8T@D2elizuqEt&u-ecz=9WYH7HvLA3uI}~L$nw#3wOz)av z1CNUes}mt~`c84rwtLOh*`B~TgQwv7zXBYav!Bc0}r?Nsn+x*)XQo4-A@5lrrf%ga^gnn3{Z@; zUxrs<5Dr5AhR;nPyg7x&BZWt~xpv3Zhotlu7N94mVOoFT$101;dW~Ge;rlex1Wr~R zD=P&7i{&9Q?IyDiOF-R|gxn=E9PODe09gTl;4yTj+jP2HIJ^=KCEG)sNDwDB_z(@T zl&4ZMukj(b)3#^Oelb@Uk67|;Jeu^-pJ{SPm;I7jE4q*oww9iy3IG!5plrH`4`ZJ` zccN2dpH{ix!&sx|KcnZ*Ax!7!c@nF9F`We|g?Vwo0{1qniyakp+|kF+KRYH|rrVc4 zI-Rd%b*sj=3npy`xs;F2N+-?hAz|EwHz#Vq8PGaF<-I*WHLpM&| zEuj}v^Kq`L5E~ZsYIqO@_~g+B7pYj(mI0?qSfuAx1P)$q=6Ny>u!OEN5J-U*s3#f#~IlK7@Gu)E4lD< z0Lew|K$8BwwS(KU_ds(d&r6+mHhK}lES_q8(8;cpQkjVAo)K2piL_yEFaT)U6>*a6 z(aX|Q)#hty|M-=jBlBFn)hemKUA3W4r{S7#wCI<3pd<$&+ufJK;{e~p0kuUW3c||~ zpBO3Osrv6^t()Lj!J)RhE-Z?GYQtoH?0?Y~4|9ds#E{?!t9`U(hPekstsUyx&2+&+$P!@9!k4~YDNxQk%E`@yU3_gnJs z$C-xh!e?ZfgLKI+lK{^n@7#B|H*^rSld1)!0U#0+`Wg0^q^{AI`IXHWObaIeE3x1! zz-KJGC6=+?+9%0A0>xsNWwh08K-c$F+MPaI%+A1v=rO=S5h~| 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 z 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

    ?; zw(xS~YawjkDO`e}cnhDEt+S-Ae7hIrLQ%#2>c06|-WYEtq=)OpA~)X=9+$`1r){y4 z-M!Q6x1QUf9y7TegoK&5C)9e*=HcNDc!YIO75X)t%02{aPX>mDq=W#(Scd~Sh$;#I z!$S)Q5Q93y=1?(2W**{kgQys`+WMwJjpM@tvLW{=N6r{$Qofl=~j9_x>7Rs=d)6J|cB@OVWuc zx?{opn*bkOH@;^fX(S7Q#`icePlRK*_fYWQ4ai0IYipGMPL4nWk$;QH_lwELA&wuK zdL@7g|Ah7LJ?M?EQ9=L}kk7}x1Po4K-~7Z^fa7n8PSbA`#tQzqHJ!=L3P>AB>(`h_ zTX@hf;M-QX%+1Q&fB=^tqlA8-4Di#uL~VqmryDc>Hl*|M1*R3WXEvz`;2;u=_A~;W zg@l*8OJo<@v;cr;C??i(#l|}4G2nN;hnN}Y9?rzT^Xr8QXtxG@Lku$5K~&xo;z(!% zJ0Z3SR%c3gS|ldCC?2h|NZ-zE|F=01|9PqV0w+3~8ZG8Oe9bP^=I&h$ga$F8;cWgO zq9m4}SxDpmgXYf!z&!A4)+lHI8{#gj8O`LojcRL>M*IP%IHRB*bajDi8o`15QQzCd zg;T$Leb8ln>+B;-E8&K;iy{t_^~c(Eq&Y4+orD<%*DcIDCx-xVVQgvZJ5n_7K$O0P zl8`OrooIl*ap|r17r5DB!k7Q@r=qp|mf*)c0oUA&C_JhTdBgR&U&X_1>?BopKYi{# zT$26Pfh5DIuA$_gfYRLv!`HLN9Zf=h0HA#gmef(7wgI7?Nhovz{)-7Up+6srlNh2w15hw% z&`uBo-i_f7&EZc(K^o1q>f(4Vvg;r~*ulROpNQiT7q`R(_+@{l39j>=_fCVlGd)pX1d$tq*(R{N`Mn;J2S*yajd;F?iQ z5HERVV#L+hKTZ>~5Qke+JODcgk~N%2+a~nn&?Ez1G_(0R*fA(aZU6TJvc(wBF`4}@ zzx5(uTqjQ}bRhLkk-0f4&-Rmn{gj(}iTml3Rv}{A0rkccI^e?+1qXwprtQ8*P}Q6w zuEwvnm9_`{tJ}2rB2}j9!3e#%4I6uT^vpQXr%z@4irLT$nG-^G%c80|oVGPn+vGC_lcHYjC-~UOrpJ&IMQ@PQ-1uEOxP-%(RW;AezvN166V&h(tkOeIp2-U@rs*b1;M!@_E zYz9!7)5FTSCLBiu!ZRK*4u=BqU=oiwaoez3dZPdYzmQW1f)p!EwQ?UNjJ8+f#e-lX ziJ0C3Reh7MNV(oWL$|v&o)Jjb2SGhxVN;66ovb_C@*2;2jYD|jOpuLScTBhyiw%zY zfjfJ~DW}TJmN$BJ6rZ3RbPSDilRA3qDNVA}j^9di&K3*zg@UTik34#p^7Pw%wm#_1 zaW?JXFgD^x^eX^=f>38r>PzH24DE40e?LHKocrAz7Uii zdG+jC<$u=_zo&nCkbWsWyi$*=`IMsB2Bw2{I5JZbGL+S3fvoPt4+=?WYBB&kdV=&m zOPV(tU}Y@s8b4Sn7Cyfw$1jTq0Icw^AqhVoti-yeWkRl~p)P$AWfdcz zw&p_dY%X*8x7CkI?Lwd_A!b{;xz`qx)K#eM;{?b0Zdf7EC;$|6`{vRWtfq_tucM+m zbh3IQ_dk>L8&hg^$x}T)t{rS2e?4`3^cT>7kCpdj8&} zkg-nwz{mle3=~A^8N_+UyQ-$@jC8ax9Mk^g(9miX{)WJpcZjl>YaL^RvND9@A-g!bho^o__*x&>0Is zr;aQ3Tr5D;5tiJ{v+H0CgN&ahg4}h92H5S!xG)?^f{9fy)B(#W3;?-Ph}OQrjyz}< zvKt3M{NHsPa2|*Y8nKh619DuafUb5BV+Os7CuKy_6^OM=pYNkosjFjrMqRb9OpwV) zh>%uo9ow?{nm4uET|8uJ>u2MKL^=kha{_agVfGBNrKA<_*uRmDHZVG%hzG*-^7?pNLq7O0bun z9av93_eV2Fll1SJJ`|{`6!TZlhjT#@k4tv~p9lRHPgFF-#8@N^5 z3mNH?b|U&Mm8#Fa_2y72_}OE#Yy05g-S2}(3Sv&onT7{41jw3G;s%ldKohj{N7pG1fdTw|x zI#|Ff==*t_5J4v5n>Clbvn|bVOyC$aDfy$G(o#8z11L#}vyD@(+pT|qXSG8zxib+% z=8aoqRm+vdq&FGY9-_G7NZ?C%J&k@`iP?%8zCPNc)D0%~nC6j7CE13v$_#EEHSz^6?S018yis?%B z8;qm1s}zLz=ZK=|(XwA^F8!ua(}iGfbGfKbsTn<-dg9YOtT;qpTh0VP0&fm56JA!M zZ<|GsEcW0b6%hLlh!X^kE?sazq3<%)3WKAC_4`zE}Z^VdjN0 z5dnMf0L(Cx`+4LcRf{V>t_F7j0WY`;OCiFkEZhYEykrTNLdehBz?CYEP&I8Y5cA`V z522i}l8TYbp|%%{;Z@zkhf3xuciXak+_iq&+NF^yf7_HtBj*8j&7rnect)~l!!%O< zg&Nxw#7GJy|0XH_3{rWv^m+FvM`_QyB~o5@i#hs{*}TZ^#7mleWGdyPBR7a$xT|8n zI!eMe?fz`QEG`pKUKCN6P+4sn!LXN2^^2x|mZjDCkITh5hl6tL9W1 ziS!#DZOL#=vF5yTXk`o@!GKXIun9MdthJ%A zjiDd=PMa0uS(x#y#PP!J;lh60PXF-kKKxhAo5PF$Lg96g(JXJfYx*NC?Y0fioG*7* zW>NCfi1}$0IM9G&OYN|OoMb9|$#Y!f{g5m6H-+~>`$@YfS-ZC8h{t(+TDo`i`Q-YV zKO*?q5q#0?7s;jdtPVcm`$p8biMMQ6%mnFEU){?bu45Xq8x!_B^!5u|WFwTT!G{4; z0K`y8JY*W7JVPP%IK-|&Vl%9raCt}8NibVz^qNC80COa>z?VHZ)$AA_I&?t^mY#?V zrVkn94V{iFnCT{k4U@k##z(xEy-cvzz5vMD_tGhoeQ&40trp5mgp!=uQ{v3^Ot@XK9In@PHwRm&p z$Itrsp!O$Ymo_maemK2BQ(BhsWR^E=*#BinA`6DT`_D@wkAKw%%zx->eR?8i$ z>~3)q{^yx(EyCT>Eq?%l6<%h15D)9>aE z#$!-#mhUeVk_zj;Ekq#TAQ`5s7QVc@67&tXih9M4@Kwp>%e-f_c^(-Pq%0)!VqvS( zm8NQi^F@u7VnQ5%O^`R?)@&0&xnPh_(LR09zU!9uC7OsD#3|!z*KP> zaRh8K{5CGY6+@U*sf18FNWN-$$(4g+-CkS|Tw|JDkGyl`c7kTkEa~Q9lKTC5LmgAD z+5{8G@0VZ`znrLhuOr0Cz9^rs!p~){2P}S@s?b`H_#V{mvmk#lU@j@3x@JmwH(;U< z_9ey#n}oDDAY%_;Km0u|wJ!s-ruj0N)#!=ohD+((Rs1po&Z|_`mxn5+thPH~TS=mR zR~EP9lz(i%?6>W$i}^msAw!>wQT)WjUq4s*Kz-a@{{vZ%70)%=6H}OZ3!9|`DtQG} zkYJ^djF~~8>EZLOaW7lP_FDtKT2Dc|wKI{3ilM1GM=BvG|MlUCF=r)Wo}AvlBK?AQ zW<5x;{#}X4a|u{*z}adRG;Hy{u8*VVcll)4<#n76whof!0-d&K8=q+~JV!+SQ&GNXi1@u+)-|3dbqVN>Ho#c=+B>)-)v+K&J67_@)H4Zw+0R4U=?QC^=Y{@qAbJf+``pdiBmG zoy_%?NjDaAnNJO4|s&SeZ&fids(@9XN*7tWWa2i)EZy z?q2uX-3)|9>1++nh(=u|$I8^l%AXZO2d*}bkB3@+u8$pY?FhV7vD^v`>Ees-HCYSW&k~ktxdMTESU9(tpdg@aKrBe62WhL$#^-Y_7^0~C2TpzF2 z^<>>M)MC;Uk3xh^CK*^yUh#@>SQ>s15wrgzdU_Jz&RL4>_ZoplUTA&|O)i+*kD+2V zr>(vrj|DAUL|%o3abJ5k$G3WUaFhSdI4Ylu{}cQ0`?r}+S27FE$BhKnKwDLqpgMSJ z&9V@$XrlbYPi8ZJ=9g!JnnE)O7vhU`l-VNVbKuM?@0?Fj>V7X1T{c;w>&_` z>vm3;Bno8($JDIH4U;jBi@wB#<<4r56hxwpjbKTJ!dhTjb74gS43FWT0UkpC7h{4IJhXr}&#D9`I|6pl(M;GR1&Mdw7<}9{Vc-no19My`s z<%jv?tpDhKLcyo1w_*W&o)m}+6Y>@X^}mty5q9qL2;M7AG&CuER)Wu~Q29?BpT~aK zeNijlA4-PbUFOlzoAUJ;O)VdNE?~q-@~^>z{%LV}1cQ85Ld#1^eOg2_Au+k&j4+$J z%;JC@U(e2EpB$39K7_eplb$%A=a?cn`&V` z+QI!-PDVeVvNpo{3Pq&mzwR<3Cazfho%{Rn9Q8RIqyhVST8}VxMdyUm)%Npip*P&_ zo;@(xU3ylFR4s~?dJyv%N|qYFBg*L@fmaONjdwm^3N;kF+6rev9#(0bco3Ru-)Pl~ zpDEH7JvdcLoMpRDyJKEAPvjnIBKUG0sEQ7@@_EhU&YyYKsm|tkJ1#blDLG}Im_UX3^bxjmcRjU5Hr6SFGzQW7SnCJL3%6>Y@awbddre%e_ zK&<{mb}wCtV51i1&g(4c!hyu6c9u;?ddXfo5b`p5i{GG8C&FgVm zh(|!wO(?~e1K9QqNMHZ;4MQbKd8_2`lIJlk=;L^`%MOp{gKtLoj9S+$JZ-VQmngAl z-co6JJ@SooxXR`J3SAz(`}dEd8Xnq8O2 znUjX7NYIp9>QNTr4Mpl-6h{O3c0(g z<0hT7KvpsMqes^ju;^v|a@;B2?ig>(?@;%9mcQ&=jXqmOPusk)Ec5h>d){(8!2J9D zChHtqg|jMop}IAPsbS8Dt;$W|aoZ5G+hAyDi{^_K_4`bX$7ianUR(|*oUM?=z6O5% zh`lGQH+D>vdyzdfIKBJIygSles*xx-R~+A!m2u*`;<@U&q4+LoC&RDWxhNm2_zD5* zHpxeFIE^#zGnONq{ivTH&k$DjG&gS7+OCu2Hh#ioqjAyjK5o*T)o0vz$2tl8{+?CO zn+69?@fmlU4nWzVTRU>cQ}IDdwT1qwjik=xG6G!il5QU-`TH$Ft*>A(puqNWkJyVS zfg)zy_0t{dQ<0-2>EH^o1N`Q|%YR$kvyaD}(dja=!8n?k%;%uY#-Q&Btxs*X_*|bV z7Nz`ik!96=(EA+8!DU;V(J$NqS*ht%3d*_j)+U9TshRj5_0}C|gK#TtZCxTKsF?rW zB6&t}+un}KGZ2@_^TOX_Qts?tN)3(m0ZY86z!c=^JS~b>#Ua@FZL%p(0-C5s41^9Dab)RDjPRR%gB`dw+YwB05mxmn_na96rYyMAm)c2?Gk(9R}2w#Z|pp8->#hTddp`G+drx1p>dm@r~@6eZYfkJ_Ra3ubPy-m%QAp zd=O%rn_CvlccXMb>$`5fmX*el@Iw6A4tu4{L2caLv8)4)%COI3Y6n(zi}QjZa^)*L zjyE_rf+6=F-gkf3C+*q#e!I#rwQJn@{Eb?*1^er%=pR%& zRW;u{wWDQ48l(9|)|~||#+D7R*HdA3rE>NutMVo7l`84)Zg`D7d2vE^AjLA3nPw{@ z_zP<#^w-}0!R~*eoOLJ>8>|$lyJggiQP%-y&*kaWPV|c0v8!yTuC57{7%zTM(IHR{ zw-+j!i(0#XQ_DJB--u^2w%JgayRA;tT5Vp$D~Z%qa;fO=6Et_>Lr%)nw~LZDmu_oH zUm3MFj+!V;(f?&GXuE3P>LcoV5{KPCj>LA~>c%JaE9hPx$(W0nx$gZz)Gfp|^SW+h z8%G!_abb5lZE?v-}m#_-su5~mAZHSsz_UxVQVKf@kp4C)i=rG zX3vAZr%^uyol}yUz<(^S%P4WT1iNd=)2xDZ^_xmv6dm6B*!P~8sTZKA$psAvQx8vf z&O3^mAd&Z8I0*chu)FBde%kHX4FUGA&n5E`a!%tu!mS-SLVx|Zv3nTWsbyvU)zw$a zJ1Q==u<>5flzx(mt;DGD_wu@e;xMzH60f=iPiP)(;JQ2=FEW}?r6qhPDI#F|64H%R z)Ar%p+0^rR zxGWU&yavBeoWdG~Of}BLxNZ)=h+IDzC_nt>C*RCWPD|k3h1xfw+cZ4wD35odIEl@=4gp(^tg$7Y3v)=k{#=%yS%( z#M4iUc?y~DeBTwmVtf+CpapK?1bD}+q-^*42`0Aq(e9w(B9(ae=P81fp-bP|1!I~$ zzog1W77rflw)m2F^^3>QFz>02Al5OG?8t)bck!WKC9x8np~Uv$>oJz8``j84Z{2?v zcc;4CLgU<}ubEU@-P)M(IS@J^x@7GPWougHQHHH|3UlezuThX+4Pv!eit7f+sG_e3 zu;|!6aq;zA<2{hle1Ml)&L%+?(!n__F97gVli;$aM|{p1SX( zEc+URdnYzhn-#-vGiH;K3GrZ0xNNx1h+FYmLvIo&MjbiR6FxZHK<-tkyUfvx0q^+> z{d_OiW?iNx8AJ2gJ|e3S{fJ(_d70*u^9sO`Wp~NEAW>FORasn4x%-FUie7`Vf&P(| zGa0?>oxW`c+)=HtA{Th_Ggrq*w4z|+5lrKW@A$a>5G6s8Q4~qPxwu??2D9;D{Ho;} zhpFjEs9Y(+$MG7Ld^$73*&AhfoW9_WKV5sdpWObQd$iwuxN$dr%BkIXuR)-*HCK|i z#^*ISz-qv?Lx!{j$@8W-ZCsL%F9M2yl|96LEb$>{L`uq2gd^e< z)eib1sPonpgL?fS+X5z|#x{{Pd5ky-8(p++4{@EGAAKI8NG={h+qkTVCn6`1XHS`N zO_)O<0Quh#C>MYO&;%TQXuuT!0KoxpkixCu^qwpOKG|Z0k8%=uoD%;6oC}?n?Sqb5 z8s2&jfeV=wO4}whBmGVycu4F^)|av@dH7P*&r>jxoP>($`Bf{PYlo@9l!?{Tg%Bj) zSLt~S9YS%G!nNYDIV70KVt4%Xb=O*1S1F2&{e$-To1RmF2CsI%`fz)4Y>D>C-_5z* zw?4L{Jp{oYr`-14^GaefxVQ{Zcj&-QEU(5tzG-w0X+2BjUk?kD^YgF`YDY9wi~fKp zvw@gR>TSKhq+tz_1j_gkPU(2b9`$&{dY`il!kWxGKHI|zHK*|q)R{T<*;bVPeXq@H zWmyjYc-3{4tY3*6Q~%?bs#RRioK0LGIPsIqk=_mfGph)7V17}e?MxbV{h}J zoR2`O$DI3r9>nUvv|nCWIDg|zwg;+lofVW3pvHt9l5nqKvf>Fj2v8}ZmrF@ag@$a* zQ(;2_8YjQawQS>RVMLIAr?ln!+977|I&BnZs=DbU7U@Tc5Eoivt2w* z+XYnyK+tIqoXXZ1(D&rOZ=lMKzuy2nxORH&=aAEb=F{E{5CaZnbKLBgh2=EnW0ECU zy?Kqkj{j`fLUaB6?&b|whfQ^Qx(H#to*sEJst2Y(!9*h!Vp#K;?30U_Y$ZPr`VC%h zx^4iyEu(d`I&HCQv~jk5B^Xrtr{=m!3?>KL{_p3#Fc4LntU1Dy7%Kyd#eSd~{ZoX? z4}CPoca3o=!7X_Y6bBUp%ymcQ6_K=W7+~UCt;hBUF;nI|Xd(ct#R_%f!vI_?1+)b? zuac8Iy-UIPgnw>_$jI28auLu0#FEGNIsw(knZGH#E@|8H$$bA;_)0oweW(gTiSo~h z_!-|?l?`}MqT9@#P!`EOSZTSLQ$KiB?3H0r)REK4m)rC#&CkWP7E15!zu`hqNia#y z9?_ClPoX(q8tU^c&-!h+BkJtjzk5Toa4j^ZToFj$J=-TUebGiV1)Z1xmIoO9JPw_C zh_PIYd$^(%k)7P_ZhRctq54T3{c<{~J{#lYr1|Ko9beqzY_;M;77ot6RY48@7_LQ< zEb(3yH|qjT$CA>vb6mngZ|mHoJa@X;0c0eJ+UZ&#o5)#B8L_|I<7zq~Q$m|UQh z%;2q1rIY3s?Cgd}hNW?tRpdW`rd;jU=$YtQ%<_ZMZ>jRUfyVv2kTT8V4%tXIRG%!L zE|+{#e++14_7AR6>HWOGMOg~N+*DhfJM=mn!ED0tF+kE7xQU&b#ZyQ^-R9acxbcMG zs(u+JPoJHwg9Cm<2j8H6wUy6UrH5=xVc*=OIlHZ32F>U?0vSZ`SKx||g^GolRbf@* z75Pm-3(zW)RVl{~^4C8Dv)Ik_*i=9Z99)AQDA>7hiCSb`u68PCoc2PXtW=tOwuxByE$!c|o11H zKqgSO%>K0&J9PP;jjmmJcj#3Md4EK&Ifj)hY1v~qs4yZnbsTm6-v?A=$}Ra}6}U*V zqu(D*TflTUDsy?No_KJJ@X0tQ%bEqs>32v_pb-)71prN&+e!p+B6|&^s!#ulRl21D z_kGYMd4J=SNaIL0aYs)69S&x&Q9bo|H&I#*%kA{Zu9A}gsL?%ubGtG?bA<#KciRfJ zJR`N*(e>QnZ{@x@pRSGUl$ps{Fk4K9br-)4B;(vETkVqie+V%VH3gr|nW!ckhb_5F zaZqJu+(6cJ4_vsTau(4rn@x+o&7s`FPNJ=IDcP|*JRR|CHO}c_8Xe#jjd;X9KNi$| z+~ThdzhKd#2!svgj;&VU1x*zBK8UB4uPF&VR2GoCql|n$1;nKJsv2Oxa%xz?E86fQ z-3+L53Q#Bz-&^sotWuq&#qHbiR_Mj#F-;G5y5**8pq+uG@eV!L>6gEZofo!f!Tz+x zKpXegy1U~^L}S(P0~?*j?B!8s0Qo%eIl>*=b8_q?THaMqiTda%bEjR6qQXAq z@~D>k&%d4Xvm34U^>Z0?V_r{FRl5{98a^(n%wEf>`FP-p^RG`NDzM+& z&JBK)2gDmC?l~QXM*aC0qTt4^x8B>gGUK*e>rdX7;wQUCKhcq*e zr=q;dG($+z8>{wPs58=Qsgyg4iS`!A=FYfSV$YNmDyGBKTZ0hIssYzkjwK*3J|111 z6`g5%hnDtwMsqr0khZfE#~^RN59pcb29uxtoy3oa=iw{-9H?g03=0g625^}Ac@vs+7mK%2&9@0h#mbfm;mb8CB0B_>k|!~5_8jSNt_l-T7U;+uO>;l#keELgbn!@ z6*})fWxmWiWe5qd_4JSzbaWYXUpNEIo|Hhjc*yF8RZdvKdy*?yTs}!C913cN_I#~~ zvSCXq1jT_HP8V~AQ$h~MWqqkBR?Z+|_GPb#U;SP{Jm7f)fSV4mOE6oUj2CG&j0piY z8lk`_V40f{HH2I9kSeNx`i@osen}&P>xZL%X3RHnZ6NYUIm)mP4wIImjIJ*2uZ$&lczk%&tWCA-mzPAc@}44zo@C* z4n}b`ihmY!e1Biw?S8&{D5^~xRgO~p^-+ID2#PS)6eyLnL7DM9QY^4>g7tv&0bG@A zln^=s7*`rO6@Q?gD%oHb*+kgs28gi<(mhJ&n{7mif>%FCZS3kU363?6@LB zEsoJS_l3lN8R-of`C6!+Xcf%umQ4nQ$Z5m7!!n8qupYxa-Cxux@yhqfc`rrtdaBe$ zb`yuLtLYk)Nb6ZXe*hZTt$YYgQkf2k*|_LGAM;HtOZMI=oMl$1w`r)iqZBdBqA!01 zd~z;=XAa3da@wz}UZBR+1S#^q6k&0mMb*;5kuo(et zh6aPdC7FsicxbgT0>jB1YqE8v~M z7&SpvWw$VG_;4ibykN|6L?~v~&iQwRX90Ekx4kAbG)TOp4MKMOADzxffN)8Z?Frg(u@1A-f`NpQXZk>s`#%JwXcFQV<&^<1wxesc`u(XNJR;3 zN^;^PZP+Lh8eWfv2cu!3aZvL^=VBa)6>J9VC>0btZH*SNp-J8ef*9h#+H{aAAW~HV zcOYR-x0iFy-kiO2_K4isyE7B2Gs;RGm89W@arRlIlFp2f z%P5gLBO{}uNKze9(vsw-lKc7h``7pT_V}0RSTtZuyr9nnODsNf8z=NHs0@Y_ z4JE-NC{Ag36ijqqrd9)X!8_t7ss)GP16sigcR`})N?lAZ7X1>7uHZXM{}#Cb1;i9z zWhP_o1&hu#pXAFrB{r*F_V+r00TvqCrz{u52eS`?7_nGl(}RZdA?rCtYe03$G=_Q! zXyLpZQPN8#{(xNMJ#zDG*M)Cf;h9(=^9LfEtb6kWVJn=~ff$L@%Htis?#O~~od)G? zWS>dd+j6F{F7$JClzV``%NZ5>Gu~#LbM?}4z;Xp5h+={L7lci3a4KLU!J^0r{DYKT zNQO;CEkxyKMRpgGq-G(gWnqf;1tS3HngElDr7yLPn6A|WLhWM%mA;DZM~C^Ic7N2@?R-va`n zG&!CG-Z^h78d|!h@#(W<24B*P3=Lmv9+fuKXN5Q~NRM7E-M8xQ>~qK2=(0mi-A4AM zBX0L~1MaskS6hjzay1WQ!ve5AC+yQX69cj-IUE5Vd7F(g5@V*Z;cL!RP{PS;BrHuE z#ybTaSq#kC5@CBZh`OQDa4N{{HIcuZ6Qbv^2Bd-7o%RUxi zE+w5HR|$YE0N|W!xkv5pTm|W^fi$BhHjJ@{%kRlAU~->Zs;V3`sTY@|Xl;6ScrTuL zRCl_62Gl)sdbHnWL5oKoF*>_fYi_tF$5VCtIwLaJ-YKo_R~K(d8eB;8UC=Hy+H^nj zJFfWQt-xg&?V8RvwLD(Qp@J`<+iYYBRxJg~?4lg*_dPi;*bu3}qbhLT2MCC`7k%*F zQ8Dd$?c1Ox5NXL*t(V`hjuR2E#y~1i_~e-HKM@!oU`&EGRNj6+CI2y^QT^li{z>rJ za616MF~b2Y@$1R49L;a`ilE6yk*yb;*7l%&h~Fsfk*^iEUX}5S%T3JPb*QKN>*Otk z9zRy*JF^l|v3)Q4+L_yf<<35rcCOXs-#GIq<4kH-`LSeclV$fsE$-J>9tESaQ6D8f zztRb)lu^fB8r;Pte@N^4g7yGP0{pQfEcEr&r{G>_C`*rt-6uL1H~@4;`6!wbqS&(6 zgkfYeocDkDZUI2VyP2wu2koLnB5MG*aO|lhh>w5-mN@3}0i1xno0Te=(6ay#B0ogq zALf(-Fn9o%3TTO@gCtHZ7&TpW^0`|3@^7_X}2_Lxc7rEh26N{q5a~3 zy?)2VxKAEg_MMVS_X(dKL*u6I{~9K*4i|o%RTv*$j=H2V;I0vNquHQfKk?S@SgZ=k zO9f}DM*%P10-D)kpHp71q$9*fy+Q%D0vw{6e=_5@$V2o2`kd%-6QDQ=9F7+T12JoO z;V&HI>ooXDJS>zCF~fszCA<+Q1d0}7MQ#}g@w3La$E53{lhe=u% zgbS+ds4_mI{Y{mTGo|EviENz@){{M$x%k*@B;U3ZPOD4vI05>c1QG>l^elcjJFhLK z-Jy&9!aX}ka*C?O?xQflcby4uM9~)5-fa=K+Y6s+kP>0fI%IimP2@QM^M#Bpp`rv_ zo&0_ILG;=iq@0-Sn*3{ngtHw75+ecgq5A|>K+*~E{G)bYyx&RvXRyM9 zGkRiOF9)%O7V9$Qr^ZATr#yQ#K5?g&_mt&pNJWlYp?wi-xB$vlxiVC7%%MUpV@rg{2AL5yII&nY zHpm=632FPHF!}s6Ow^dNA-X0Cnw0m>hk0Mb)~yBb^HG;akqbK_8QBOHx&Ff*Y$yln zO~tBF5FOKO(O57LV}vh8@cm%XZ@xn>GOfeFrZ^IRy#$m zLRvwb1zo@k2fi&32*@bTnh1x0tixf;si=1tB!5TbgCX|)yMN)&kW2vLB$+yNc*ARA z8cPIzkFfJ>`VR1k8GejF@BEYB9XANc5q5eklUajyaz?6y9Bre;!8}>3OOW3!y)GcQ zxhCT3`jK>)q@_P~#Q4}4%q-5GoAvyIcZIpei?mOEePe6`v+ei0I%Bw~j{CN_ifR?J zMvl4jmlwY5UL~nIxYV^?pHY2yG^2^Fk~W>{BBHd++vLw-<;@i&+Ff2c%gQrqOcQyo zCCY*e7Zi*Z*=0I01f@S8w|FIL@-CsIrbT{+oMvpj%@%y#H2E>cmwt3rSwFvg$7e2m z?!_LzCV|(D>@w@iI@9t%MVp_`kVOpKj)CPPGANxXq_FLPt@JHGs0n~LHcN#iEpA|M z!~Ai?Sx#KA%qg3UK(8(Mfq+F3?_Em-n3DD*VuHtiwwrS#MjBhltHeS_YaIr-mHy{b z-Oa?QhF;kDDpEZv5%g3;_;UCvBef!1_jb)OIaHIxVO`O>%BEmWVm^`|PR zpocN{et7z~y(+&N{O5XNt@gJcnK#3$X9*28a;`YwWz5zu>h-j#tXs&&c(VOXvTB^e zQL8OJm{OYEFd$(#@X7v+oB!E*iDzeW)R56l2`(wGQDJx5K0K{LsR;szChBMSD;!3R zM6R0Tx4inNTvhtmUB)J0Js13JOnn zwg2^wRk{U>Y_(DCpX9cD(|?zk#1T}1HJIKio&*<&S_#mT%Sa|>G3G1h1SmcCj`R}7 zoc6DqjXr0lvo{Z~xz=taHGhA!{mmrH?!E|U^RbNKi|W~P+dtrWdgz$ylaHH+iAcQ? z+yy8?dB8m6W?C$l9-~#>@VZ^q@Ibd)T%@W$9Ep& zoVieFUEe~LJipFXi+fj(XW4LN-WN=kJ%taLrS;a3!=^Q6SJa8-uic!O1(sk z5uiXG{cAfJ@oj6!y577aud`pN&V3~11oT=P%{e~){A-sT2W#NnNP*Z+8Qkt$9o(42 zkkC2}(&b+tPp~t@TGK007x=`2Q>6DvQ*4N(tJ8A$2%6C+7H_qpB~N&{Fm+Vz z*+K51^5RJsN+JtGp{V_CbuUL3d-PI1JzSuB=;D0 zur^QqlND?$6R>-XN@6k;7WuOlc2B7Q z<*JsUVDl17wZk3>W)0q6`g}(%)qE}E2Hx@qs7i|U-O#%0TH>dwtP4#sXR6ie^Yket z9qoG#yCQp$1IhGRjRrS)t72icM4|E)7w!@)Q#$y9!=UPz6bw%h@2ts=hmwKG+whHH z(V{c#eir=T5b+zZ==^Z?EzvL+$08MBvS*(}Ng2e2S-J0s<3KSBaa7x`PcCr#Jt=c8 zJ;0(hCz#up3d0B4_XZzjOw4q$zMu6Z9&G@5-P zS#L(t9|uY(bkgzk!FrO?RL~2Q4AH4D0r>Xwrli0eq8X5ferNS0bs|oqBI4!8jKycC zM^?0|)owsI!}$FATF_Y)AWKfuXS|;6X3U~}C$lG+_X;+S;*Gt<$s?_q`Ipy^?sR^~=~*H$Oo zdTlblXjgW-2_1Zz;`cO_KrexZIZa?{ktHg+jq)@ngSvz?1WyPEXKFMKf2cs@Yr&eb zT|+Ed$H-QWdVZ~Ot~H5RjLlJ~%WS_52Vda#f(_Ht?)6(-FG#f3&?~;K+gb zE@nNoYT)xIGNZdtAFrb7iDg{3_*0m!a||MicD|`U9uZ&nxdI~5R*(y#Kyyr_?evMYp(Z9?)T!vWm&T4$fOx)c2<&sQv zjaWRnPc((c0ETx%Emw%=C|E|<6`bxO>OiNxglethQLz64VK*_{+}@6OBjj~q@mkV2@H+<>5G=^vOUfrpjRNf%tbSCS+ z2%o~f`YYI@>I-*r>N1a5+q%lktdNXb2L@~dv5vbAN3B8BC^~qbeKwtgYKA+)wC$)?wc((;VMa_tx9nM1! zMDbu=bl72pV|)6fqg}&IzUzqL}y)V~tfE zbniu9?}h3s(d!~56j!Z&n(@{Q}g(K~ErH|Ijc83hHsUQJ>p%#KzatxzP|s|;|gPVP)6bg z?Qc^99(W%I6y;hRZKTf9Z}37CBh^jB5GHAQZAFWoX1@`}ZPbA^fVn!tXmKpl6TKhz z$X>k5V#$<9+BeSZP68K?52!88B0gOoX0W|ZAtn-LVXw!$K0&f#0TXY~Bf(qnn5|5U zSBPXpX4tH3{z}Hm5+a)82m@S}<^l!G=+bWz?rZhdsPoTJ4-vj-ByJ{gQ5=E!*75RRs5a-a%Z1;RlVlfN^&X1Oy~W147_b2iLT%RO@OK7}dy z<2FpQEPz>|b-s@H{xTgxReuaFO`k*q@R{P4bQ?!>zUv({04OobJge;&&2!C@a3sF) zisp>JmGFBL3X3L9oZ3a@l1Ea>nU`~!C$iu@A$f>;!JPiXjGk1%#j(vt~ zlq_X)e*(G{lJ~cnbPHH3buBf|o#S^usu>q&nHK>ENtO_YGJ4+Z6QyL@{)52VYpmTZ zDoQDp(x!!%-;Z?g*oCMR|72^aDT><~C;!cwD6X1%mHX(6O-m5aAPA^`KC8X0P&P|C zIX7pZgeV4`3^~-3y~HfP(8(ZmUbGRnc|KRt36RC5?T>>8Qy6l*IVdIHhz1m217r({ zhY1(U*iL1qLFeX~y0!?-8{?;$h*Lb6{SqRcFrOElvE2ttqQWWlaOZ7!GG!nSci_#1 zjMV`>Mv$L^Ht5KDEsI-#kXpnpL^jeExd*;b5(3v zLy0A$I2;>c{8)96wvwB(nvJIreo`1m!!onaqLF*H`v~Jh#y@wH?`255l6eB)WAf`F zIojS`%yw5s4?sd!%EUR;NZUJkWg$kD`>--0#U6JgWKL(6rs_PbEo+T(jOsbJ+g%%L5)lCyp0N(L|-QoTSS&zr`B}UdTmo zSXAUigT~G)r{r(kdV^}k0ULzdIw|iGJ*14f-=|W6Z?5++SAd45OGdyoyP~HQS0Su8lte9N3^S4epZ;d5FGJ(_(02+0snOo=mb`X% zW50o)C~P90PfmjvwL3?(xc~udI~2z43PNIF*)Pw z+Vy8@?A1{{!yZy<+s0S6%pXVG7&w(9XXz=qNkdXoDU@dO7D~9)G1@!f5?c0(i&0V zETwxvF|p=(fT-rIrw!;{mkwL!8{INonn=zY!M6K76g}0k^}CV9LZ8=E zWUM^O`HfwU&FcMGPiyfegU_0A*hp)P{F|GeNV==a4)>J0e+6RDm+1*ba*&_$BQU|f>U$Rfc zR@Jns=#;sa<5hCwIQs z^1%}mv#h0$TQb=;h|-Ms*ZGLKy0133%iaX$=i#Rck72MBP@GI?JURBjW$(Z{Q>ROh z9wo1*1|2|8472d_?>1h?lOMON79h4H6v_|+)h)ON1z~y?p^^NZbeaH0KOv0)Gg&`> zu9a>mgy+}yieXD%pZ{LmK|4NbF2~*bw<4Q!I)|T^)9dJbe-`U>mX|W4Fd_b~C8+R; zo{+j&TVG$}x%I))Xl*se8-qHnEfm_OM%(D7>dcS#B0)d}*E4f|oh-3WvkcoTzdPiF z5OC*LCNsY5EdoG!K>mH?rOi|-;6NPd0FN>el{vv&3#q+4W$g_F`C(K^{)h^`u~SY&xcgfkt5hNRKPE+TOPkxaaDqjC7XBOuNk6m+dy!Sv z{Z7$HqP$xrh$hUvC);>`u>0;sEMffnA@VdUp_v2 zWsx*bmES7KOZruUqiL?t{D%yDHp^HG2`;JBEc%J=p9{2 zq(ASNU{-2no3K^xTX2cw02mbzkC= zwl7)mYx>r(nnlbq=U)i{B=E81uvm6mLQ)^|5!g#9!Ix9=S2j~Aux2}I#2x@-3%@rW zytV@LiSfhX=&gn9mV1)zv44NkKaxxV18Hl3i+dt#U4(xug`oj<{bX-w=t z!4WJ(&`*nos~mf~3T38(pxIko;s6&07Pw(&S(VUqki7PVEfKn7x}+}Qf_CpBK}eu$ zx)fC8h!m5zKrC$)3=l30%%c%RlQe=b*h*glaSJ*jB%Zb@vBH0sRwblmS;pB7de&j2 zEDvq9uLRTvODp|z3Hxk^6*?fPsLuP+cSw+=}JxSnwEg>j}A9f-StCN><5T6o6IGdzD|g+oKqP(%(oBZk|vMcEZN!*hF1tIS88tX z|8g_LjOhbg3H>mnyrH>_q4DZdX}W}h@hX&rXkgjW9doi6sjN9 zFy;VV#{CdmI_Ti9694@$$Ql(Wb;5~wz*%^sG$0uILqxZ+29f1kh!%5G2W1(PLJ|%9 zU)n#7ex4UPw^Cp2d+}eyd%`Ch6ECq=;oVwngC>rQu>N*2;}~d}oMS79Y*;4T^w~NX zBXD0Py|IInX8pqMTeV})*fObyk~Fs?K$O$h@NbgzFK$daE1!YmTwfLLa!&7vld=+jd(T^u~H`QpH#Pupr`1hhhWS*Z^ua=64+yE0vp z64cwgTWU(2vpambn-o(`3w%)~6u{QJ)+%}Qn7w-pr3EIrGVuJQpoC+Oe4Kz?I|2Qe z6H_I3^opX#9$SAICnTu^>nyIQtL%{03h#AN@Ox43I`g2LDMYiKKl|3zcC#jnO<~$!5MSUUWlD})I4-}^q8-)(2JVAD+hV7j9Vq3((m^EpGN$o zNR78%xa)OV&rR;8Frob0DH6j3SWs<=UbYi1Z)@lUY+bc&AUYS^J75PCum1@OBIzXFjo&xgqWbeHuqFbW96;nvsS{| zu9`wtEYDC*?^kUnq1(ZSrHZdM9vE@Ynt*tS3xL!r|NXcQp6rGa)>bnZVGstHu`n`H z^Jxl~{xC218`di1zoDD|fRm1W`w;*-4e+#N(%yjT?X;KKH+RZ?yO~ruJ1Hp376`(L z21~4!$0Rh%W+f?Ei2h+lrClr6)|zJA%{rLvqpY7jr~m>wom;XM&SJV8*0I0Bk;pKK z2@@8+<$l@zqyNB*5<;4Gc`CjaGA!;Yb>xX>4~eVeJbmo=#=FKfm&o5i2Mdh5bw#BH)<_Z)}Qdi)Q$pEP+97S4<)V6^*n`pJLkL9G)e;OX{Q#(eLBJCSS1=x~_NeQJ6AM+pp7?qJ zMEjV$kO@HW3GP&RKf7D%EZU&OBdTe%D6i3GQw(-kBA8D&eu@e!)6e)VH-Dxz!>my+{>rn(3H+FVFK)sS(+(`LX&8pXl^db2{HpMnftypcBM70bBi@z%Y z9}3)*Kz+=ME56r8zDn1o1K@Tu6EN6hLNn z45fNbGsM42EPgomweTeD!RQ;*>fxUyvo@@x-(UG4S+2u)WRz{hmnBb*y$Hq*@}AF! z+Vbe{0(S=O{7)?r$!c)8w#KueCe*@E7TT68W(52mO5LDkD6tw_=>RaBNSBRc{ zK{L0>4j)bl4-xmfzcg)HjC>kxsSCTv*BH!nK!q#0pl$PXtpx^wDXr<+fb)7jV*zb9 zcb2Tt_N(U?Jbao?Jc^6hd*g8brkNm&0mA2x8IXw>CZMxwOz`jp>@In2`RjNM%q$ zW@)`_Z2cioyjb+WbziO!NK&eYiiIC1fc@#Vos7M+45%*|+Ql&G1e=k-(0fGqF`Rg= z*f*!kkWln_W=TEA#W57(*h!9j!av6?(o0XPzdF0;)4X2`P_8TG+B@Y05Sl;)Br?f$bf7l?jTLFomj3C zE8Wm$2%u{?cJSkHcNO(uj-m5L^cQ_1q`;c7`jy5?0!Hlz0e zpD-N0UkOEWA9FcV<9Ix6ztnrMS=vYpQJzml@T5NRaUL+HXKpru9N}GAQI5h0M`@Mo1k=gM!eKO|7k!)a=hj%+xc`3FYhxQa+fXKl9Qy;sV$f-8jKS9flV zzS+7#u#~8{imanZp@?(vPQZuIOq%2XJvsR1%4qNQ^`pJcKPrQgNr643Zf=ViD;Ir1 z$({!LFe|dy5hxQX!=%f+i@7@hKH(&??Ej;X8%K9XbrNh093>vAof=ozPe%2-VAC$=<@U&RizToN^u1e)F6`Sm@np!p8pS`Ipf=?)*lva^=A;>>>aJ zd@%bu5DSQ#z50+VQD?A@&& z{4%oJ4d?Xmnv*NlQ0iUSC->t=;pL%lFP@|4ivbTDvuU@B=g@l;KTR@?fYb*H*+~DF z0Q@ia*2%A9y!LFX!KaX1!v2RxeIBZz>bZz|07@ImMmYw&+RffCKfmaAx%o-NF~#(t zt6^eG1pnwi<$iVh`pqve(0IbJ!)f93`*X812;|89yIQ=a*tZfB4_2N$R0QyWQq+!& ztGOkgvB8Dm6GBg^7P(Hj^pl$ZnT2|ajDH2o{9)cor2~~2dgoo7^yCN=j zRqx)q^_12H-d^WgtB>Vj@dS$j9Bwi4zl%y?p$}3K!2kGmwtz&(*KUVL6r5}DKID(YWX_{z= z1_z|En?L3h->db>_S_?%5*yaHATO3>jOgEk5I7Kg^;g8)tWP^wN7m2Of^D|@cRL41 z_AGU6Go+RXLK^@+4}cj^j^{ zkh9Txk08zYmjD}yL-FpI-5|I~cIJ@jr(NSb^H5QH-CN`(M?^Vp^$g$>W$vr`!r-BN zA~we8>6|Bj)^F}R;qC<)h>f|QK~Sq~wIG(F{UfbcwaW_? zNWJnt11?CP1JY-M^hx%x{ysYSE0uB&>>Cp%8#*snCrayp^&tKAB59;VMm zJxSZTT=;9C%6YB2Nbv&d+o?~zb`p4ft#6t)>4>;Bgh(1f&~Lyu)>~OOi1T_Gei~sB^H!gZFMb*e>=0h7=o-eOFxGw z(+uY76QM1y-y5Yrg2Q`;1s=yTeu#zkX`JrU!1syC?E1zBWJ4;Jzd9q)rtx>~evpXi z_;XxSr#&?&~(KuA`w5Wzw&8 z_W*>_G=(5?^|c}om-m8JgD|n!7hI_=LU3X)f2MaKaLw+aX%HW@r<{)G8SAV6LI^Pk zTzUwHe(ww?L?&nOPWnvf%RhvAE2|EnXKPOa`v>#$!eeik?V#OoHfq^lZ5aY~0M3OB$#XOU!rrOm=PCbc%3^{ZjvH6}J<6 z>sLj&d#+B=L{{g_Op@cRW&=mmX?aOPFX3Uk5{IrNv8HEuU&$Xop9s8guiE-{#NG;5 zUObKGL~DzoYi_LDbYlXCC|e-rm~|A$v&Sa&$tb*%+& zyq|{Fu$J!oMmA0R>mIBgx95P3h8bo*ogw;wPzUbzSK&h)glk#aLbKza@5GLMc-eGY zXo1t_`_M%C)(0nrTR#ueBlmA=`1kGT^xBQyBY+#C3}qhiEE!(kdt@A0t2y+h&5zp5 zdlOq9x7%np1Z8zRDN0aFQZ^MSBU50j5hnyk*EsGkX^2DSE^{$W?F6PpnE(vb zx}%@-;C0v61^cVPw`+@it`hh(O@$MNjNd-R|D6;0Wyi8G*8ps&i zRVe@)Qcl34IK~?h)j|$t$$LKD!{-HE4Nbe_XOU8UByV3*SA?#IIeNebF$wNlVKR4g_>n8;%GI4d4 zhWcjG;pU`OcR=@3OXdnVtM6gB8<|*5}~t1Ln)WPaSjlXSaXv zG-EGEDu%boqMMenoV#fk|NYu$SRj{(9BV1M*^@M5?mrW>t`7;^Olu|{h1v|F(9R;H z-h{u2Z$CrdV<5@kuE6b!{Wt9m861!A=PM1PpM*HfZ9t6m88iXI0x_@@a+~qg$#}28 zjB0o+^=EZm=7~!oyNw{R^>&hn`3UPJunymI3J<}kXNuu{6WQ8?=}Xt%?0egD;O)qj zZ#T05XEeg!>$iwsq%L1QzBVLAxEFbZE_;C>UPtKN&X9Nbm*aOaFY4aLv5PYi6K9gT z^W}rM(NE>UrW^TtiLcFP^%hRz&%G8&Taj==b`rWUXSdHTtNs0+xhGAJc{Vu&Oz3Rg z{QizWhwF$&o{3%P3%QlHpsI~tv?J=*VzOxNP-88oQiC}?i zz6>O!-Sy8R|7DHA@Q#NE%jxyynDIyWMsrx^WiUo;EAwp36y%)9wg-vWZ)b8$#^}#~ z3ycg2Wp^ga<|-I2Tq;N)kI^X5XL7nhGizaqk)nCXI8u+C=?YfL^!O~+{?yUI`)&fg zg~g2zA{`0uFDsNA5oI)7%QXwHl8@6BTxV^Qx3}`do|OhqR+YLJsKXPG9x=Rt-gQB! zibv}0@5D_FJ+Xn>og>Mi_e)x(GmLdEg>_%D9==|C0(9H9g1YKf$H>3^i3^wBhTlxN zNBf|Bs?Q3cOm~#o?PrGp2p~Cx@2<$3&$G`_b@luVZ4JkMR+c?d{uwH(B2f5z69rOp zZRtt8z+p^ZuIDo}Oz{p#)x;GVQze=U%oGd6hNVlolfK@Yu?CNui~0gib&Blk&NUOZ zU(rvSbr!l~G3CzuInFnY_|pD#?qR%Tb4Hs-SE@PX+k+KXeAt6j->oPpJ(PK;R)oLx zF+;q7bBwUT60Li9WlY``w^LLUEgMNG)E@q`&%W-Or{Rw*&+k-zxM{vK z*_WNl=iSLNpNsCx-mTJqlKtSA&9BA|T}y-^2*ztX@(8wag$+y*eEH!*hsEi4YSbqT z$)2r`u?&wztJI45);A1fkIIQRa|%pG4JH=?#q&6d&o^@K5hk0OylpeD|71L2s(C>$ zgXlfpEBjnec)c;-${c44kYOK0LW5L3$@_99(o;QGuCmQ3ot2r#AM33spBywA#vN(W z8M>n2r^~Eq+h6GFSJGru`kC?cx7LZcrruA}j1(2^@1c>xcy4OagQHgtC%thxK##Rv z{jShWyo?J&vi&3PJ z&1=&(&Q=8XHtyv4Gs4?cT`xOQ%PxTg5vgOMV=;|~ZT13>8# zK1{Eyq-ymn){@7E9o*YTygXTyz-(54HSlL2-jj%TWZ|mU5w{WH!klvvvhTGk=YxIo z6CA%OpWDm3grF-Qtw+h)C6LUbr}MggFKIp%L$#i+YAS;yXg_s-YJ#APoKqK7y6*SH zE*6;MpS%c3zB^-wV@aJToRGR#pEh`y3{C2&6wBb)Yj_{X`Z`MwjqN>jo^rZ{v2udE z{U=i~b)#id;R}wfk}o!UlxcL2h>n+`OO=q2Q8IMI*PjCM2vf?nKSswQU+ay9uUHN# z9x^sfs(cq-WMZ=Z%1uYLb0Q$v_z`1X$7<0J1!qcW+gG}Q)n~_1K^(^If7PXaZA8@R z-;Bmz=?sC)&}>nW(zTi;?JIv=kXIJ+Xq=kk!Fv(LdFR-not$*{1|&MfxcCP4Zi1Ch zhUih_&VVIVd*XbSMY|D3Ex%g6-+iVPk%$Qy7b^`CRe!}fOp0?%_dh6#vLQV0-z4OS1I9IS&F;V{V~ zDhk}NvUlhBcARq!l613lvf_|sO~Y+Q5USao=KK<&I_^;#A)BB#M16aCyc(N!L&FNO z_h(&h&pk)V--G2X$Hj{8V(t=B8$U(_*t71|Imnmfxr>QR?Rrv8bn5XClw)@D`S?PZ zDZbw>oB(d);S4Z`;*T7Mzs|XH#}@Qc8t9r$En1&&q5bxV@~zgG>(q}lP>l5m*KM~m z`>6czoZMAJQJq=CF`cQ>RhVg88RLa>w}Si8DqmK;-XR@mknhu~U}H~`cU&;9a*v@G zp?0l#9jC`3ig9$=wUno2y8a6@m#YMQk~3%M5qWjKH4n_9?m^1iV@K?MXXAiROw#&w zqimo3X4k)HV;1cl7KvZaJ&TRZ5^TW?P(}zdw5k{+=7*g=w(IgRGX$SPlRdEwdR}!e zq~T)p{p8SYX&sGw2yY*w>Ni!Y9UA;YKui#>Hf8yokLb8zU;_2stv<0!#2lT| z*!XtfH!{@c{9NK3E=?Zt2ky~OXI4DbA-ULLtD(RPdbXMe5n(omy`BpaYQ0_irSxQh zAIf@svZ#z-L$tTj%9QrUT@|oR${(pBOx9=x$<+6_^sOOWU}5$l_M&pbR;G$KwV6R+ zCpnN;?5roVBK*TK#A5~r2SAfqN=wkglaq`fT(w4KdkszWQ}O4{Ztz(5)1Tf1ur&*S zG^M`7^8b6hm%U8A=?{Xwvv}M#MMafMcuoF76RkPb%sAYlo6pZby1462`?Ttv&K&hv zl-Rdh8}$sHup)At{ZWzm%!j?ry@vT^>g=Uwp_JKYSHwz(S6Zh%3c^#J-gJ26b=`4t z;ne0@vF<75Xe}PkYQ9{AdHOVkNEiMXhCGcu=R2bVcZ1vRh!&h21}j2hphRO}anx~! z)ZQK`V*PFdNe5YQGvKO&#_%%Q1v`Ms;MvPhNV?7iFXYMhh{k$JOU)~K7C?wxTKR>c zcdzyWO9lTe%Tv}aMD48qxB1^=z0{MJ-u;b?Tm?MZj{-h(dnuc$`{!m&X<8Z*$@pUp~fjS97@A(--fkv@#k@({IZ_0FztVk7q_)Api`yRon zmi7G5d!?iQVX7cv%doBERo6JNs1@rbUg@3&jZi)~wnfOt4bntL{{?;G^W@xzrAc3> zFp<4WPjMwrYAWwogB;s>XdRNtfL)Z0lrWE`^B+ zXo}jcVo3T|XPgdZO_|Du29c2;R6G3jDr4dL1L5-ni_pP%h?ADiX8Oq6yf-EGpkyPU zBO9pu>}?{8zij|a5(VXp!5yAyUoqBpc*bW<{rn@WT|D)!$rqH!lIi(q%tf1YCKeUZ zOCIL9OQg#SA$lsDKQIffY}YKuB7c{Ck8vj;5cUWT3U!J6eG%1kI2vfi)KT>} znDDB|Nln@kE+gu`D-Wb=2#sbVlpK&B*vK&){4y76PL(b&)*foJxsoV-(a5W5ePdP} z{$qT1Nh;5agHl+dj{U}+nOzTaOOjeFysH&Qe(1uY-9 zKvzWPqc8}=-9Op(G`AH3pVs~)t_PeZEj%YB@N^Z;ezxbDF-H*^X5IN8d{0(;&#Z$z zxy7?$)}&9-N$$VD40R)_Ae^U_MQnHtU*?|XHT@FC_SCht1c#8SK(#no3SBx`ManZw zY>X26ZAu89s ziGW5munp&4|KQp9HL)(23E`G3bGa zE`P9HdUofki=W+|*ph;ahev#{dCMtv4G2|a9~_nO$-WpQ3wS=2A+>G1p`4Z!K#-8m zWr|^G;@V;2kX`#J6_@`@xybBbvFgD_zVcgum?h0w)TPfG`VuS7osX%=Md;om+S`Q5 zm4Fln7L>^*I=nN0$m3S3s3U?1LTKp6SDxyjeYI!idqd`FDY`T{J~i}zhR!^m=|7I+ zJ8f*Uxi{A~<~|zb7TetSh<=hZM?xw&N~Lx)B}b{;%~7rrrBbQPkt1bP(xK)^x<@KX z_VeHO-|y$~eSE&3&-?v)KcA-69-Q9l3B3ZqO?4SC2KvjorRFVX_j*ig_vOVP*x9$f zDH#H*`qfpRS>f zeumCpgRNJ^>HVeF>1FPEy8K^+8nwjzYDOU-b<7mrf9k&r0?b;pGDL)WBSF6rCN&>K zi2=v%ZT&C-*!B(}WO(4J&KfE&QsZexouUKr-J*fLp^_oB{uj+scOydaZt@^mw!tD!q#-Fb8nA#1lg&_9KvQ4lw>$b=s{9Qz^}OA zU%w2`?oODyw2!j8bcV7QeY`_TFH?})%7E!Lq0RxC8zitU*1&;Y^QS9hy{}p{2D9k-EaIjVVxa^#*`AI1W!M`jdw>59J#WU1O2BU znh*}8886~l)Pi*%gqY}SrUBF@G@B32rn2BAz3IxIJkl?>ODOX9Ytl7y<(?P}N)l?~ z7uOqxLw{(FXb7+gc~Bl46c-CVF#;-Hfs`-QWkbwl8_|!DmeUP;bM+W*R&J!>4hm)W25KRfO)cLSj`670f55&+I=?+ zofW4^@I6NcANH+9Uss$)`)^vGF-N}vDA`WoL#fJ&da8@|X!UJMih4@$nxrX>l%{G8 znkFq?kD1n8r{Q%VYucE!jW#=_J@yTsO7&d0s_s68zQ&$FionvE;_C|MK~US%qi?mvg_@KZkkc3hpwFSy9+61MRY3+ zWrj7fP-0*{HtdW$jQf*R?CAhOZG>pua&c>G;G-_dSwK|DZFyDimm9V z9ko`TsIg*CI^F(I-7T={z_a#?2t(awC9g>nsP(W>AB4ITzd^hM-96ph)2;N<*Hx8 z8!uFiqp#PAY@wp{14+$~@LG9JDx8)y$RaEP)Ci?ayn8G8jN$Q^6uJ8Hb0DpOZ}@ql zR&s=DYV@Vt7!5t*SJQ8Vc)9BMxV)zx=M0h@RG{beO($C{o&d*p7RTJeNbaL03vkLU z$=>zv8H}Zfsaqesa{^6kjlR2^iW8>Zz?r0}nCu`x)?x;e!b@*xp2V!ej*;yi+P~8=rqGiqzi?3V%DX5Pt zo_CS;D_|EBq`-owZ6RO!x}d zdldlGBE7Pe?^cmHQ#GV(YF*K)p8}QsbnO`-DDyU-KHxFAzu`aDYf8c?!=XeCdr`Xx zXgeAm$^9ym5Ot@4y3WLDfFwdr2)^G9-rlu;rx&8G-XIccQ;)hHSf441(^}gnrX>JoI~_2tm3hP<&*&K0Ox#Jr+6 zZ|XobKG>VO+8i23K(LQo*s5-S_*xCXRQav%g_mWe)V+~E6F2tDEZBeEtWY$9GT?Q(F}ZR|mM(<^U>P{` z8@?eYo;`y~%0VjuI{7=Qte57Ie-NyUQ zVA-(i7v54Hrw#thmsf?|h@vMP&P369#f$xTndHDjtW0PfnecY?hgudRY~#88L-1L= zf&Tl29D^!xD$@R4J6F*WOr*SW)uDSrj&A6c&LS*AJ-NjpW8{lbLD`|DQ4#m0=#ukn z&n^J?>`T1_ABFT>HTyb%Q3a^4v|GV^q7;NM8Qun>nwSF3RZ>s#hn1~NNM#Vi; zK9OD5Q2S6RM_XSO?pU~|b)=K7MLP0MmR zSxXBe4MCGDL5kV9}dg$U5H&aqVk-5_p5I?lAZBTMf|D+b+R z8;^3H0@j%rS1pK%o-d?6a2&tC5^~6ZGBT| zMN`~c`9YZP!OBJQSG*J4M~k=K|D*$9yHc_@>s`BiABWgP81?ov4>|j%)thp1-4n8} z<^;1V4r^HiwMrQ~!|XI7HLcHL*UaZ8wj;D%9UgAFAARyaSqDNpN_&v-u&a|vN07Q3 zvfpMIVP601IrvyJ@cSR=fToF?)~onWj1&RdTib9sINJqInWX8O>aX!^01=zVm-}Uj4_M>juB|I z&-jS-shqMsCxbo~T4*=HH~gwoJ%{nunfA`zAqC0I+QAd{QA;%ky!&fI-?*2;!rf=C113*sWJLL9=|tON&FyE{v&MfjSMmyU=dB1~JQ zg11N`%(1l|lOB_K*SLiZRV8~=tN#ve;gJdx#oegk&+}d~Q73Wj$+~qsysPT;0OjBe zZcpkI%Ye#ea$h5A1lUFI`&v(9s=h$4K=2K40l&u8#Zq7~O z;2K&nrX|!;<_?fdeR}GHgaOr$8kp!vYf8Oz%5jFoj*ShgQf+^nbnq`s`_+~nCTR-) zO$@vLWd$voGo0?T@;VlUL&xXvQ2KsZ*?93aCV=pC{pK6Vgn$c*@=$94D701+LZGml zcW&n5oOh33J7-h9Z?lPq%?SsU>{hO8SjEH}xvuIWR04KXJLiVNwPD@eNFTcO4>%FJ zzX!PX>PLaDd9M!nQ#}Q+^tHvk_X21XoscnM!6G>N=1MvC((X>d3^{u zhazkn-NVF|eJ0WVS#=Pu&$#7_M??Am?q};Q%KZg>^F2lAb;p3dJy1X;&_U0sUjl)$a%=y;GZ}x5ZP0K8R^9nmq1-i%oi~v?2hm(b$NA`VxxPdTNii>g zL?IK*2I-{8SSDVqUDvaoJY*5uY&X@#W`LnD&&4`=B2MT#r7o#1joST+!@rkP!c|+e zv6O;m5prj@gBBE?3O$Ka@@L7IvnjhQHe%+K9f?3i9U8*f9+fMRleNmpDW_DBk@qo( zH7iUv!+faq=+P}l38ILg2J=xh<;(Bm{)4OKv$CvTkkz;vJsMFyeF<(*wev-(XSODd z!PJn9V~=N2E9KISpE^rlq&)^_EcB#R7Nubs$C(KS9&>@nb$`1g=ly?%3t$$gO|Vu^ zBDRrI&ZnwL$j}DTzgx`Y9OedFmfvYpVp|~dpia&5MiAS~X4G=tL6c32VC<+);R4 zJ`%$N70IvtLJK7JBLGC;&7*{Bo__=KCM@ew4R2cmZ)-T;cS7$(3NXLdx|A2b8*5E^ zv7_*&OLef%*-bRrK(+-f$LM-go#=RQ|1qPPY><^^9dPG@4b#pJ5M;xINkVI144E)ZP>lo?9~yl1zb84(VK<27PV^f1HAEmfDU? zC@ld`ew2&5WrQbBHKy{f+8`fDRER`H;xzJwRG|m1noY)7H5?Jn$dJMxC=gq5 zk)WuPigcTjKPHAz=N%ix!TLa?T8w$_nYepKm`5?^!?5aW5X9XSL{_fCt=99~qRnr9 zaHk*8zfowhfK`@IZ7x!M7!!JV<9_%LT8-VQK%8=rT3Gxd4uEC8%^|&oWqhxBK|u$J6m%z2WDNU`->P$Tm7r{%+H^6Pswfkpe@W5rSI{podgcj; zT8hHA#j5^^yjqyz69(zhZ}bWJxoUlQE*N4H0kAb&hY@H1FiQUqWMB%?ql1hiKxXtB^E;poe`<2#qjYk> zqwYTUtrE}YmS%c~1;0oE?(vg!9*Q9Eu4_~EC; zY0MrI;AR`l?c8$WLPkM@H+WgQeEwkaAuuEpL}FDqQq1T?NcH@oG_G5d_sLfd93@js z?fboluY1`WU|#BBu1Sj5jcfBOs&9l<>x@sLlHN2$;VJ_mNx4ux<#9r685QSQE|E*1 zz}B`Rt`i!_K&880e9cep-!k>zcSIbCIIng`W)~o6ntiyBK)~ep7JBLBB+}AS8_H0L zg-R)aZr{tWLcqgyIGq@fWS>NA%LXr8HUoz#kO8W#&G9i;wtT9+t67JV-&s{u`nn&0 zc)1%?1ia$Vvg6N182$M6L(K5ss;Dj|ai!&YZbj5@v@wOXQOt}nGDW4ad^y0b+uoLv zgZoajKGIOOfxzsFV?zP3=O1^0qfr*Yc-?^%Z6G4I-9m;zbxHT#X;u78QJiI<-!Ra> zTG2!iD#+^WhQH`(2~s?9cABxB$hNDLqVo$I-xadg0fIZapfBg`K|qy94AdhjrtxbLI%;Vp!<=9=UlajU*O~B5sg8GQ6P2$F8zm)f*Ql9GES}Ta z`)HlelnAmakVyeSR)8Pan-+GOpf=xQ2V361O5O1neKJ8HjbIXiK)9fs4F@jOxBPwq z>@qR+qv$wdKdQy$5snyr0w#l+`0eP;U3Y8aniDVD0?Iv;%59S# zzq$z`CW(DDl0V-kPRZ^+ckDXomzeRW&`0BDspl01ApplHLyC{V6Ps@;uwq{@w<(kd z6}8|?GYbD^{L2At{CNAp%kouIQ@K65`#yor9W}0Q4!ltfWf@_7gsO8sceB$JG!rt9 zZB!z#5gagTj(`{;AbuL2>S?f0t z@4*T;y6oNJ6tV2$8A=m>TmI}XWG9)7J)rbRO2BINN8BwAD&ot;@F|{opSGP=Fl#-JDo9cOUjf@7$aFi)NbDR$cL@Z|?iM{*KnBTL75LWvari z$8Nx&U1d!Uvc!ewKu5*_0-d!FmbPw(xEUb|tIvSXlT9Nv&ma!AfqIU@)aZp62YI>U z6=)d?eZARkH7>K8VE?ejZnJjWavSok9^#r1Q6RB=`17R#~n8KEIKlrinE|h;#*xu)R z%ViIKzP=A-HG}8?IVn2T41*Q|(0~l)U0-%WM!21+>S`#@Pc-*xcf=vqAK%*^vZs19iL)1{GWaP`rH&qmJ-nbZeR5UZ`c&BH+!R=r1Rg#!e8Fk842qH6zF;YAmU z81m$UIY!`!NhQEaE#&^1z@#QmHYLP@i=Z*i3f~vbE<2+#f1q^66xNUkQ=m#+k|MDU zHPecEB}Kj}JgL0nyplzO#vI@ z`n#WBA9eY>`+bO&_c?=x~YP^-PvZi$g-oKgzPt9u_oAgT^J)K`ZzW2bm#k+eevax(iQ+44O7B!}Y z0oet7erIJ%V!X2^2U;>fKD7hDH&^O6KTq|5Tbbh%fT}cr}kL+qB*tsS2BQH z06=aVYj)z>q4BqnU1G>V>DPc1SOnnPw65GE^Kb2K!yq7(PJznru>Qa{i(&aAGg;LQ9Lz;H0GT zqhvCQGd!958W>EXcZbrFI!@4FFqW?&w)!Y>FYu zEoLglkYh$rz=&^)4DC||olul}u@9D<5^L+J6|;&)f?>&G*aL5lr{Q}C}KZ_`+iGqJJ`YR9-97llp1Lpp_kDgD2*6;oIeF|xbSFRMx zpP(Rosj!#xBSg_d+Q^UZlr3KXOJ4w(A2-+SPYK`NxhBBWba^*Up6_9H7K=iRldG@s zW!~w_yNR3}rnXb2fB~!<uo# z<*_pVgxT^@e%^zld)JQs-4Bb_AOp-Q%zDXF+`~V<4g3v)Bm13_6Vzk$bBjnm$#9?4 z)};q{x^MHE*L%h}CS8#eokoxHjI=G<7U>;a;t}0p{^y7x;*D#_6UC@B&rdsB8C;Y_ zoF(k1tCsC6M|Y*K~Sic;ojAGriOg7h<)eHx!X?+Rq4ggjmt5 zBS||3w7H5y--QR}o6)tRJ4MB>nF%IP5)sa?Q82#aeI8{iG@hBXUTbrE%Uw^#)D`4? zqP*jiOFn{X>K-RtLTD@=@Z!^?3w}7YR}?vAO&4Q`PXV?DW;i~yj5jP>J~3nV-@It1 zqt)z;rSi=;2(r~Z^Xt31yH_9iixgAMAKORMuebChKQyoH`O_{UQ_c{U2;(ta+D-G4 zbErJSuJDAnE(y22s(o!XCzSdWZ4=yL?u&kpe+voa?>Ss^J@*AO@?U?UoAvbBujT_n zb6f8Rsow}6k{j!kW$(w_Hr!)g@|nf|S__`Rd(#~TUPh>Y@)+B_=Hpepr!Bx}``&At zQ^+Y6SH2Tsk4Nh(mMgj%pP?wC4T|XM3cPcc6ztSNw5JpRMN_(2hcMP%xxlFn%fBxm zw1q4Vvt;L*Az!--z`$$?@ohYV2Fd?r9y!!{$Y)P_YmcG~9hHKtq>3Dmo z*-}Td{BWE7kAD9^lCcU6j*s+&Z@%siIMvDpYgYT*8uC`IV>Ot*@m0u=fG3+`H#D}1 z(CsA;8J(93(Y_}&L~U>nmK7?cb6T^C&_hEmn1HBz!(6E6 zu5F@O>@k7p6GU)eI!7s`(GT$J+W8As1m!Ft7{97tiYpbl#*?d=^OVy5V$sIkLhc^j z+0rNJmp0v7L6fver8Kgk@_;<0-}nd9)`aEI3 z;a&~j@bAJ0VEieYJzpN$bx-tJsE>)6UEO-+%Dp7#soekm#X2KimebaL6&UHn!CQkN zDg%JVZzL~bz7%_UE&61l@op8K^RB&R%jo!3eKkiL@%42r(P9sxK`(M*q2x% zE%b1_`gK`J@@^#r`d;=7_NBdN;+f2di1r$_%J8qBqv|p3%GO0$U2)pUce7=IWs8Je zjQ6IpQT{v73;95Aj>;M}GTKxO56XPyn((9GmGf_crs@P$x`57-#D*`?1)EyrXstT# zxNdAY&AnPaxV5{wszyK)r$DVcs6_0)elurBJGTLmg54_iv@@`oZ?B({%6O*a3{>2$ z?WT0Yp+}`c%n#1Lru3_Knr4c$*!MH0;lgK68|%~fkzL>La<|{u3#U%)GUhgoh&==Q z95*VT)4>JSW~93>c5OU3X>O4;(Z{7d_|u?#^7zT9@T;_Y%8yYV04q7m33o-@V}bVcxSSQ4ii{y} zeTrs~+Y7tZtKe^3OKJtdncbMNtq>IvhMJ-~i%Je3*R*aM4>j(gGziA2LzR7lXJ6OL z>_5$2v&l~{32sy4z($L&GwG59+vXtjPN+U!CK2 z!mHhM9B+mdqtsi4y;^HUpVf~PI~}$6K6F_>+YC9SAd|YMKg-tE*L4rCU*zel>)n{) zJe7|W_E1?zwB*paddghLoBMvLS=YQ-it}1f+jvEPkAxmq#Ei3r$Yt|Wuculp< z!1%7lep}Q46K4sq#P1@^)5hL4SzD!Sd$GPfN9%O8ueTyD1oGeqS3OBH8O$~BxS1DT zfdjYT{=)i|ZtdO%MLyzZT$c~Fhoc`ATuL$>@iD#hhn!@U69Is*?$);6p;V-DkeL9j zb`dgM7X#N-9e~6S@GK@cmMpZ35bRe2elpWZv2XT=L!XCSd6oo?5b}+kvVCfEHf%Sy zsDo8HZK^$a-+s&H=YBTUS)2Lv8_e(MFL3e23A#ll%z#@f51}4%#CKyh9l3_B)hy(|z+V^XR_9G{#82GwamNTPiX#}U)*n9ILD;&cI z_}Y-1^nvcXR;-QPG&uBRRwd0F5>c2PRM;IeF<|lr;vs|vB|w7+KE=**DG5183UiK7 za%{Lhhr4o)wq7q=-E?fg$3f=S`|@)Ra~>u{4)G^)jxFcZyvaFk>{}ZOuWOgTucvoaBgFN~-&0Bo`Iw08z^O8B2mfcc0OJU^qi{WLb94(Ia2ds1253ts(V?Sy zqo}I7?W~Fb@aZ<|{hu5-h-*X_GT|I1X25v4&)MFIpHdMauBrJ0Q(T3Cl}sDbI`<`- zKHOvah-aa(q@OnA53rb- ztT_kxWcsC)Yw|OiE!~@1EW$Y!DJWVsr>{ukwWGjAz;!Rve|tjXa5ea&v$kym$S4K* zeNzjUP)w!^3}m4K8=;1afW%C-IGN1P&+0z8YBa&tF)HppC0ODF%O3N@c$>_JyNAeWi1IqriQG-}V zxa9=Lcp2shc$oPS6r2D(u>D~ehM#6;x2ssS`gCXdd`NkheQO!#Qyw2;p~ z4d|I&n^@s7?X_vII&o&5+cv}f=z?1@VZXnG#oWj>b`}II!)Rmm+!P*3k2Ig!qMk%N zHQc_%31-&0iQ_1!U$wmI4Bt6X*4&K>@M++7xwkmMw@`Q@dyaX^BfdSSn*D;6%hFLZ zTKT9e7Xrt_1C1Jh25JN45$XfTTYY|><;z`pDeDU%r*zVpZ`_s*eaZwte75oF3_1UV zOJ^GpXtF%I3o!1^c7j7Oj4Ioo05v*Wr4Weo24NE}sLThytpmJOea4k_z2q-HW)670 zafXwyEwv7CqfAcJYL4|hFBT5TT;wt%2wve=xNwMfgr2J~;$SW+WHN#e06+7A{8#yk z*#Q}k4(koRa{ZXuIIV)us@#61mYdK{pC7nwvFYfw`U+pYdo|GQT_Y2??48DP9o-|` z*7m%4Ze|-)e=P8U*_*U3$3-`}oy&whQYac4>=*q2J*KL(S>W2mWy1T!_nzKpSJS+q z_9f+I3m{j%gN2w6#tk5xezJd=6pJD+bt9dVBf;#w{`=&qX1hGgrCp7@@Zom#4fiS@ zck1)SwUtAVIvcPz#+f^bEYIoPZTdvrrJvf)kYG1s_>$bf~i+z>dm1GFiD*m1_muoa8# z18OC(tF725IvdsT-=`wBCS8c1FyPaL#|g;kTc9a`b%-a|m}M*TU{ezK2kHRR)d2Uc{U2Xe%Kw3xOi-CX zAd8I z5~3(psx#tU#(v=#i27!D`>=p0494kjv!;jz9v2aqhWc2azyh`hJBr*17RVABHvi`8gu?x0ip5w8@fM)W_ zGU3_AghA&Cj&(TKNy2S?BFx-*|NUL7$IXc7O| zsJk!6*J_@RiMghv7*5IFC6(JNr3d8-n9FajI}6%hU%fRf(8Dwmmw|oKdCjg#S}q6Q z04x{0TdqVa%HDKM{ezj=*d;Vtx}H-X+#cm4&yNq8wY>;{Q(3g^Cuv_EbLlLXE*5m) zJIp(voCQ8)ul>F)iO&ddxCn>{hv^6b4v*pE{+xI%D93S|>T_($=n#_J?nS zSmvq;Gh8v7APqGl3`eDd^ovCmdawNwApYUWLJMzGc%74Y&%IxanLAu3!7l#^XMg){ z|Bj7L;TN^Ny7l}=93!67Gc{g+;w}|Gu}|T;c#o&BEf0R=K8@zOFNS^@%ZUZ-Zc5ZS z)*W>3(dcABItRSMnT`eyK-JD-((xlKN*3+fBtxfWi%mCP*!m2w5&zQP-~XqWM& zPnk08$8Y%@)*B81OkWw|VO)bQEGH5j-7WKUO6fUlc8 z=eYo&ad>MUpylwOmkAt90|Jd$pqV7Y2(j6W>VTfBz^Q?o=GE*({><;--2#nRq<}PU z^$XAL*9{Uwvi9F}n3lP$<-Q8B7&ioj&zVw_|i;u)tIRN|w2e8HJB= zoCQ8!TY_0UrT}8o01gdz4)6*$Q=mFk6U9$R zY3qx}Pw8-lhOI3Hc}L6S6U3%x>^}=3Y8wDa|j!lJtUr_9ocM68;HJk&AD4kR(S?ow{Lsj4dl_Idbbv}?AG{YP#%_rgC5K7GpnIyAXY zGi7s3E2&&!Jo^53ub@FSk_-*Q@R;F;Pbs8R(CI!!!8khmvrOIpOVkqU`ESW($AE&5 zk(56*I|XTG)bjjrcJ&-rdYJFckC2+KW4(kobJK_6EV@vFFDA(p*R-(n>$g4Cb^Pem zmv9JAW=+US5re7Q=dF?Hg+Pc!giLp>M_Mp^KNzPSxvCLY*2gt=u5yRh%;j-?zPAshSWi60&G-C?RW_omM*ez&0M%R&d1jb;fhPBvf7o#V%*Z^ynrir?zbop#*eejnTM{mQYw4?J!5Op2DTc7efA1?^DE8eZU6O%|@!7j?5>ps%ve zKhhb2XrH(ScOVES$iYC-l>k0N7D+!7y--;Q!70+0M<8w&Gfu1Iw^l3Ds01Eorz{;a zU=_yz&`#i3(JJBI^JF({`>+r!2sH0gkz)wAj8Lr!QxG+qN-{?=-hqn=%|Ba2RGVLY zI;c#ggvo7whZ$Fi-iDcTS8gBB$L2Yhj>)SARj-`KBYwssX!CDU6|6hLXZ$KF4y$PH zwwX#jc_H1h7Pp~^9y5f$GC2OwwN9oTI!N40jyRJn11uDvYzBI_tGZA!FalVHW~3^t z(Q1I&+!b=NQFeK;ApMO<(~R~#!Rs^i`Mx6>Jv0?EP(Gkb5kU$)EnAI)s=zNsj*{uf&3OIgm z&uD$%J)0h+5q#ll7jd3m>hG7_22rNXhHcRZ9xC3dwhdcU9eXg`raLUT@Ic`y*l0*l zc-Tikw!Y!yvODf))_aZzy`pherN13~UXj-M+^3(G{s&zgG$G2y=h}0`Rs?+dlIx@J zo+awr<;8-^4gxo?LMDqmCN`0Eg=lVo_gN2;UJove5VH$uz6P&!M6X?KAkihStHv_K z4X;dUn>>oJFUr%H{U@5Q$dGI3mNywQpIoz+PtXld#jU5`H@a`X{2t5r29$fD*Cu@s zcQ3<7FJE4?Q7yVS+DqASb~%W^o;VP%97Iv_HOdVoV%0*@MRHh%x2RktBt12+Gz9rl zPwTc{u*Zmv>$>83hxY>SXK3;$)MBN5u~&WkQ1m^OKy0+Q5Vm8nQDwY#rPo#DQ1sY! z$I@)i_CU;BakpoNraQ{9q095Hqrln!8d;};K@P0;%O`JDYn$)db=Sc=T&M@RwOQm> znFMuIcW=KMF6&kIN;_jQKickeJ0{U|wcozes$QHRhpS(KnR#<t!uKk*>-jsqo*^UoZ~RQa`KA8 zi*k`iw^P={6Y{x50AN)gFs}sA0kaB05JG{RcM!0U;jHQ`p*S2KU@PQ;&P+sy_t4mF z?$0b$m(5ha3O%CM)bw>2%IuBOF8M!zeCC7*9wOO+8^`3>7gr;9bo8hzQwx}+7!*w2 z-8h1Jaw|_yHFv-rPg_6@nv+y2$wOxQUkF?-FW?P*+zFafLr285D#tP>Kc^4+J55w8 z$=zTk1~SA{%YH8f&Q@?XH3a4l!9G#RzhM!sqt(1I7Bd_Z{=rYrs>x2(^3a8GR-x zN;WMhUo~fU?^oI$3VIQwU?y<%QjLTcJb5Y<79A6?X`!I=-!4qnkDAM6vG&%QivK;g zn#s7fes`mA4yjTFoKL|q3gPir0%5b~Q#L^fbAgGZP!42uJFH0yko|fJackrfdB_omtLKiM{kO zeMo=ZPl|%L$-|n(?#&c}H_|D@qss6+fP;Y}1qfDLx5~ybteGDHa2fsd3uaxnm*us= z)|RpVngmdH-sT}eK8WKasYMgRWOYr98NRnD ziIi-??uopCaj$fno}?0HYU$d&P8U(`c!{*2MrT&*NAa*#uT%c6tlx=RHs5_N{z6A4 zpW1lv+8MJq+e0E7Vsdz_Q-0`Ffv?yw#0JPgF;bpdEPcU8NiKFa&Emk~X#)2s;t z;fgZG?T6cezXgx9A=-!@(q+{{jb*Q{_`C((NOt{~t?NJ5aa> zLQ=HO^Fw;9hu(WR=CoL7Nxz=cU~k~`3^v`I-ODujFMKxxVIg#brL@B>I*X1_+#5MkrIfSF$o+t(Iy zgVvz9EuR}QExI@Tm;Bh;-0fL~YEo82R%Q2#3J?*qel-)BU4DGff5>&UtHFKbZ=MKC z9>uNpXaTyL1|}_^bvHj_Hx5ir{_9JFmrvP;1qb)L+I#7{)2!9r?sV^VV*%8b*%pLn znc@j*f;+GROWVZHqEiSu?mJaq>ad?uSrCv|7uw|&sO@gz@7B`hrA!yeBOy@LQbn3 zt%BzO3le7|gcg+kq{sN>2lrPoT^@fL`KnmaV{>GA*J+Qss*#`U)5rMH+K{Jj2A@sI zBZ%(LIyHdDU)65|TS_qI;XJdf#}g%e#(6Y@JdlYvyJocqGEGrmenrir>PR)YDE+IK zPZ*}qQWJtry}eXX+b_FMD6}vf?YFn2!)O#5s)mD&fdD38Ox3Qu$$m;nY(P7WK!4{Z zouF2PC6+#?Bt+3>-E}F?JW8JX3{Rjr{`T`-fhA{e_lBLaa^Ea|-JxCTEm-Ge5A5jV z@5}?hBfGrm7yc&1$WAZ-T_lWG3gi&>;aoZ^d>#gi;2{gCdK6%^F`ve!tj)rZ&v>NM zRYskCG4m87<5CaRfXB=1<4e?qUaH!vn^P~}EG8mgx!ax*fJ{8)k>{Cpz!SAMhp@`k z&IGN~X0+Qy5Zzu3rq-jAs-rrCMRun=b062?r6zPYNkh3mPfgw)njD>^s%lM<@^MOv zTgFaA{W6#$lzWh#x~l5t806f5BAoTst6MCvur{{4*Y8=m|E4}cr`qk+o}#{-F^n=) z+b_z2bovy~UNS>P@}nH8d*+`neuX3*ELCZL1Rt1l%>?M@dhcXW?5`9Fgn;&Sqml7g zU>sLg><}GAjE=GySe@vVw@0)^_RX!jfj$BrXH~!kE``mG&^F2E5+K`1;++uMnwUch z5k;nh9uwV9Qp!Ges+Ugy?>~QYu()URu}@`Z;TjQAi6xBPn0BpuMKb1ut#PsG7i%)# zE6r;L>+0(k8f+VPCy`@$nlM5GdsBpd@N-_2UhkCo_A7u(YR}zKe@@&m=?_cqlCMYr zRkBXB%%AYw9Z)R#>FwyK4))eJ*F2!af8 zK-F}hYB5`t40aKB>yUu?&Qz>3wXkd8+&}Irgr?C+{pQS8S_Kq_)aLt2@gGH0~hMHBkTSoRH;*GCmfNg(W#6{1z zrh~7fyV+gtZmt`ps_LH8>D5VamlKvyNIZ(PgaY@LD^cq_cT(dC#aO#%!vH}46iZM6 zAU%iwH{op*>4|Smbh$W&3l-riM4GIvW~(8_#ne;zTd)-&cyEr3L@tXJcUxuAuspWO z*>TNXJq~2B`C0eTe{RMleJLN6EdVbZcr?6ZD}lZhAITvRw&-u-tS-OR=@lJ)J3e&G zLytrw{-VbEuyu;J;5yC?A5Ts+;QR~){>4(CZ!J)Y6H~jmI?kdh9X>jI`th*h<7zj2 z`quzGNKgAm6}rgGAzOXD^2?^;TSX>)?C%uO2AfG&64#FWzG~GiF-~sxu)H-~V&*$p zFW)o13_9L4O>84hNJvtK6wi+E;0_SQ<@c!a&*B?)|2niwo zJTgduH*L#3IxVL<-Ew}>4h>X#ct80Y`Mh+Z|1hancDN2JJrVBEq-#q8`C_{ke?8Q7 z5ZPscN^vAwN06;Kf6?OfqP!ng;^yqk7UTi`Gv=!lvo%{!t3as4VRbxN9Z#ZJjMCP@ zloroHmPI>_<7j5&_5KsLAf8)rlLpA?Hha(AM538td#t>BtjKOM-5xOWqboo|?|8Qn zL~613i$ew1hv&Ar-OY(!W~9wEVrXn`@7a}X4mt0BkjQhEfQu(W(uk)H9P@CY$fIB{ zK6gC%oaN?X&V9Egy_gt)+KVzsd7MZ9@BW{nbB|~G`{Ve=u+7-U$lctRTkf}9wz*#; zmk{L^a?35@t8C2uHrJ4t`@P&#q?y~0at~d;E+t8Yik zE?gIXWyg`-S!=hdU?U-!A6^i|m4MYNs@|LBi2;PC*Dm2A2bIkdqUEWXS!}xdDL~Mm z@&VAl)YrOe^Oi`~bq9WgYYBH1K%*@Yj-p@vwlD^CN)=+c0y0Nw<3byN)#5-}F|dQH zh=wS7fkNq1eaV@x6?5P;O3`C%(hG1z76^CPf|D+$i$vi*cb(6P;v8y#`B?2MJ-KMp+fKum(e3WAtmE$=ylqd|kM#HblssPXu>t|av!>oTz6P5L?A7eCLx!@of@ zU$Es^h+_>Pj5d7ogVwHaih+2)5^)j~NE}V$74+tPTg!kZzhquhZ}I*=;*V2tUAqQ|L%s7i6t9#ocme0K+FImV+H38XjMtKs<0BK{s~*qI6T(M_&9?xk>5q;+02##RTTaIJ!&snQO!_r9 zGqOrj<8D8SaZ7y<=IWKdGf(I0pWFU5SL|*)pq0cujnhX}jcpMy#sSwiu3OszLpWU3 z?=a~U0C`CEQ|)KJVI04M_3BEeaan>cBe9}udp&v7=f6?&7*@aObNF67)EW;RxsPNd zT@of4GSfn>4s2YE_(X@lY~tJRBJq)5lY11SY}w2NUj=VOF}g%JHJpalPZNE_jGhI1 z%T8Yf5&-9Tu^=~qj5YzVw2@ZJ=RT!Y8b>=jB|McH)XoQ_o!?goE|=*|yBJV-hdz5% z835K|7IaAX?J;b)TY9g69q&VYaLhvN)p-4FIKBkemD)bU5>WYt@am}UD zGPegErUjhD&YGT#aBcy3eCQ=3%Nf$Pj{jJRuNtB#fYg@+7&_^yq{%ILx?6w6nI}av z(n8Ddlh4>s=@MpFE^4L16l6pgnIgVFApHiPvldAHKU~QZ2 z9f_mF5+tmK%}yre8%G8vq$6}(S?TI{!ce_)9vc{liY7=cfVj{F_^dn+ha6nw#AO0e zlfgWd#mey>&oM29tJN-|Z2+I>ykQwkYmIm|Y39woSR_WcWRRO_a(5CKDx&079qO zvk9w0DpB}-ZVXr~%~-@eO)OTP8iS$&B#oxnjYNfpxQ7>>wO(ZC{y&D6!* zlZ%YC#;728%~-)Wx2(yqjUmBPV=k=(QAu0la-+}#V8DO~k0g2ZoE2=x-sUIE}D3@H~J-ElSMJqVmE1QztZIxQR5 zUwd$jnN!}v{%{8Q>q^~RI`a9MpyYpVF?3*a@*-@xQ!hU1(i-qwCh<}C0PR0k+MB^a z2~!%w81e|;yaP=-zrg4jK$~72)(uMO3TW3aD~ybcKK@2!V7zE_oq|mzgCp%c#rPWEWI2Y)((Cn&7`XZfZC37tLDgKAbt$SnIs#{ZxO(~mG#fQFCe%OJ^#9ei~ zK4a16D20%~#0C}Ka-smlcH^mO7o3&e9VJ#)hW>TJ==L%t@l(5+_Hz=xl|j$TuX1#- z(qXVsOQT(^W|GXm2w!UGURAc_H?{=8-j@fthx|uxdtQW6lvdgk8(UUy% zwzdh|+}|%76$yWFmEv)kwp(}-=QD~#j#2?rGtva>Ns8Bv#>k+OC-5Sz;-{ZMIjg&3 zAYtEPWkmA{rW%GJoTj8JQUK!+k{4~e;t}?l)T1`?Aj?@Q)cmW39Ik4=A}mgMiYIWu zjS<)y?{l8*!WElA<}96QtXZ3mB*@%Gi`g&;;pE)tD@1;n{>;c6H#3(TklM_g|FI|QDUKgxNLg}>AoQj1(FPUhveC5xp=l0unSuixmv zr8-HK?4m1tEcQjO-<`&>NFoHbD+Hn+hFz2J&6Hq{Mm{r7<~IQV4N26>8`nJ}_C3sb zE*{sho=cR1V5*paegm#Zok6#A2Y*tYS5v9!92)DX)j4Ej>0|Eb-)*B0q&=T9SbWLO zf5+~hPa+eu9jBHNc`MOpO>fmD7{@orqa;%>F1l|QS!5F+-~Y{ zdlAD^P3E>z-%ck^`>V-x{Ij(dTQly5ooZx2_d%BW-!u*l{n2mU$;nFStsgJ=DTMwP z?<0Qb!E~uuXVOJ#Z#{R&_V{}n{5VsZa3;A4s?@PtmL;z*Q=M<)FUFiURojq|)j~xx zbDH|x`B-6RQt6IVp=?PYjs5o}a+hz~k&fcir`9X1MItvgWV4}(*-8^zwBz`u)I>ek z4lU;6*PUCs8PkKIP5I=d-NBUk+|b*m5~OI_T~S0h$mzOYsN;ut*Fwf18xN+Y)-jhG zZDT8?jaePywIL505E9z&xXa@m$ti#Tim{xR989pZ_9Ni}%(o ztk+8lLuU#r3XS>Nrk4x4x}=`G^YT}(;jf|8Ktv}XsSZO!266QCtC=h*K_z2Vqd*Kf z+FPjf<^x}>1b+`vI;6^FzS1Z^e>OE)^@ozN&9ujprI*Q^7xr*~zjg$su07x_qg23I zIfe;9$VSIh13oNK0A~*nOb}$Dj-_=~!G%-+LW~)T*0Xj1oHA|TFimIH1L&2+5tw5m zh(ixa^ey$ zqNlc2tH2+EtE=*tJ?cz(4X=2LrCNK*# zC=d52U%;-tbKKxPW`$|7oLjy2504{_#Is#q#-Bx2$0dA4ne40$Kvi%^ccSnX|Fr(_ zny+oTWqwQLkn954@0$6#LG)(&1NU)7^zrQdd0J6rSXT`=_cG+tnjHl=m$ z3nmje9h|Lv{g_yfjr8Qz5e&Bm30bDWv`lI_7SO^hhm_h-xyXzvkmNjSU=xfYk$xR} zTy?Nj;90$r)I_L;nzXHQ`~L=EQeP$7f|(WeiP_#=k8V3mD+qRBllA%Tb7U8zwT_Ob zl?Y;-LC%JFZh=%AP?vC5n;TlAFx95NMnHSn-=^O#J3RA@oVz=!Qm0mnI-v=w`;465 zyjq0XByrs+wd9Ilye>QeqlP#8-#?ijRwTBj#c$i&%qerS)YoM++Oge$mE|i+*Qte7 z!_@xHENN1Jx3nQ6l24mVndwM|7z^Foix-l|ePJJ{WI~r|OC)bl#+c912}~2u04)7> zNJ)+CuzPL*wp9}ymT8=$t;YivsVFAMGi zIpYE)HPSF)M3?y2URjj)UJ8DY40Rihyzu@Zq0L{V@4{#6Bjp7f>LArs*s5%H&8Fox zeo*iSK=SD5*#MmJC#4s6dGTEJ(ujvTHO=}oCqlDfA-m7*jqW>LlBvcFV28!^crqPVkUQnqJ?%#jiJM~y9l zYf$8mEl}2Mi+PZ`${cA;f5qT)5n7}skN%bDuNW08Laa;j1~lFXX}^G16T2Tj|4hDD zYh6K2c^ESiEAQ#~#WHLQ>Tzep!oYh{tHsFvXVH*Qy-kV^pu2&UF(kNvj4s+C+|(FP-?foC;vI>I@Yp-d^#D_GWKL3I z!X=@&=B@Jt*gIf0812)H+RB}ZScjO>;aXrN#APlO^3K_4F&mvILrFyR3A20>?M!lN zvm)xxOR!fP(OFP{ByaE_68jXKyfe)yR>RK%0~m7gky-et{4B*Vu_7vOHtw^c^zWm( zguiR>%b;tzo*3v9TiM#5glyhyPgXOKSPj-dw-SCY`}l++Y{f1{kYkU;^q2eC(5sS6 zrUsHRTz_#k;y{b+)Oj*deoIb1#QKM>1s~|~j_x+^thmC&+6$`9yfl0#l_TgQgH=`F zb;e8FPmPVGRPjb#L+BJxtN_QaC3evNcu0limcSrJU>In^#7UI9Ft41}foL>_x0N z`@6H`5)34Q1`mkcNcCc!ChA44ZeR&at1L2+nV-sto52a)8-{X|cGc7?d!KPQe!@U-C zt?|Q;zNvZgtS89z;`={*UszF;5i;Zp*&W!-sI{Q~2vtbxBF!Th;tDO5f{{?MmO(*= zcH!)y?dD@`0!gJnDG)R8Zg z`QzB6LvSJrUP>P8TtJ~m-#4h(s3>&13P2nq`6o32+C_o+S2Dk27B67h4o?8;4D$L9 zA~vad*aW;@&g2WfL!4;bww6QE-~}CAYcd@ve=gOZc6*BSLDfj@gqsum|DpbVEnp+N{qupg<3_{S>$TgMWX;Qn?vB$G4OP4o-5n_ zi4Qe2xL+}%$|6;+>m&GemC6O~lodvMRhQAF**z8abOqLz7T?=NvrxgMm~@1AeJMDo z#k1PiSBNiZkN1{}mLa?51$BV2Y8OsWc4&p%no+(c`U4Iogr)a3 zl-9*vYXmgKWQ1^RYTubOzKfE_;34=2@*&=50i{R)%R7olIriS84YM|6;^vU--^!-H zxbtnK-SBnRsXgBbq{TF2eJ&)_w@NK-RPKlGmc_85Z}bGXGPUm6M^XO?3tF?#10z0~ z9LxRC<bOq`qHQx^ad?p!uwxO!M{WsZsx3`bQrrQ$BAjU(x$Wpld%n8 zlf+`AxT@!`VU)naY(gFKM zfZWB86DlY}B_H;IK|Wgbg}@s`>?e2MRjDNclj19JeY*^68KvM)zDlwaO!cwaG28vfqWGLFZ zt_rs7@o*{-Q+>xYLWq1NU$rNLPfEB5ho@N=M9{Q=t0OC^*-DK78xJ58A=|`5k`DL) z{UY(^Tec4t2*Luf9+F&ZAf>dNrF>lsz*`B+dWN8iq{N{H*}nhO_k1KF?iYNH&Jsyw zA$!2it`_SCIi^$MkJ#?MAA^J*WL8t4P1wk5W5hQl@yD7zO&LV)iQ;FB<~ybAV!CTg zdV`TdgU1soyu19R{#EcE^Ad|ujvmElOH>Y&z$H~z&*#_q7%gPAxDoi{wU)RdJYEsW%dkI=J zAJ?W5=3lD<{k9}Nuq3WPvwR+l+Ar1F4{eCPg^R9rjppz1d@w)X<=~47$|$>2Pqk4| zTQ=_~Ko_ow(nC2G(Zhem;8ut3Evj1EwtzSf)G8Kfg9Ts!u(RgRy25CPd^4TEyNX4N ztm;rHBe3xzW-KcTc!nopg`xqN1RR1%@=|6t_A)@1itJ#lw#ylDMXqr>tY#^WU(CDE z@-o6c@i8a6C2s$x0pDjmfQhq+Us|wK<{aDI*(Z*1=(|~f-fFLiGLNg}6n6Or_6F0$ zSF0Q~85~9>aJSg%7tK%8(<-(~h?*neoQjg$gW=z8OC@!XATgANC%6BOsEB}=hz{kN z+Vdw&kg!yBe%=A^U^BUhZ0mF@%W5Isk$&(G_l0J^7Bx{9ljpFPMfpr!aRysk^Aet_?AW zH1j9Qg{6&Zht`ww$}2iDW|^3!Hm!Yspnlz~mCA=Dus(9VZKkQ2!2BUIsFMc>eFlH& zX4Nb-KDz3GLV2PbsDL=M5J=2emeGzCYS!ab7IXVO`R3BU-ZVFC)oj%HyRJsB{(cfQ zp%mB*2VA6fieT;@RI}?#!^+W|+m)u->wLRwBD+MGxW4J!ZFXz|=QWABa$3n!wOcd# zY)ukNwUNQof)!Q!)!h0^)vB;rDvqJ%7cBNi)oeC=MJ6g^EuZ_T>m|(f|hwgnL55wxEuh{{VX_LDO~57NWJyX z5W4lIJnWhm2iYSwJ%RUR^&hNwf_367u1aKcVOHwWzLNJ`L;{b1Mt?zDmHyO%bh@0# zQ*-kDrIWrFgUR!W18J^x7A>g#s{d3siMt-}y{_g{%P`wgBwNhJzM@1+?wdG7b6g3%0Gc5U ziQy8@-G4m!nxSNn3$iZ0O`31;ZBEcq9vaHzhWMT4x9?pqQS|&PT>a~k1;0iR^6x9wD-Op<|sY;#r0lmmgnMY=Vt5i>zdQwWgf== z-23mwwP@QoX-NXCCp98V0fQ$Y4Gn|WNldI-`#B1~w{KWVjU7B7bGAy( zt}_fIvewO|dNQl7NL4R&B-*A$&$6(vyRCwS%C*GDFxk;N5PmO`e6ski!0NDOni&Mi zx`_YizzXxh;x5BJ`fxia-Z}o{$lQEB=?X)450oDMC*RRlAvUy)4TB9{gjh1F4H3Dw zfp+{15>tsg%xT7h$KW1J@>u?qC6%LI}*#>qE6qP3Tmt`ydq-zDM&LIUwOwn@a~50*9W&~ zA#a3@HfuY}*A7^^|9$fBi6*b`r}76k@mWg?(@ZktuwjTIFzH7Uf7{HsG5aZ*g@N+H z5)HX*eb$(Sd?bl^oRyMZj(A(@8Xe;kdP`r(MRE-(*wtp|AQ~=jl_B)`qF1WStEH+` ziSjmM9*h#R>-0KEn7BN77PNy2?RYS3DcyWpMUBpgfH24eH`iHN84bkiP+VszCbR}) z>zm?2xLLyGbE%QUz%eY1wRsw99E3-9?Hb91ZlxMws<_K-ht;_D;JDKbI4qV~FuNUc zX?mScC(=k}{XcG3t7=7^qZC9W0VG@yp!xm$fb`D89pYo!uUL|J9#15# zxpGs+9c3`|4%SWbV2j=J&w8J*m$dQqqRZy&m=P|gTX{d>YYwKu|1wI)o@BBh86W-h zzfj-nYBUN5!o1u*C4dv4gKRA4F-WhlaV8LTBH`2X98yE5I1-cAATG09JE$%jrUSuBn=I+eX*WYh&O2~H?MKH^ zVJkaQLgr?#ctNU&t~ps5QUpWC3zvP!BpJ0 zZO}W8S!fH8qAIz{BLoxE;_&Nkz@^Az}WQtz*=UkM**)#1O8lkI|Nl}#)dyK z?GGyK)lrmlg6y3E2IjO>ing_1xhw=q%A}L%dv)!{R~a$$gY$+1GWr82g`Y^wNVj%H zy%B*)-E`xJtGb-GH;Os!|5etZ^lymzeS016F9UOidBGUPFk9ZAIT15NI3^ux?XM!i z|7Ag%pKOWAT(x1PnVF#x8U`?L@}1}#qz-Ye~Mn}NcSJR zk7$-mxM7g7%zJ~vEZkPq-I~-G*vCghbbRW$HU2Hw?`vZ5Uy_`UTq!bhm01{pdJdIBeSCJNGivud77D6N^LA{Vi&8V8=- z#c4w^W2^;e`kBIY7f6aSG<`7>rh!|#@b4CbPjJ1U$uZZG9$_@NTD6JK?y{4W;7))| z=;bxCyXO3pECHLBd=>iQW|LE5f~Rx0um1DgR|Y1UtsShjf*;zZ4Db-t<;B~hW=m#> zRi<uvr26JvsPR})12;TAS9@Fa(W^ZlM{stlvRgi#oHyy zL>|ce{}SVGWRJZ8&OW|jFgXC7$XG5zVitBs*MdgOd~tqPva5w70avm%g}NP^ofp=n zgPwlmD^?%2u?fGY`q1+eYPn^Z$2P7|B(*IX$9MR8XTQ47d^8C6dEIb#YdT3ri`aYb z)t!OQH`Si6S%WJ!BO-s7dCSDDKukKH(0H;#`_K2Y2Tr>_ulX|cd2(Yi?BhomMUAL@(y)D`|S<^QqjR zZZq_m1^p4e?BSg&m!p<+!s6-A#v@-cb0Ko&Y)u*|t5TAjcgc_Aev?o1Pl>;btj-QY z7k*=XXoo~F>4v-n-$LP$YJDqNv z>)K^+-G%(oB&|nhpiYgI2J#_7RMOcjc1X7#G}D?DfiN_iEGtopJ9J<+vw37w!Sc}wpmTFMTLn$ zQyk8un|A#*-lMJEqzfr%%!0h+Z2Ejnj$6aDPg(W}z{8M5cqze=hIee55oj3H{9Y;i zATmUGNP#gUn8yshZHo>!zEbptZO}gI%Y8)NF1%=3y^#>-o|xdSno1voRFa{sw(PY^ z?47IeX-AOm>C~a=grRBZ%W3Ek6$bH4V<@FDuE1W{!bYgDkzM%9(DZ75*clmd2F)=` z;CP(M(RKuXwwwM8oxvb*tWnr^lsLW=A_n+4X~hT{ALrl^d}o(qjhwjyfKMKAJf~-V zam<_}BN#hMS(BK|&$gNGFc}~CI6r_hW)U2t*z6Yi|I>#bw$fW0GI}u?@90?!+srrM z?0Ns3Icnx-rJOZD)?jZ2t$}^b)?=kd;FnmCC@b>2otCMYiL{9bG|Hh7FL=pJl)iR7 zz94sHMj<36=&`uT86wyHw&|5$g0VHGd7lLfI?d%4^OW=D6A9Zg}$Y8 zGlg26lGK(m^(GEscc#>VczYJao&EE9LThcIbpT{Hg58dvy&V8UD6Eh2YnjeT=gZ1m&h&kgnFoOvxReydl=xmL@qJRVi-1GcN*DL{F&@6abEpQR~>8zkRV73o6? zzG^o~nA2Jj6M8bMFrVw_m}GW|MdT_ST1ANdCMLIaXnO4i$5Mw*NkQ@tCpMgCe#Rgh zfr3sVv(=fVFaVG|0AwG6ETI%IrVAjskTRuIV+it(b^(J>(Y{;IiC`Z>u)9MF%fZNk ztwIKZ{h<~t@R+^M89IdEU`)d{inC+C+@DJ={=tWOp_P?+Gba9K76YS`^)t0fIU?&dpF9DQ?%(&De!dzxaVO{qmt8h2CT>5#P23fx}787Q$kw6Pmd zcwe?MH@YzY2!`E*c`s9VPv9lbDyMegqaLtZ%S{mF`wTKi=+pGarA@fOY$AX0Kg;R= z@E3p3;=Kn!O6lbEVpk{w_FfF>@w$iArVCWfxX9Yp4%vyzTEzNb z?ONeai^99503R1~K4W*}OE_?f$!Ek*nM5V$Vi%<<$on!B?ws zVOg_$C8LOz+i&?7XB8IKHCw9$fi&&bc88hsbtRpG4TEM^`2=U2a&JF*;@C_6Ky~P2 z3V}X-LVhCLIwzPdC^SDV{0Hcmd65^=VzZc=7;I8`^|PFN6>a@w#UVZ|Bt8ew-l33+!{#zNRLDk?pTlq1w%hS9=h-UG`-I z&1M5v+y=IPS3h@cdFJ0TU5xNM&A1(?AZl*Hq$}bLqE_!4Z_CEGFPM+^MA65ea2~^d z>V!BJ%BLCT#eEcM9TG&JJ_Y_GRA?&S95&{nUcVRisAWIs*G$lqXIml{ir~?%Uc#0! z1Kuq`kjap|X>ixoK0s^*1PLue_u-G>fA}eKeFZ;EhCQ^4e9U3Plr&1fbJw8e=B9M_ zz>&-SBM9XE3Ag9ob|c<11N74tMq|?m=42r7BJXl}R4_WIE zDH+Dvr7KRnoC%6iaUYoontRnhqB~K&s4}IhC$-a-6Cv1K2Y6KO8FeZr`0RMp;=KS6 zR&U86%+#OMX4lq&2yO-l?!i!J=<*w-j)`(HpNa&?_8i;T#Ya_~e#M@FvQLYCzJ?6u zZSg*AWcJ|r7RJ_5UVXN#R>w6YTlATYSG4TR+;uJht`r!V7aY5=TRf%wz2B%NH_Y8M zIXn$}O>Ua%tDf##UX+`|R=zY4Uio1%g^M^ z^l1B&f&-t(Y8u-o{8I~Eq}C_MhQ-O6@ zVwU9)Qkaq)(2Gvpg{yoCs}`DgEzp;DGV`agK>b~TxOGSR-A4BH4sRUH_qeF=PydN< z_giYJa()x;pOGGmm8w5Qju%`M_iu&(DM(D8S zZm1+h13F>f}k^EH;A5wHzrDAd8z6a?;5$Ieh3UkCaD6s@pxWIox$K8$TC zT%^H~qYumyzF>--@jKyePFx;4psQYQlkfj*x$W zBXuQ|xlCzU2WZiyO*kJSehi@VMFf4GZ?uW`o;MeEOos(7P#M z$u}ClEQH7|t6duIZ%Hds=z5EVIgQWFgw9qur|VR_)O!D|L3s1lK)-QczucZa4?tiLqg%_wnEGCP?h!-5;2gd= ztvU7cN_mme!=g*K6l|@2{fPPne)aqH$=Ubzm)mit`Fs98FONtDi4@TmUu|;A6A}tT z1!=w~m*?g8FXox{-Vi`hWAciB(|=Sh@+y}3<&-_89v|(DjK$mv5XD%X&9C)T$NHqT8GrT^n8h zHWyxIY`$gw|S>i`*pq&b#Dz-fl>ddQVqS{14_G>N;5CJ4!8HK z_|bd5Q%#OL&K)Wbv~OD+-dyRJY5VifKT09JCA-^yIoC%hLq`KK7?ogTy#p7mFWVRi~ALyy8?5YX>A&!9RWX5P2hb9;q9#4SS+8 zmbKXOOH%rGWwXy4dj&nc?9<}fx#iujEnjE#$nC-2J4k~kr|dTREF(Hf?chZ<%eMCm z$l=ZcFBmP08cL9okpkOvLZ67pQ=UmiBn)*H21R(9jkycQ8uP zc+}&aRmYi{UV7}RxGG0ayiw<<;4PDk*3M}q0n^@@YZv#;28{m6dpZEf;F^#yS5=gH z^Owj%#*I!SQcM8j)d(!Kq!D5Eah_A$vZvFiKVD@GBx$3+zV$rb?1Z}(=i+7FTpXg) zP*>VD@uj|;GkkZ@s{x9fwbK05We*)Y>6$B`oll;L|NhKU%QNVmyvZiXrF(3GHDU+N zpL<+CHs;i5)xk~tXQ#-K$o{COz`u`6L#Y*6)FqWO(yAeT>$jh|&w|)Dvk~mqNsC2> z&&65wAanaMb)SH=k7%8$-}TQ(M;@KlSOIhJs}jj&&T3t$*@mjUzmOyeeYyQDG>*;v zW^KV%Ms168`1&@*H}yDWy}+uZmRgnG=rG?w-q-SBt?eKybl`b-9`r69%QPZ`3-=xi zD{qf3+D8y!+5hTo?OCVwK0zj7JAcQ zJoQGY-6U&r?s9=b_xf-ZbG7b=hDYu(=fdqPlR0IL@=VYjno{w*L?$)fA|y_Z^kucU;NL(r%)OCS*T=vywKmB;nKc` zUS#jI+gdHE>Cvom$$hW9kQkWN6Py&RH~zZ8sa{~9=+&=4x%$6cI{{-R!4negLctt7#flL4m8Gp%wBhW~Y1MafFjk+`5w zp|8bHOjbbEZEKl9t;cf)YSOz$r#NsU_yD@lP^ zYDhM$1(xB8iYTFZr8A(RQ|qp1trY}%b&Ox=WT)!F$(StGS#=e`kaJqL=P1?+5WGc| zP3zq*$t85EEx)t1s2)7O*mYlTOMT%%-8?k{caar(6Z%-60VtSBia=hZBV5q6-x>&AurKzV|G?Y$%}cja5Qv z>XYv%vim)HgSX;oxsTCBsc*vV#n&$T!f!^L-L^znHB1|oE+tzh*Q8zhT76Yo$SXI+ zXqfBYN;6a5f^vi7ug$ydq9(3D?#!8w(m4}C*1RYz8&6U^gVN_6-976bFRha2sLZNV zN-XSj=8`dZS`f<^Y(IFUIdGT9MMEWq92&}$NSD8P!*;&9JZ;ociJKkFKBVKEW~-bx z$n#%;4~H9ZT+o4ieYZFwqrq-UZNUqvQj^mC3dQZ_xh>{Wq=nBea%i+}l{VS>BprJr zMLfyhNP5fXU8XHb{Kl|Z1wFqU%@*`9K>}js>}6o@X=IT3JH$oa6YIkMJ3 z6;tjn4|yDRp8H679xglJoBbp9)9)MYfXh?omw%Kg)0$n^IFmm2IA$4rajA!V^`&h{ zbvya=JL<7cf1JJH&@O6*0Rh90A&~F-xnx~hQ%yxHuy15*1E7oCy<;;ewuS;!q zX9a@#%S$!Qw2XSW+vi&TGePRDtw;pL<(PelFu4BTYr8T5+cDR{Pk!*kN!7-Jj^B`P z*Oe|3HOBm2|Bf*yM2+Mw*<5fU47N7`BybOYYq&6&n#~7ichJqLpy_LpE6TN?7`B|! zHvea3qWW4R3g1gw^CX)CK1rL|pRC!%#0V@GM+IEIT#72Zp_eT_Obmu#`79hcBtphdB;V+r|M@S6Cn#Q7QH zC?%j-qYhJ2Pk(NQYG<3|8~o&HYwEGl5Z$Qv&u zNn4;q)dNm{?p>HL;9L%v{F8AnJP|JCBtTN*(~F#!j%N9DeKQ{nZ`HM-=Z z7aTlVp+~k}$81PRtyIK$+Ax$`)J*9`yBE4VA^MRm!$L!0*}#IsQB4@5JaLkC%#b_dzS!wByq83k=j5Qfp1f0bNlm?$nsvU=J8W=xy` z@p1Ur)$6~LVdEBs9K1&TGc9u&b!BP#U*r`fhUd0{7dX(Q^*?D~gqCCI-y&tK-eK-PB7d!7Dt$)L~C)|EL)7_m8I0dW!t z9?LZn@`g%Stpp&IMgxO$Ifey?ZnaTDd(pEEM#bK;(RIj#Q*d*%|XhMQ6d z?iqH0E2@OKpl=qC)KP|};&KEsx-a>B>d6Jc|R4Wjg;Q#d6 z&9!X3c^Ga&|1i}1(zA-bxlwlIt;xqJyS*OzBgtd*sD!)VTZ3VZB%;R}fq6MmB;a-u%jh<= zTi}6Exk;)Xd0o_?nDEXlL30oif@T6!-zzx~sB0lI`i)L&PX0t>SDWTIx1khR2>?1% z5$Am!%y_Aeh^}qmC{!|%VkljK1UN8(HN1qc9y&_q9n^xWe&)J;|8>7s>GgS5X8XLT z`jL_^BuieyIfL{oY9nf_R>B(2llrH~V~UPx-ugs<~f2npJw9ua0T`XI4>W zgt3xxvY+u+n}`!1Myb*_h?UgWhb%R3F1_E|EGG(q*_<|w!Aqkzs52XZ2Wn;T)gZLM z5tUVKRW)v3UWe~J#uOe?7PgjibF-3!rT)y(p;Xqo5$G_bsss!o0pW#tYw!_^t*u!T zO~Fuv{EaJ)~`$vm?6-23}^DFw897Jxp~bz$RXw&&J6?_4yxy!_;==Urh~k z6)zV1s!WvFP?&0oOI@So^M)?LLPN@vDy;ssYYSe#yHwAgAJby4%>-#c^^@%mx*t7v zDKewYL^F7|{e-scZ)>eGZ3*R7pKbd?-Dl`Tw2|Zmbu-~ALPDjMSRjLX)0}nRJTLOH z1yM9?4TNkQTIw(!sjXUGCZmzRyoY z+xS!OnqauFe(0RpM7i=qxJ$7qud{TBP0?Ev3j0xCYvD^{Q**vy&04*$2whAg27JMR z#$d0}GLm{^OQ^qxAbscX1Oul(l=YswbLUeY-Tcc27#G@hPlyo;4 zDAxA5FSy&ePqi@Ep7Q0;<@KA7&$yws+oO(N#}5m~W}k68XZo+nnF*s?tuwYP{Dm61 zievHL{9l#68*VX^dxdsL`4#+)fDPv=yd;?8=jOdP=a7CwEr|ZK51{~J>>9+~>!fyh zqG7M~2=cUjKFnN&CB@lb0?U5BIrL$3UWlpcm%J9nE~uSPRow7cJDf3~#sPwTHSYbm zTGU${fzEj|5w;_6}a+a<_Vh?WzT??Ge9#?`&h z;&wTmwZxQLe+=`$-nOyCMRP7)53L&v>uk7Yv|c1`-qk*0z3P;Yhr`W8_1unQt`s<4 zIgZz-=lnUE33s9S$vFQ@*Kw;#-;)ItFA>-(k-stIr|@sg)t}c6ZRIA*R;c|oO}_FT zFJbW(3m|sYEzLHEmG96;m<6BRK$a~`{2q|(ymZZduc94LSn{sMi&*aipQptkCD(H` zus6Pai__oei)%V>_O8Syk0xhglzx4^!YJKsj3slKK49%jhN!_-H@W$vpSRmV6{;>( zEH&rYc#9$Nd*{U`44&9EK`gxFt)Ct-x)f2V5jk#5Zw;dp^JEMJZn?Gz zYtI1+c#!@WN=Om^u_pep4Kh0g(h-2(^+)X?q*?W=qU=c>mn2=%5BE)@#<9u|4}s&2t-&4^k_YPQ44j!(A0(CjeheNDfcyZS?pQZyZnct|QoP3}VgQ9}yqTQ4p#F{=kL^$>@+XDNjgt-Py6U|fg_bW zYMRsMaOz%-p|&LGq+IlYTj>O{vBDZD$Qb08iUB!chXok00nC4={%vF6=@RTV>HYb~ zFDD%V$A_A?Bk;^HqC{r5QKkhmP9&XhIuuZ@iB%`Lwvwd5@wW1hRP_LmSv$y0vW1I~ z58b6Hd)rs_uCJ;wwvf1xYQU%e^^m;>S7_m69a%JRUhtg=fT_hJf8eu=Xvml`5D}Bu zdkOT!0HaGz+k2ScGly~Za1zw=Kmo%EEbMuk%Q^KwbKYN0ymb<{E|+_H zCYqVZR*~U3!9o<)azi3O?s%;oya0+Bl4I=b-{zm?Bw;H7&Ey?)1n7LZB=zFCv-Qa% zd&w9+U%!YBcB_+wk@w-x>?6(MQFMU1aX2$|cj|PBrj(xdI8e{raz&>d^U_)0|N5d6rtt)a1^{ zsiE{(3gw;AS|M(>Kxs=e5!47T#4}J;9^fi?5+CNzXPC4>!Ayur9CRX$TF@cs%Y*5i zW$K;HkTtP9#YIU}8^HdLc&DFDw~=m@rrwt2H-43_%s z5M)8E%^2jvA;=}T0Q_p@Fb~|UZAl3N#AR}O7BAriqVdjjQAU5|k)9$)$Hbh^6`@+7 zh@mv@M3K%|<*kRR-*+YvcBYaJUs8*zTy%wL?>jc7P-o<5~$5_=vS zlasGR#AD7ftjD}C#aI&tA4Ua$Mt)+Xna=kkCHg*J;ry-!*Gi@1VGey-CTn0H3M~Av ziYX1Oxz;MyR&rAf%ey57ivt?2fo0ag4s9dsL329jXP*iYj$5ARm z1~@$iu03|$fxcT|!EyH3znMNs(9}H=7F{1@_y!HcfOsxJ!7U#LZij@n-QFiC(&Htx zeQ7_@0~%c~x^Xk=S$y`qMu&$D$M%uxR!`&9WzZkHCdc2|M6%NY{DN~j2UHSt`HxZX z@597+O-)Ij27|=^-1*Z9q)s+|CJX?97EsVCkR%x-L%=jJ5L}wXVZOB$rP{O(pBERR+oNjckvm$%9Yv&Rn@*<`fcGd& zt}O1`NuU)E+823W$S5(S1IGhvZzbBQ<#ls-4jPGPt+BG^c^C1gdrbPT*L7Z>`n3O$ z1Q=prq4Xd!^+ucdxCP4{csnXhz7$k6ukUl_dJG+L?E&Ol=Jk+Gqa#jAblY2$YMQDV?f3x)I{e+H~#zF9gRQf>~FGshflJHF)|%EyPD8*@Q!i7%2dRXZ*_ z*T?*)g2gpd4IZv!j!7tuOa3L^9t}{YgB{6z>H((Kf?Mj*(v~j!hKwb-&E59`&nf_} z8?VUTPKtbTNAjMNo7;Mifp6h1f!mwFcr^*R;qd6e^_{J!{y1z$%4uFpK~IrKKWvH5 zwTXil#O7#7mzGOMo9>u>XqfIU(rg}BO==pqzyK6399bT@s~8d9cqxr|an8O#L?8}mWqbSaVu$ndT zdA!(;nsrhLn7kf$5LPB=(iIa;Mia;Lh*D2JNdxj40IGCI+cyng==w z%b}%6iI}KsPTBxyT;^GsTh8srf~A4JBiCeiWBzmTnPuyLrHxt`?{eiAm^VR;0g?7s zX6_A5yHQmwUCFb_ZQymYx-W%$+G!%+i|(GvRvFvlA^;VK>e~w-W@LyN{rcuh*oeHb zCIe|=Ban$=Pti`1UwTQ(-{!+K0&GWJtx7* zxbFiB=Ym93VLaG@4&3>@i~5fv=7Db&b41YB+5T_LqY6 zFskpccMY~X@~?Izp^c9^FNB=Qo}@k`k3PF`DaiH3CG%IRwqv+p=R2R}R}g!YLVfvk zZlYQ$jtuGgEqi_i9`Y#9vQb;4kdORmfGG;p>Y$uHl@3qk`R}-oJLn?pHTZ~So|^&m zXoX~iDP{=@zMbDH9J^_20Mh1z3@NaZ#>wMilkpWm=0Tfejb&!gM}{xdgbdQE#XCiw zA&|}};EsveNQ!#sfdOxQ0?q;eJ$r1__4ts0t7R+w@>BD5wU|A>gl*!r`0HhEdp4?j zz*b}feqExwnk%-!Ciip0g4&Dl?89?W>OSW?E;QY}&~zf=p-n^Y>!eHeyWg>@b9Ap= zGWrrgsVPFZoF@u8{+;1uyNpF-oyQOA8KfHhJI&UoTfIAFb;8O?FWBT#Q0m{>i{t@P`pdP zkXi#+D6pD8>1fg+UTa`&ilo&7KzZq-1rWfhIfMIe{bAyBzt?7g_TK8d3Jh^zt^Dib zp0W^)K?vvi0Mf2EOtq_Xy1X~_!Z>IqYvg59xt;%yLrZc1;oYcI$UBFR6`UV9j&WsK z>t)DD5!d6(I~)Fmvw`gw2CtxVyq15=UHVy(-RPVbl!a9Sxc9LjB0^oMtrrWGFb>g< zw{dF$Jdh%79z8}6v`1pOq<=NF$l8{amF?kbc9?5$}1g1=`wiAgj+^Xdox3%}+xhw$T9oPG<^laP}UbTGQ}8 z<u(PJJ%D};)z`d66wyXh5wU82KT@!EWAr{n@3;jp+TRA8ZBR{7y%MqQ>~Pr;vk4a$ zsp0l>vBP-Pi#j>oHln*^7BV!eww+Z;@=-0zm|aU;P@|;V27E};fjeJ`%qjeguxVj^ ztkkG!;!3*hwAC;an0%0q3agQT(!aLhm?rY_M3g>oyHAI4XX`2n(7W|ngi#^W=mdm= zY)6DP&jE!-Cx4@MeZBJg<9+wJw$ zgKv{2(LI?82=Z>)0jSq*pTk9f=O`PmpPqg~i7JcqV({1=^sg^X?ChuCziO_U4SDUo z*d_AcOo)2Wu6rTp6lg3I`A>r&Dx)_%)!H0i1(DqT6ad9`?K63*K>8GiwcMWXhhN{O z_BBa(CPlIv`nwiTmi03aMC8x(vnJ%lXKNl+Yv-0Q5aibH~!n=ivl^(Nn(YDSp7@m@t*+(@@A@;%d(wf&=`qk8! z85!Qaz&+~tIJH6aqRWTblUfl9(XI;{g}K|*y6RlhX|`mZ-6fC5+CYQX845q33r^s* zt5MfWPkJ*(Q&#Rwj#~cR>TFLTHglmwzS~q(9%gktuxH^MKfz zR!=$F^F_SOsqJn4olANYnk%-2fS}8GCc0$gGlVccO_X zCZ_XECvyMSJ<{tqrrcsrW+C3De3F7ddGa0i66JFR48Sz96-4re|hX zEe0w74bqiraysZV?7~QTAYVNXQG-ySfmZ}znfZEv9Hk((iUB5J!zPREi#XBz-h?PlM8CD62%sklD6;w|i)c45CwBm?kdEs_y)3o$f zGv+?+VyZ9s3UkLZ2=F7@PEVvKsIIn7h7kk2NH0xubGJ;U(oTJ7FT?)y}~0ChT^ zR3=H%uvzoFvXN4!WE?^Jd5^3=GsP_d5#F~6&l~+u+63LZTAUCfy@x7oj*guqeC|6S zT&M<@FNE*#nezNY_e+}t{hpYg+V|q*+?k#K(DF&!2}k_1DD7N{gD({~i(IZ0?+Q*9HA$QDeL?z}O|pV()H+~J zcp*!&kgTR@^)Mr+uTWO08LAuTe)RL}t{`P_?SVIc#VbOe-D_-|z`vn*nwa$={7ZoH zdjoh6C>(HQPo!8J9eS=MRbeU|jD9%=!~0e>Q{-6K77CJk_3Su6eo}Vw=A`+wUl^lB9T)R@1P^s@jV$1{qKnVm>=v0k5WVl! ztUdLjbzI{Ymfifq-LF~?jjkQQe7LO;>=T-J`?4r~r|whggPHg~&h;eMN+!`KVtE;A zuBL`AzKds#>pp1(_KDbU+e%-s3s%u#p{Orq&rug5v`!S@iVd z4Rf0rqF?)R&&(t#`^4QeQjU&}^t2JFZM0K&3gE8E^$fLOy&7*OUlq#uOvgQQ$1zBV z5ic*i&j9lkTJtx~SjV&rb|ownZqu5YbdjQlU#!gEaFE|ZI6a(9h)dVBcphVb@^%eB zP>lUp!HAHskvLlHKnB8}uBgCxebP&92#-4;r5AAzb)pS2U9tkDZu;(-s?-;ca|O#? zEoQ52)9X?abL+3p+uO$xPFa=wQE3NQJlZC7WX}DWZub3Y9l9#{_bc(7?)w>IDfe0y zo)7SXW>+8OLn3G1^%V_K6+ir&kMikP$~~QK$vJ|IR>UNg6XHc(b^Bfc)Pa?|bc2-g zK_`W&ueY626^7_gJ>ROAtv>zft>gfmE$KEi!qCLJ4mWHf6o4u-1@NMSJjoN@osVRB z$ESv8eI$a-5mnKNKkDBn#rBw2mp9y+NirYCv`-ZN$`DP)U#QIzR#}nzC34mvoNJ>K zzi<24e^al|bY1u`e15=sY@-n2NUr0cIRibi#y)Dn0GcU`C6zSDvxsDymVRwK%?3|~puiy2E|3llL@aK5(pw@&$G;fc z+rF+|>1z=17>z~cV%wOCodzkz=?YvxZ+wvuVg{IxEAqIKLyO1VS}z=kWDu+Bb$UB= z!I^st@10@gWL+$?*<19s0GCGTW`zR~YZ=*JcK84Vz;&7D6ISQ*MbEQ}@n=x%Z><$c zH80zlD)>Qw-v*qIN0s@qEdi1*X3gPoI}CiFjhx_IH=G7YukB2W zXoK#g*W=?jbXSKkN_vPLJRSf)gGs-0R&b7&9#xorDm-1pm?K&SX*meZr=^9_pv93E zPQFliyGPvzEj$?(O0^bAQ)&D0CDoe%89+7xp!1};#OJYSy}48;C=>HN12h|Zz*vM;X~!zXfpn|H6^xC!H8CKokL9}lF&0sDbHge%6oPpFoN*gL!{*QK*k z@ZiQJC0b%JejR|1N2zXRAmx}OfaLYPmFMI^=1FPvHTx3)=n*``V2GtkX6^pN)E;8# z@}6iqfxb(EwBl0jXzcxDPS6G{Zp?ZUqprj+0j0U&9KFMp*~jitLSLJU>TiI05~=lUbwck3>5i_CPT1%K|m zIxKD0(k!sK_i-7Y+e;{y!VI4g6KivR(u6?F10;vchLJ$JK`VDz{KX;zG|P{c2fp9T z+iy&gGebFiM;dIvA^+eSjrRJPO8I}```(e7#vc0^=Sws)Q_LZ@JN;}kb`&0dq8lEI zNe>1zrkJs2%zrt*O?(KQy>ds}$`lmf9yP<0jy|Hp*0+-zk(ixXSk1vZFN;@?Z@J(0t z3+D}nMx2;!^7XHAViI_bpg+>0e;C0oOgTB0DH$@c2~=b-r70!SZCUghrn1wNES(t~ z==P14`*qFWLn1bv1I{}M;uFO14{i%C#)f(f>;Hh;kz) z%YTb$N!E{TgU50OncW=sF|eZCU?{YyenwV_223mm?zuXiG6cYILHqd-Um8d92h=|a zb_~xszv@mW!y*K*y;svwci_nagCsCE)1Ex7@SmaD zEZ;!LF@n#O==$qS%&4mw`{BucYOYd~B=lt5Y@J_8=9qfyMdmMzhRhe_8%)k7U2&-q zP_e3FbpvGE2+^TTJFmA(^JW|RnDp7)fsBs7ZGIdsjyK5t(pI`cG5fTVd+i@1wy7+N z>8$uUCqbTw7H~}^&9e}X6NU-HvkYnav(p~BSJV(+xC2qZ1~nU=H34|!H2{A`Rn93h zK>9_>22yn6Z{4Un(=0 zE>8fWjk2{Tjz|H3s!iia08p(P>g&r1*+`%Ji45O>3*YKl(33bZTuvMv-q!+8Tt=SZ z!lRQoWhZ7T#@O6CU!Q{BFaOe?vZ3R_3}OJ|(L4Z$$(AuIEZlFC>firEkEI!W40ht} zZ+)dN9j2-bMv>>(W1}h|+`GadnlP*i|6l2Tr0Gm?%E{2EJ(WQw45jI8XZAIQx zFT_SDyMQ6`ru*q3vAm1iw6W*AdRu^>#N%@i&krGpE|^DD|L13n1N8@dOB(gewbSh;AZYp0BP`;XgPhEe^*4} z-ih*19sJ_V#v{jPU>C`CzH%cSMb|XRmAQc%Bb|J7#y`>TeQ8w_RnvS$b=^VMN@m1J))B>UZ*n58< z|HB_MOdIi@zS(}QWWx7gk2`;2MtnD}em1<_X1!F!wj^&m-Lw%xoA=(e9ax#@Io|-E&+6w8^Sx`o zE#zwpMU|l7mAMqgLw5{YWz8DQLy427b$o&BZpN`}+M+37CGy2g-B-OvM2S^!iAJK! zI4r6W5yJbtF0-`rhr`GmMt1sLL*HLw={u*!p=H=w{qcrt-1srrVgBa>&VZJ$qBs)h z#S@^|7v_20_mG#O{u4K6a6{AlSvgG3+*UR`vK>a={~?$uN1IDY`Vug?R=L4i$_GPl zMB&1j_s@tEb#kQ0L$Xdl1)Gjo{T8tg|9Mfp7E^N^rm`ir3;Kx9m2K>Nezrge_fYpj zt+S=EGl(|@W!b%#ux(YYQ(pYBf$g%n!=4qomHzSSgQ51JR1>BoJuA@5Hq$&pINt<3 zK;9Q##@6!qx5c__#AM%U?6) zmR_eNhSNFWLmWa(`q?$)sTodaTSWOZCsAa~Q3DHWOG8`)dW|aB z<`uRn$N8tAN5@W*SN6(UNDs@&)D<1mAcuNvqzC^-o-<>+tbsyWxf+}jd2EM4RNVwO z+vD!tSBIn16TN8h1AYeg9I~X5zCZ~%yG6=E{?^@rM`o=j#Xf<{F}`9~LvXjV=fUG2 zkIBazp9OH`&dcI~*zQ3@Qp|(*#1m)c9>sWjBqqtzy8>5}_*~QjUZ&7rEj7pt?n(h! z{L09_ju2mdju6Z#FQ2m;JWk+>{;WdKNW~o`pjq zABL~(>JxzB+xwGAZB!^4WM3!<;L+=L!GHgI1;09BZ7UpTkuqC4CXUndZo$kfs$9fs z`3@`_6oAuHC?=6ZjRCf4D6FZ||5Kp{nY2pNVw9InYKvEqO{>d|s2v)-(%Bnpp8bPu zF2a+IjIO51TA^t)#+Sf`VI4nClv})jAnyzP*>M;uR8+_`y|DE+-j1F-1WK9TfP*uwJr`#O-&UG@*i}y7ZxA96S%|*aYrb|qD z>fW_yJ1$PjRiIc>91AXC#utFD?u0sz5BxK_N*Q2c2K1h+eS3CY0#%!B2->enYoFfx zV#IJfCT!IDga2i*Q==yv(BxOqlA~vTPNg2O6i$Iv%rXiyR8ow$Y;Dtop1(jMdk5F@ z*ocW{JQZz4qV=NCo&mP<9&7?t4(e53hzA}07sD3$Jm zP{D0>RxqK{4Haj^mKp-Rj2vj(~psNjJSN0f=W2s5{0ZPD@Ws z#9wg!;1!-Rxa}tlu&He{wI}C>P2W~B3NQ2LSK!{ zyi6jQ2oTwk*7Gl|bJpZ+WBx;AwKLJKYvR&VgVAvL5vV?+>0Vz9zN7zn^oLVd0kxUC zh24VVQWW6imL6EFap4sSZ-x;i%D2NDmJ*bqA~#V1%qj%mkHl2LRXQHv>9K|5juphpw#!adtfkvhW#8M=Iedqz8By9vD+QtCcJq6TP zznTLvblQKedS5uqnCHywV1Khg^0XWc)AXKa?jOzlmx!#JHRwUD*o29a1E&hBT2iD$ zs4q9l%s<#FCs4q+Oab}}Ou#H{*`ca1lzI_YQmKF18?63m(JrouSIGbo_Lf)gy2uQJ z40G|2f_*eQ0ZZm-EW-P)L@q24l=&|RYKHJAqifw~ie|VO2H`y&HC{SFqR0H-t2 z5ax4BN81Cl3h-DG%~qe5chQUI_cUPCzW6kC;_cNS- zWB>p+CMyxhJE#{Z5P7f)u(5KkzQmrM6*YZVEq88Z0qrO_a!BtNKed4?*La!C{{~ zJ$)+^#Fn=6;ulNS6*GB5))+njz~vk2@e);Utcjl%?Z$G#ZJ6|HP+KYBos5AOcb21f z{ml7BxO6k|an46i5Y`UjcMv1SX}iuZq=QYl?uw%09A#hX=UOs*YV(#VGL8?Ct^es! zYs?dm7JaB*&}{oOzYsudqu+~VWLk)x?!G=eJ*12fE>=U$d}o|h3Q8!TNqWbrHP}%@ zX09Edsk~-MwyJFqKk0_)Yqc;w@Ma1u+=>8z=Uq%XOrBdq0Jl_Sq^jyi3O`DyHH$_E zEPx^gs4xlStmMG_jg3e8B~jyh#6?c@-k6h3H{rz={-~Yae5mm&ivil$)X*EEiX5eY zi@H-4j2qb&-lLyau9x-Rd*H(!=uL2ZvxJ8L%z=oGDl{6R6NL6a7V}ayoV)vFUyYZV zQodZfb<)+?$OGCltez*$ZG{4mN)W;G5*(Uk#;(Apje8D)_YbBW@wmQP@{0l8X)d1izrXXpREJfZF;ze1N=pK$pP$E%R#>*8O3JOkISA8tKsbmok6&falqL1c2RG}Yf#Ty zzcD}m;R>|K0I&VXEBMbTFRb+756N?c3aXAcA3HpN_5!bu|OgIOc+N&YMarohkv3i@z*_ z`s{^*Wia4SXzTl0F2RZ0$}mYiqr$2^A2 z^9^0z-1(OZ+K{xqi}rqlx4E%DelKN#Yoi*cx?7vTG7gB|O6{u)vRG99T6M)=c|n^i zuf20(R@9Wdp0)mAv**uybG5e@R?>AZCRg4b6Z>x_XKe|$VLoJIs1_cbv!t{_0!9O$NWCqhDAnx-@t!VxwQbGC5pf#J}Kkqv=HVj4$z#$JY{yfi6 z0fYfyIjKkPXJA_!jA09)==Y6{oZj-s^$2#%Kq>ad^io9K$MI=VxhhIm$nG7|FqTcy zEqn0(ecz587S>3~y5gVjchqXmdS3l&d60ght^SOvof)9A$X{fhQ9CG>@mb2PJiPk3 z*jMtYeY_W&g6)!oxApk6ddFJ8t`yxe&*gk7x!iu`Tsedu6%V4oLkpm+x%7m*bM@(YK&clT?ByCOjJfBzo5V8xItcm0b#?;yu<>L!U z8Dm_Xqw~-c+fSdVLAy=q!X3n&FJDL(Kf4Snr>U^6Qci_RN zACfv;I`mwA_u*1sib7BrqHFNT0MsK~$XG;AK9tLtz-8n~P3OfaPAz8`54)CZdF^>S zc=E&GR!c+$&*`P%n-N4+D?}ALF8@A5owYoP2DW`L=un93)A+j@snh`qQ@&>fKqV%! z<*FoP!kLj*_k^w;*RqB68aor5Ql~=(#O$;wIPFIHk~7+RAA?Zy0#6fcuiaC;A5~qB zixuTE5eU)4R8h%+osP{4B*9RX&E0Ec?;Q-{4+3?))RZV5#tiO;kH-&gKtx))nqHUt z@q;uS5RjLyj`#D`8_y&{mQ0ROY1?|%UC!c z_WX46sK>Ly7^KT9=fv5+av6QEldemB4Lj!xf}4~n=yUoTMPpXhR~p}?ZmSy`DM z?!E#8K3g|ZH65K|g|;q6x?fK|c;9dAdc5kPmz0#p!V!%|wo5q4v1QK(R>NmFn8eV# z|Ac$?#eK}cPj}m2v#AzchZMYz{d5PLbOG@{3(4Z_mYb2HW=6CRYG5~Z&gT_-WdJl@ z2{JF(Q8egz+N1=P>p7KA*(>3UnR32NJ+o99x#>gE04*`SBUH*rakXdM;Po3ybAEz= z%HqI0N@remtBIn}7@85)+>R=ZL-7xb(h+;3%8bKiIR~d`*&^Y9s%$O>lRh6zvHMb1 zzWDh44NXJh)Q3S~smAdiGJDs+LR(Dt7wX2Ox}#y!6#8QbAZ$#YfwfZ-opTX?t3Q;e zy|3z+H)+qF-6iGbCFM1o4{zQj+9n>bvm!E7Is_ui9Z$cN)e=ssn}JO{L9FlQXKIG5 zl1?|Ye3t-AB>QD}xXVD@LOKMpiU!=)>rS=g-Ad{WNP1l? z!!3~knPTCOTOt<~2Hw{n)wlFsaMiIjp)Z(=*~yH*YsbH^PF|3GsfyAG{q%5o|I=u< zE2PI>PGZaMUX$dJJ~A2H=_%4#%e-9e$W9g+0FwC^*cx_xjiJK>9?3Tfk}1$7iexR| zX#+)c1tBsgaN~=Mb??QI`havONA6~THJh$U_ZKQal5bFO`{}}3TW%oKSG1Lx^jEL+ zN$}z<7GeZdp59zPz5M|f#elLINOJKNlM1uPYX7KgtaeOLKR>;CYKLLU>X zC$!8F?Frc-85xH+@~L)635fV9872*qA&xWSnGIF3*f42rN;tW6ATve9X=poa0ngqs z!Qy5Bcc|$QBunMDo?^fCJY4+gwq|(`L}bXs>@wMM@fS)$FIjl`%wFWQH2Jt7IqrNU z8fUYmx^6W9m%QcNy*3AX1Si1YeeYC&`%YwZq>>Hes*(DUpMbeyR|#ANlpP4EvfaeA zk&d-tYG8a7r~^gOe;m{Bp+gZOCa3;19(Ty1v!?W@}L zT^^yAk3?^2%Bns(`%%6V6G&l#Ji>tlonu4!9kQ6m`TGX%xMn>`c_?aR&_l{(wu9CG z!a*Q>!V2SEPSkv**!HFy7}QHjj)A zNUHx>sKw8tK^c19$r6oxqJ({+7iQuzQStIro>MztSnblRT<-C(sj1}KXBD-CPa(4E zDx>YCVcTauD1||o1xyhPiLWPfyLO)edzrPoq<_p;{@MVY(;UL>#wD~rT(si?%p~?O z!j|zKV&+zgPV}2Kn~5W%uw;v~rgh>jfa-&9KLnjl&*4vj%^vNceArLCri!R0M82zA#heqZ4y1W=TDRf@9Q>(@_<)n@^24%?}8&JjW&zcA>dgPuF zUNk54voG{dawJ`DD|9!jzJ9{9z694i8!7ufTWH*IIZ`85yF7Mc{KMJRx0{-!XTM5- znW;oX4?D#-oS7yYo>T?J9TgB@yAEG_d%^HQm!GSlrB4s2iSVtHD=J7x$P|TabY1x! z9%_V+E`l7YQ9@{w4_sSUs1)lrwlHvv9EmT8#ba#Hh7D$}1^bh&oXW(E@q6z!jZ$?5 zrE!GjM*+|;Fmg#QWlSy}%JWGeF`^GzZ^Fh*u z6sQh8VDRCBgtVY$X5hBrosSYtT7&0=#RJOb22LnjxhEEgetf=V(;ZbMHn@9bR9Ty6h2B(dj3e=^z1u@xo$QV$T3c z07UR3 zVvCwKO$}a*i7GMZ4d%0X9SL$0{LGX=@xGy5$C$W*Aj0;NgU#<|7#Zwxz83Y-hqmwV(0*n8Y39@ zJ-1Q`PRw-=z2Hyhs5@PXtyvc}mQ1ttR8osqrxjLX(=a3341<~_k#{*O{K(ojs+0{8d%1dN>N;tTy5@x3YxP5=q3!tjlYhj0 z5kdFwP!PJO`h=Mo(q01Z!s3Aw?H7;!=yy0qkn=vNW)R@&Dkm(C`>s~+<=~Rm{LIPG zbek%k%q?mWWiY^EhK%n$&oHBn5R=9L5!>#dYg2rktkrOuK5gW4xiUol*IlUY z*~~8}Avmc~h}UME9-lnoxzRXff%qvFy0@9`+edr<_+TD7??a4<<1>Cb_e)<++*XiM zTwDQo3DSV-#LTnIIp6eUHv z6}(?rS-q8NF%(9%y_I5|7+1F`T7LnWIu2-7KapA*DT-bfU*W?&H{klA*`IZ`!4neD ztLIcd?$ICnA4O*#4%PR^@!8B`7-Q_~ESBtzB_YdLv+qd~W8Z5m71Gz3VT?6fNUE_W zsm78@so5D?+AzO&q>0^rKDw1@aB4Scc`ECB7!#gqkkt3zZ_UdQmikhJd)gTYE?8FV{b7sIBf>SZ13)9S z8VY~e()Dardgk;|KJ3AQM1)2HAp*Usl3-xzH=FkbZPmmRk#y|=rf57#&Am>Edy*u~ z$;^-{!fTZG$d+elv{-6tje;~C-m;^o^_;c%w;kVtUZxa1Z{E6{TIizDF(gc$GYL9AfKJ{EyZS>^wC7YP+ zG3O=7w}t6J)UEvngirT0o_$(V9rdw&;A4GYHDrklAYht3MniTriUld;>(ay~{{lkz ztz3Iyy#4b>HZ_lSE&XH*Fqwi6f2cKSpk)jwhi~^jDYE>|qDir9HEL-=epZluvAv3R zDP4rOD28T-#$oi+>E7TzJ%+*$AeO(JA;Y3+u`Ly+GK9iU!I5M@EEthPMGUpd=K$b? zq6)vQ_U{7WD8l|3QN61aMNy@2?>p%wT7>_?H={*Nlv&^e5&Uw7XgSG^{Nht8^4SmG z0wk}hdsSclm8jCZCpE^M;f9ZRp^y7vX!R9;?&dM~tiQ@0V;e07f?3S91=eU^Tlg9j zHJ|3)uw)KERdJyd&T^6EDw_6sFG-!MCyt+g%ynwoacjz)lT6b1CjYrsvgq63Nl1*S zWD#EKmZe-XMuUyHx$#1l3c;5=mujdzTqvAsGp2dBBtUm)=LIqvIbyf(vb-eQB);j^X*6t!|Ny|sox>os{ zht7P*7$l;~sW`~3E!mO3<{|U*Pw~b-+)ecjCi3?)ob33xb^k>!w$}YWKj=@4 zlAu}HhUxxr{??GAYm%)x0ro!rzr75NvRA7q%vI;7E=p4mw~Q_4Vn?}$HCg%=I02hJ z^Hur*>19TEp|W=Iw$aTRW|FA^-2hL>anA!UL!EjqwBPq$(rR5&)={=rGI$XokU0#Y zSnDq@W$zu-6tzQ2N;}p<+QkbjB(nuu#PCIy*XJjs279DhdZf;@?Tg2&53H%39g*F+ zX_^I;O8DV%p>Rt6w1)Z@Nwp}DC`}rh(<2!h+}uNM?A0uqTg#l&l>gFPUTnS}A$zk~ zYx}2qIYE|>e|HrqyP{XNR-?%2k=Y@&ic_ggi@2RvyFxvZ8a0>}fM6-lI*Nr@uGJ}H z?a?m22!7VKJNHFkEx5vuGt4Yvduk0NH?mC`c1QQ?$+FBLngRwKq0koL*d9Tj8^Vhu zWF<+6K`t*Nop@y6)+^-xcmd4XP;n`(YFoGq639;QT2ft0OPxT4{rt2yIwV_RrZEPd zuHcP2lrEjMxR4g9?^@9QHyVrzCgtt|1yQ|n_g~5F0^Lt^%KL4)9hby+d6`Fj!?Gu2 zy>ER}1Ia{hVh4ed8B=oIKpCwZH3=<;*vvp&UFvF~%s%Jd>NAJ65SHy2%_iZS9nHxY zgOKG6EH_B5w`R|HsJIAJoW{%U&PflG(B?su;wyq=);|?a=&v&LtHqd=%jRSb zF5l@H^)_sVi#@}|@(d!6cgha}!v&6n2}nc&Plt%4k1(zl(6ku(z7_F)E-@${z6%_rtPaG+Vs|zN_>V4?{SgT|%`vbc~y=`A1{$HhR(Q8qgH;PRe(3TbhVM2Br-Tnjz zXXn*kcBgt&-CaXxH1;Iwp4;C&Z9s@?P@F4sBQ{%PvlA74Y8MD?)Hg03IB8CEkb^{H~K-YO#cdYPhG+elDvwS zJOh;Q!DGy8L<9k@{~ZaHOoW@3!8>ptOp|rrX~S@9bGcOL-uoZBb@4wRquKZSj*HaB zgW&|hJZiXs9_s7mFp(Itikl7Y&SJKJ4-QIUT{%h>NF^c5aD{jGd$cJ%?-jbNk!qcd5hkH=GeLoz$jrHw3&j-o|%SHsvfJK7k8xLOJHj2!VjC|NrQoC}a z{yDE2q|rR5_R}7ko*B0N)8f2MYKb7Bu~w>vpg`ztHgoP@1In{&B#iJ9NtYy)Y9!uM z0n#ib+C`oB>o`BwVUa{<8-w(gv8oR+q{p=5O0Ofo!x;NW#vh5)W)k(t7b;QQLnfr` zQ++u6lquTBom>~FupWq*4^-+E*nznH=&vu1_UUfQ=oW_vqCdR2zX z{^p(%hz-fjL>{pEbgvvvG5-mT_52LK4)IeL@Na|p`<=rRB5Upy3mj-rd2s!hVdR9= z^{g}UFMLI_esgR&u$2 z)f=m{+AP->=dd$#SaTqTFlS2$Li-fAAcp0D9Ncm+{717*gS!sJ!6rMuVjfh%i$ESR zs^C>Q`QZf6n(T)uY-UlJ>Ow>>`yj;WGCx7UgRJ9`s!3SQAuIgdj{bScoWpRBGzG}s z4RF#=+WLboalHARG`o$Zkgl9~r|roa_Tt>d&%-Dz7=;U!w>@dY@>!uX37=F-OM{{& zF8Y&ld1ZwE%3l*`VOZl5b#NEmuwh%8FkF##?QdgFXM@GXnM}NtZ;*s9J{aeqQ7)%J z^O2j8vUq!0tJhz{PR8DFwYdb={{yJlKZhLy%58D?5%B6I!4lJ*zqIZ{T9l99;0Se4 zMXNZ=6xR9qb^XuEY#4bD;<-i!97sYctr8| zwz!%@PzY@p948)T-~ng-gt+lb)I2q2uI;zIXZ0!=Pj?tHxU7BWiaI^MvO2!3`)N>u zfp?!l(+7hw1=rF6<(Qw?`kN-}WzX?+O_x6r_}cSV4t=bsx&AC@G}|!DYpOoWha1(I z{<`gjz4_2^S3=kMVa2Jz>Q`5bAe>eKmBcFONubE^HCSou0okpq-J$&;P}VDcRu zrhmvyQ|2|iqBoz^#6%9iOfRX2JvjZ9iXm8 zMSj~pLU=MNDpuvJPK)PLxaU!|h*UsDxjbR71wg3wy=d_zn$xrBM3rksCh3P}bIK1r zNc?!@Og%s2!dX9VkgT2_HsZug_V#*P*;IA@yv6I6t*_*Es=KA^er@|NO!UY% z)57k)lT4}Cyq6pFpRU5Io<6iy$Z)43w`csT95x(?u>oSPewN(}BSFak2yVazciuBc zpiv0FX^GV6VOsyD-{={556t|Kaq%(sPSMpNgH!2H*kSPT6~wbX#G0;Jg&y82GapKA z1jluKi9$DJ0%|idA;K86K{AipgOb=iLS+$yL2xLZR77dD2It8gjNXc=0s&r>!`$GQ{VO9SUTu(+O<2s?t z$3>&pHb;(kc*yXmwSm=(JI}1P+SKl3%{_)azU+M<$ZPmn}PlIFU zOvjDIQFpoM&L$6}+v2rb1g(Ib#A|>4VGvkeoq(ety5e7Xi~0*u@YGbJ87* zYOwL7%Nb1baWFiET!7InpGKI5L+_S5oz`oD|Ih z+DRT?(DEug7%>nktdsi5R;ud1Z(*L7D~{C(Um2PFR1ugo{#v+tmR0T>d_8AVgq3wW z-1RueR`S^Kyh}Eb0SsvLtLSSgQ&ak1O#5C~bljaN)QH#-z2Ly>@4aOBA+qkd?#Uf} zDhS2V03lz9j$0*-e23Txg%`UtDmPF>NS zK*NFLg9{1V>%3l61t7lPK->S8i}@UH!p#iQ;C00B$Up{y5L;B1?GXx!C$nxo;SYE&=)qk+Einw^>Vnhg7U#Mu~2TBz*divXU&*%)Xq0PsM`*gKA#l@B3j14Fa!?H?viXp@-w>s z*oAsN`q6pSt|ERWNL1m=wUw$?oD`JRA^p{?dk0tphPe*!|>Ey&(A=E3m6T+&c ztU3p6FBvg!`7$oed9sw-jdbb2Ly&g=Ci(;8a=zqUPHl+t9A7Ujm#t0~Uh=y##Z{^B z8{>dTzAPH1Y79bfuR(aTL6Q&M2d~(*BZJfy435YRg*bW%hlNt;ugT8A-be0M`Y-8; zn0lvOT}QdS1<4O3gE6@HeSW+p?7ID+VW*`?GLgRlVA6z||^33S+L^VW~NX7AUSa8Cy>sbv; zq-JEzxv#lL6=aCT>GdeWE$b^?^0md4^|yg4CRe@&N0bzV+JI1^PaQNmSlot16=I4* zX_bKzs%6ws5tD(?y3tnQn-lCH8s_bI=~2s0pMW3LTCju)ZVjy=IzGGfZz>bHyFRMC zVGaEUReIuh>xOIhR<>SwjZgrGhM`JZEAXf=8yraFR1@7{Z`PVj;xUN#0~Tk-krXQp z*9*~JEO#Z@@nkfKtXabf2tm2d)hWD)%-+`vVEUN$Np)2Y9+(P}`>?p6xXi6NE=bmX zZ0UiBBWFr50R!8_2=mU`9v9y+kcx^2(bvG?u$f?Ht z{SE8#4rx$y!`X||E8aK;mby)Z_U|qTo|5j9-tEasHjWm93xP4q5N$5uJ#mOIDHLnw-529Y4}qM8h)o@h3;$liQJ^pMGAw*f4mU z|5OCbetk>vjHU%-nd)u9q2W7NKnoD2Ka~p?p3WmQB8wbwC5Oxhb>gxMDO80_2Tbw0`E>(A$v<8 zMlDaz_v9l2&I>JK>l|c;?UyWeBLr>SX=2rt{i+*0m?>_@e1_pTYIFd6)MMG#i;M(c*O`As+31ag-J8$1RvqKVBq>3Rd{F z@y#GMJ~EAg`0`L#-pBp7I6Jjd%P@qM~?#b>zZEOktA zV-S)Tn-08wa~i$7*KPx68l(+XUIR;KTY#zr2jp`wCB<6BnTu3H%I~2Mmip*eScq;;Iy!h> zwRLyk&~COlJb357F>9}M z)!qo~gxcx$XusFf`H0bJ6~jbK0^n`pPC-CnG!R1univsvBLi`s#5o2L-v-39fs$b0AqdIBpE$L;}0xBn#aQfQKo%HLB}BMc8BdDITG00HPS@!sNlI)c^QoL^4lg zY(wPjZ$z#dobN8UhtZm~|mT$=_|bAntObY<(}`LaKc zrZwnV-bz$5?<1hW;RomI1(HFJf+hF5t5>tm2J& z^{@)BZQdHuV$Ry0JZcnQA+;(HSh5tWnXLWO7J#$^tkBS)9(xc52%@Ecg2`X?QY?O% zK@(uAhTn0 zcxCEI4KE4JzT(58;@+feD=OIEN`OI>z3-j|I()Pah#WdtnsypVlNNi@5whNgtRiFf z{9{xqEFGO=y)D87oUi6k%6TGv?n#F$Os+kxSy|9>XO(WC?qCQ36X{ zPp;=SOCK1)S!R|X9{_Nd)QI;Ogm^OV)rApFRB#`u9BJF4tuSl+un&fo@{?9;=3*e*b#suH}jwiqn?eIjlk!wI-?x9P$ z1NV^oBAnMg|M!rX*Th0n+LOK&3EwReyvY`9;<|j{q3)s)S9m@`6d2;a*@mZYh{lv3vvn50{p$hLUy}M*i%k~8oejC* zv9C^OOT^<~gGV+%8W$za0mzV2!mAoD98H1c08ixt(Ny5ww?MS}b#M-F+tc3VQ;Ll0 z5zjEZjwOcXh!MG=WJ*gdS5`7$Lksk@#l4pF;lsn*L%c!U#L`<4t9uq6>}LIkTH0;_S` zbdVr507xGRk^z9ki-3`S?czj$3=v@ZtX+@=_&(5Xo(HgU0v?t-ZIuTQr!;O<0xRBJ zwmpsORU$~f!Z%#!J2V_N(~4J8TPRkh`p7;|ewrw8ME8nY#>v+Nh~gJXD~kAn666rO z#Ec~Hf^Cr;CYAC~>UOKpj!&tsj74;gV27;Op0?51(6{@K$U|A`!46>#nyMNk*vdkV zQ85np{;NnS4dCA3lM|s-USS==BTkD3T%kbd*D7jFf4_z57Q%-RwPqR0F?HK%Y$SwARUs~)1MB4 zZ_Q3Li2p7&)p74MatGbo5Ya$1yHHUU;7|h@i?k-EG=a27Hlb`@a}@xQf1h%1=KhnB zn}0WiIBa2FkqDoIY9k4q)I|=mkoMP+Lo8%h80PXh)@2;L{$WlCIm|>|nt`$M_eyVQ z4VSIIwek7bS}3N81+z6qTxB6&P*LYrQJtrw^+PZu1Ascm$G8YAVGD62L56E>_K%$y zuRLu_9%jS}yeQFh>N>+Nmn#4ANa~Qz-|{&gwJ_0KKCSi z%*_3VxCOyce_b5Q?pzsA)@muSV)@v$miQGNcMTE#z|P+2D>YV6TdR{obs!N2Wp^EY z`vF3#b2lIJg!;$K-{C}d$JA5Qkx%}JG?O1pa1ldfOdV(J1qq(lXjqCvO#Oxh8N!+5 zK~n_oR^EXWJ->)m$(JnWDk7Xtg{E*4YuJb@B;-($z!e#wV3GX6IeAH@n3Qe6a4+b2 zPym%KrXW38xZp8+-{ydy4QKvoKv~ST?&AQ1k#|-AeGZ&KL6FG&VV4b?agf%LP#d%7 z@kpS@C!2L z4a-n6yG%7Gj8B;nSz*zvq?!{f^MhsZ%4#|Xtp>}auKE4ks z+4j-$TgEH>VC3!=*bX}^Z-5mx<3;CvSNIz;7t zwYXzESTX9x`}R~T((t{5*?wHfM-?K2XOR-8N_FSns@AAlb!JoaUOpa103a>RV=p20 zR1OZwAq(;M*5<+??eB;uT*M%CNYz8`+*$;436Y6IP3#r-aEKpKm{-{_(y@e?W9kjI zt{K6WeiOnMKwmt*k8jgmuj=v*dwMj6*ibN_P8A3!6r39M(B?k}+3B6Gc>YK+%*Jjb zqaAAdTNwMqle=(Vdv|>jrn4VU1%ip-f@5o+PC^eau4z-|RdlAZ@5~j(iA@D>+$Y+4^AU5|>tp&!Z9={7_raNhw91!I1u^!CF;zVB z^0TonqTsa1XRT`k6Wo6kC6u9sa2*RS(1j@BAXLZUC+G48Z4!%Xr6`U1<-lKMuReTG z_|@X^>qC)MHV8e_!B;r$#rb+a1t}V&NfF^~PEK5Z>14szm7PgM)fqcn)VUJUctfViqu~{` ztA7-8)Au=>llyA`bKAcnP34fJR-U+%C;UNi@PM$SW@ddDUU{{!OY6*rJFU;Q)L3D+ z7(G|Z>%kCq9~jvaY;qVnp7+)8i#TEV!YWGrtG;oIpx%=`e}+a3ZXT~YbwHfqOwbFv zeX-{i1R>Gl`>ZqWvc%VG&4EvLfqxrDwk+JbO#jLK`tIBB(Y5OW-sd*^=-i(oKKQrr z_C5269~2Wfzv+dgePgo2WXu^5ar$L^XTS_4&^4$CS%aRshv1_%Z+_ zWnIN%UEKXbMu?YJ_6*&oiR<0GS+DoX-g%T0TBve)10klCUXtLbakD?OiC$Q>fNd6J`VUWGgp zkb&|XTec}7DMt}7&s_JAOOmH6PUbL z?V}2zQdu8Cf5MXIvcz5hf^zv!sQ@7;AOnCVE=y;jNYE4p_T0|;5dQ9~X8W6k{W+pd zocy;pbe?aKp~6OyT7*7(N(K>QPRVFfn2J`*QI+qRyQXkv`ls}{%(p7zV^)7*mrfQH z3wO|q95%2qQ-|Q?UdbL=6~1XXlV$E&{|3a!hDTo1xT#dBzCO3};QAHssP)A)zDMDD z{RM5nQwaJUZW~sV&NQ~K{B_2-i35Dts%kiT=S=6ZaFq%*Dev9l>YpdOjES6+SI1Sa zgml3xGmz3`l9Fuy#iss*xZgyj(+9f$;U!Vhzh~!ydRrTSa8?d*LEQ!r1o9rKq~;ve z3)sF0Hv-Vp?+=mwet&1i1)v>A?$@vDq96Z-WJ>Lw0qM=&U)Z{L$Kt=9-6-;)j7V?v zV)`g|uYH;Y5Fv0-nJhehi)!P@sT|1ULUYxf!%&1wlQ|O7F(hxIik#;aRf})j9@1_u zuL3GW`*)M%bY3w!?Jow4b+Pb9YfI|hIn9_W&F?G&)b6^BMaZA(sMib^~vs{X7TaUk5qRc5FO1d!KzSSa-U ztcbCwt)24FY^#0Y^F-#OtDj50{QDj`%heN~m8kC|P+j_9XG8MWzHzJ_H!?o>jFlUlRRF>__VL|yX z9oh722l^w>-lG;J>~e)k1S*c5k3LfbF*K?(xM%b{+5q31PA*lZCkXE^k1vYY0>yPF zGc=erf+6k^vKIc=o2vA4%~OK2d9kk7Wc3QOT6lX8lfe}9F@Q+c7tg2mZ{9pXE|w(2r$H#aLz4Yu0y@Rg>9s^{ z#r!&MKPvTcY0jmT4 zV8|gp_esKVD(JxFbbIewjK|=zY;XbaOSaS$Ii!1cuS}_|57kWurW~gPh<@;RB(tvY z;Dw4#RcBeFzQC+&iX*km=DUi8_KHRA+1Ii`r&TBGW*vM9r8at}f^~E$)~cBRA!p{f zT{DEz!KIIxUzlZLiVfD2D?tjUsp$xtFuxDY&(PDas1c`ck~f-3&x8spn5|B*FZBXO z#BVrfp0X@F*Rmk{#)zr! zd`QrG*fRUt%4AYw2$EtyVdA|jXR=$vbW`Tf3!mCLtZtOS+#rqD$$hG_E_gV=uzHi* z%ONB>F~=DjRtXalmos)-^H1`u^9a2&0d=x3`g^|EIrOOgg6OZJKmSe>u2c*!n!Ub}U+=!7 zXk40iG4$pVJy3mL{)ouABSeEgu;YiHJ}NGreiwHH2ddy2+|#pzSsecz%+ttJB{tr+ zqS7UON&9pKf?Jyq1uQA7{fpY?ZtSl`f=Jl_4%H07K(-rB% zq)UQ5esbM*3GjotQgnOEWPBw`3qVI#W{7r+djlZu06BSp+#*RK34zkmkge~L?YG36 zN=sdmwj7~0Vw4Bo9e@tAh0v{k)DdVL_Lng{)j`E$Me%Oio%AuFrxnnn3Mh)aOy$90x8oK6JdIX97hKq*R9!#J&Lrc{{%N!tSyaRk^8Wc_&ugWxu|TaCwf*1l{; z91#}C6Cm3&qWDZ`2vhC^X^+j?w)*vP`gAsPfkB?uTUUCLU#c2Ue3DMe-f+@SsbqNY zP>1a^yDl+aPD2hdL4)d9$C9D1sL)91YsKvZa)~sgq{q z?jIfbot|gbfB~^)D*{WXPn5qRt(Jcc^Q^bI%TRcMZLr#l8q4sH)|^0IGH}L$SFE0K zRqzz+gRw{<8eNgnsJsl+NG`+>jQlB&N%$Hj@i9r9;l;Tf7uo;y;U0EYZe=T5^PPDf z|8Mq{B>4VBsJpyvVSCmXNPDaV7GbY%o2F$KJ) z2LcR=f^|FW673Q&a*<4N`?-M=J%l=>dW1Y$a$aBZRhB;0QYwGwbpB9=@zCi-rzi4< z(XmX<nUzlM_qdObzt7O@Gs^!9%US3@&u7EjiI9{P zBTuyU{;&7POH`BWM?83RjbV_U5%XqRyDJdphO3PlX1Li49L~9IA8!!1JTMa66f7<- z`3Dx7#t3saX~+`~HXv!3{C`ik)=H(b0V4n0kvuAj2SEMOxRYxFxAzbqpf-g7;A+ZJ z+VxWBi}{|47fFivjf!&VN@3IH^5m;k-0>&!p6N24kCQBwR9-3na!T0Y#Mh$*cskFj zodqZWL8D&l+RB`#fTAdPQL(*{FD-BNs?H|8fY2k=~SLhZhcP(#mKO zNu)9Y{$;mUcPKA3wR$sr-JuN=YRiIMbgr$XF+<-7j`;FVMdD3@S%$DtMpWFYJO^D zFcC+`tz=sssj=3sv09b2kT0;zWSsI8up&aI+V-H%jwhRpPB!fYFpaLZK#b8~<1Lsf z4y=m?+Y%w3)P2W!5Zy5v0|(Y4W`-~wqf*-&>IEW<7^k)vL1Pxa%Z|T{i~@I#kX^iNSYFrrbr4@GooQhf$EVTReb~1m>;*YXl8LB zT~Va|fdCypusInl>4isowXi@fM%)jvH;dylu!x!2B$_hXOIdB^ z^iLNVdk_&GWa0kexKY;pighTjZ^&3cLSxcgUhb5hoFfkMg_r5Uy}wp(@)-23!|kQfmD-^cU+_!Ze^=d=C!EwPo(y-c|W%;A=|O*r=}qe_5#PhH3b~V z4sgj1+{w1}x5*}E!%r~in(k1QE@=LXFMlqAk1$Rgg9eNV9CT-$x#bgmnu*c&z!Xq1 zr{PoIu3DGF-<x9{k=(?IE_eB;avInFG0kFp&p zD+l0)1uXFe0@We||6WeT09f1JPn%4qC`O#pp&P>^;tRosWUvtv zY>^ycL8hk<>zaR~r!Rx`>>-~_#U(anZ#e`_srBc}UT*kX;IQni@CU|vulIW1!E+1d ziGFuw5qca)M|Na%{>#=PgN%vCJ=uHY0+1?|ZbP~C&|&1|ADEx-hzT-SAsKFt8&N!d7)I;DbJ>Kx!Pwf%pxEyhAI3Lj#$7vqFI ztX}xcn!)WAE2eXZcNkYreEsnDMCh$GcQ@2glH`daY<6K4Y8y>EK% zA5F~y4wM5^!}8Awd>bnVu6~M@nFhXGx;c4LR+`fIW;3JF0i;f)@&9FGi)iUZw3%I) z+SoT_CWshGi`!j7DuWGibXOyZ)#y3%dRH3VPt0&`oMIQqgCvjP6IgorN?DH5SsncI zS?0y`?2}Bl7KrLFt;Z0gyCqb38)D66oW}_mvq8Fh&3~29TIY5;X+YB)Zo3RN@s;8vAl~Eb7d1)bb%_$}|IU)}KKusQpjg@=L9)rqZWWM({FNrU>Y|2@qjY z#SX6T$SjK81Bn0rAcn25@d^EK1^waqhl10@aqIP0+9zBGr_SE>IcxTYQ5n-i{d7_+ zKfw$>`N>^AlCD|=eyBi8M}zl;(^QGU@|+C$36QE04MwAN(PlrNUZuNFKQ9az>wKek z?`Xh$?7-DNh2f**-PNxy-{{h@cuv6T>8jAp*z4OcC3c2_Z@3b%QOO>p#NW!;H~j6P z`MjAKGYSg+I2fKy2D^=+(gAXBczvIaUs(ECWg#%hu$MbwWD&7tGu{k2iG$_6^L}H( zbYwBeEGSDp>u*PZ9Bw|}Fw?wdZ(V?FsXAtv!!^F1`aYeRz6?p(&9>YFOCIE9$zGW} zcICBh_E~avdL=9_0;YSF@@GtBRRg`$tQKZ3p56N(Y&-iHWm@#$x3k~LYCIPyUdEg7 zkIGv>rQx$M#q={w5UXPbmf^VGv_?v$|@WV-_z1xvF z)Y?M*#%WjMY>rO*S*H7U+3`q@&5_ILk+66;bWD(uD##!gHFjiOCx4HO#$`P`n5kv7 zH~BxN-_KAa&UXlJ(wWO+xo#TPPtpO}e)mUQmgzdvG|NhfrhE`K}$BUvfiEPmt*dx-7xxN8pl@f&cQ0hA%l zru_5y5s{v18*oY`{a54<6SGb;_#p9 zu}YiVvUHOg?~D@L8{-XRPU!aa0n}yl!kJ$`-+afmT|wSG_BZ?MHTG?G=C|(CNXfR= zZvPvgeXjn*_^bWH>waGXRjlhYivLKpoN90kF5JO^1sK9t98#s*?pBE3c+8OhXZ_i^ zy8Zc!nYSyGp95>hPY~i~9(=vlcH)8moq!K92fp5!8ExP@{rqlS| z4kSSy+_AR%_s7I_6wRWlq3yXskW?a=s?kKG3##n_(x@QCWuPFe761~I4xJ9l5J*J_ zGxr!L8O(jm%6Wul?fv<5zIqcbPotXaVP&P(aZTvZnsf=u=*NAfLfvmvw2&4)$J1Wx z1J~Nka5R)IY`0ouBUu<~SypZ#{>#n7oh_;=6I60+$t#xHGw6MOLlmw(G~E8$HS(X= zbBigp?9qe~_{6j7L#I{pHTg{?qXu_ye=bPZ@ul0`RqA$rI;v#5O9~x87PpRx9#*y* z6uGv`5|F<*jqxQjIO8^w&eLs0+7r{DLZ$Qlb5ObX{&+k5-JN!%27mI^jKvcj$mMJ7 zUtLZIUPRSBKlK87@150bQeA=Gvoj$D?ay|=1%}Vk9**hXwf7vhDBz6`TclM#oH%>Q z;MK4S^7+Gw?i%j9+{d?e`c6Ih_e#%6vv2jCpmxXdIihx-S_@HMa9=F17bfOU*>}tT zea>J_Qm^h~#j_>MVWkW5_SlBjr;SflCua0+$B*?e6k;+(q0hsTiW4Q=c%$uNZbs|+ zV?UmLwjQ_N;?hO!o}n{^HrIXo3O%2Z?us1Yd!6WeeSF`y`d7A=0$I~`89>|Vc%vV} zCL2aS3-mvC{43Djm`-@1+bLiAyN547df{j~!An^09{Y#e{+7j8&$NeTf4n@#uO^JD zmvQmKs#nzrjM~g${E!+Q==0W!k@TY9%GtjsQ$Co}(Zz4LdkQm=@aV-Bwv;L5B zLW~r@r3cE3-_c*QxUn%4qjN`v?{@IfpQz-G5uJ@4*ZbOwa+D_x`h|uIBz@FFNe__+b%h%5xO4=IU8|hmXBaf#p6`rS z|7$%m-4F8KtBPC0A5DH$JUQDo zaphL>py^kKmk|((^h+ELBz(UhE_6ZW?eL&sudX{?woE}dbjVnGWb8^cI1{@AGxfP& znz{qdmMN$a_F5jlkSd;qbHNKb1&;R<@>{te^1l$!L+lZ`mVg}+8 zfxeFxU+}_c!Q+bYO(XxXygj&smq$v2*zR5hqdG%42GG~lZ>;rtasKF#O}K6j!t1MU zqWsKp=E>Rh(TJ*rOV1Pdg?iDzoTS#66IF3G8q38Z7p96BO5ez+!-B8m=l5>4f)FlS zJyQ%~=Y}-ibQyRdm7{d8OE@U*t)#7{vdDo2RjYX0gjPh&hJUQA}0ypEDTkt~w3+?Db1DW ze&dy%Ld)17WVHJa1^y%@+wy%4>I``>_N^!?d*w`alfsvXpL3t~pSy%5OX^?R4qDQ$ zlXGzj|B;b;7XbJ8;TewIX$d#a8}~2&2!7}?H-oPKQ%~Sh=Nh!?1@A3<^^(M7Ua7|SE3MJg9zT!` zKqN|JXiEkM%1?xWh7JZeWVa6%UrypB@B1t^@L=j*Y4W#3BtID-pvK$l1j7OT4F0yD zp>yrz?XC6UAPiiQpGg-pjXX{Pm3|$|QoanFKfY{L`i+v4oN--rZd=+UMl?5LJeUf8 zZU;I1U%rrI(ISs|IO=QhiK1QOrJb?tOTRKA48JOA8h?kqJoi}SD!==)6}D@PkKb4M z!(Y?xpvm~B`iobeY#00+eqi3TJJ`&xTk-p7^5)^~3+I#n6GG)huO{2n}wA(uW(=% zO;+B+^{eTl9E88G5z}^uVjdcd?|W*f>vYZe4*T4`oS%;SyVkqE9ea83<=?A@oxk(# zTlNq0nKr6c#exd_J&o-U1)?oK2LZT~_w_vKmjv-d_CobA&#j(wEEe1^vc}Au_0l3~ z?7aBsU*K=WbbumHL>!Xww=#XxwTS4%*q?@UEBgI?$!-2XcZP5h(^|TV!Z@&FiRbmm zF{kCyda*lnIq}}eGCK()uI8^3v%eIi`+-BD`C=zVf1U5Wy!iZ}ytNT}{2Dmy|HKkIiMv`=mR4UnTzt7|I|L1yqKJU-_^?tpcuLF~i`olW)skM4}ugB)~pFWU2 z`qHmD#X`?X6}y*A>bXQ%`#M);XNgk;bY9+0_H@yR5?H`;BRw$8$P475+Fv8EQ`H`1|h93tRjB^fI4`lEMasRx{0n}T4 ziI{bNIF0^hx!+Dgcczy1;maWb4RXc-@ci~2N-wdl)7v~tnQm>W$X3j_P=d)Tf11~2DOuvP;aWDMF?`c~pCmZcQ4*sMZ-&Fl-uOl&~ z0bug zU8Lz$iM^KMbttdo>m4};Q2J2T{Rns;_~9d3bkJ?tJ@ds9kL>%jib37RwdNAYzOPW5 zn*PkhcqD0kkD^8Xn5+ET?JmX+{JSs6oH) z;avD3saKZw)6h+x5#HK?KZ(ez@ zdF6=R%{Y?nfZOcgrxy`D7L}i9;ID`BoG-^%R)c2p0J=MG6EJLlL5yMp2$l$zA)*Tk zmD1Sqq72|veBsFzB4T-}hD*IU*;0GFC2>lr4_i}k*Uz#w9&bX|=ijYfq~%m_jB2nW zH)8b1L8tMScqRxJKG(QqZm(+t{^QzxQ)`FPESt0S5Ba}KyTbJ_vXFA6^nn$vv?K;+f@5n7!9;&9EBCtq<75uKQq0-4!p$waMtK5BxwMe*lm3W7`g3 zgcw&rE^@LDYi5j#-@UHub*+rMrF#2T_YLR8T*55q&6k0KXTgQZfmSQj5dFLK2vKm5 z2)2zqmUv{tg#y%l_G1Y{{FH$sfW%rg+l%l7p6LQh?>e@=AFNR^S4Hu7Wdts4$$-i zw#qD9#hQb*=3r1?3>L+v;~a^s^mt;x!wK>IWp6p?!?)$&A{(B;fY@oOxV;1P{1%G( z2K7J^gqH@bIezw0_mD>H*~1GQ14r(w-u6F&wMLHM*V%D5nnQXe?SHrV5w{J!YF%rH zLuxVC8mC1UBV&+*mnGdmVJdM03RB8vXFKchw; zdh@~RX31r4$QlOOowaq{TlWvibXE=r~R5xW#QRQk<0NnY8aFHIcBsm z=5FKn8+CSHxL?~b7g_WLO%X)F`{#+2nbegPZxEEN(akj&KMh`L1a%8R61@aV#nKl9 zFRKSHL%^{BWZ7FUenwf+=O_^uu;1nd`Lm&5k1TI(0xfHnjdlTPN|YonAieHfcm3Qy zl997rMJ5fUj@|u^(v)wb@9t!wJRWl~@6qdv+{aZ;BxA;RtvHCX< z0+VVbkExQkkK0!l6%5~hH!Qtp4%ppF;%fa9?~DH;&)5bwkc?DQeei5W7JUyv2D-2a zQoXsWUmxXKIBlPDkDm3ZcDg(`TV*N3o3I}?E#>~R4r^)%j@UW!kwU|wXjmLgZIP{7 z%?_&*BHbG|wH2sQ_Gb6&xIH?c1?5<-;#JH?M*OVU&7u!{g?$wr9r0*A0U+{G?em-jX}7NnoSR!k>B1dxq2DItsvK>)Z#5U`DsfD~y+=%I`b zm2s{OLJW>&gS@Clc!;{m4%?M}WmeJeFAPkqH<>N+s%mM>1Q1Y~(S0f$kw)z9*OB_2 z2^8zP(9XccC?3dDLKj%R8<;GW8@O-Ve%aeo+A&nS%kNXkRGqhHthaU*#~^DoeKGP} zJ{R=f+e$_wWKG;MvAZ?q?K#kYF0wSx4Qs<^dgyzbvu%|EO>AbtXiop3z@WYxcg6bs zn9C3w$yt0V3VKY!{N9-$?AN&P+9l&t?!%&eK`O6WYMpxMTbFY!lE^vbG%Z0zeRbK# z<^D~Z#hd=+n*IXl)Hu3?M_$EH#f!6&B2|Y^XLY<)@JCIkU`s_T6~$dZO?_>7U;cF= zm~edSNMoh5Ma+i&UxQV>vyYB{Qy}G+Wl5cG1v-#;n_-LD2yxulc|6|$R>>g z`UZtBaU6EYc3u1~x63$VJ6EqJ@YZ4i8}>EH4%72v&Ga++yn@(jZ4f0G_vwBtT(?E!;pmT{RmKJv}z;~ZJ>P?A5wQ+;8d>B@u-2Ap}=M=u+gmj?WFqc zFpf@at^wtw!DFze^;r$Zwt-9e!>X4V@x zdfTk!&Wl%x!F(2`dZL4_O9K@JKM%d%oD!I4jQaWySw(ldk&+-K!k?!(T97PJbA`YJ` z`et0$C?6aQUv;(7PI>CKEosO8y0gSC!r9x|~{DLFscXWz`VSk5(E0(~o{$@P{Y zcb=qg+i_!41V|%kpQ`_%b?!8rJ56)A0-GXUj{3d*-x1w0&~+i$oOj4%44fc8vUKVi zrS{tIQ*m1!;)9}`V;9s|PqKF;w@z90w`~lwk&2q$%$=18=(o7J*#0v7x);;k-V( z8hg0ppKot(TK}4T8tVENxvOx;`eq0 zvWm8A%5Qt;+9IcJv_3+eDdtEEw%0;zGK?&ryW%mbF3F&D>ljQ!ha4R~*Y&*sgE=b8 z@Yz$zLE|}?v8Rb?J8qtd54c{`{c_#;frpUPAKYS}a*eK+9>?nVdB0S;4DWzVo4Ev* za3F5`|tGUzd6;I_>!;=d4S+uFOfb zj}-_sbIlVrYsIGUqxw!B*iIEtK~rZiTOU?1mkOpRHkrY4;-5Rx4|O)iN9<;spYI&J zO<3!E@V6HEQl(As=jrPjsqkW*lk-1Du_pBz@ry|hnRYP@&9u5+f-kq>-fb#29p^Xs z;Y{sa|NCiceBGi8bqqFsDkMXS_>86Ta7{e@|D79ANab1hq#v{74#$(L*3^w;__scMjt;MhiwWVQL-gU3uP}D+(6-Qh1t>0$e1KD3n zQ&HaH3=&hEG-XmKYGs~4MDmy5o|p;Z6Ns9A&v_g3api%Fw`_-jBKvp%^yK!};i0me zu>M6O$}%4WMN#e$$}^j*qa4h+)y8G!%nT=@NtZC;a{qGlJABB9%G6b<>)qKaI?Mc_ z%F>#YdbO+(Su$L<()s>$a9W2&&+i+r{1JW)g^Bj&u41Lq#7)ae#e03r`^+pKZ-pq6 z`#NHJqE@@czbswSlNs$k{` zh{~c|`gkVpcD8E7;Ahc?ip{sb8!Kzk{D_5~a~6=iSIK{e3NLQ{vTSt6eS7p)W3WZP zt4U2ped)a`my@^ZRIRJ8Jg_*kY-G{J?<>4Z)V+eT{3DC$sgOOr(!iGMe7K#Rx|!(} z1)qlLMDjM{Ppn_z_#9gwUR21>neLEJ@e5JEWBPS+Jbp=GiD-S3+ z9}<+T2;oj+V&7BLJo{ap#WpA~$(gS1OQ}#HnzrbXu`hkI%_>a)ea@r)=jW7JUc#+v zz@A+4BZY;kSp9nk+WxZQ`0&{ARhMDb?2k|zaH#%#4{L8QhO1Hh4r0vXYIP%WFcsPGu77-JY(I*p03OqY#M?#jw8pDD@<`Sqhj_hreC@$SNZtSw1?2Vtm zE4jJvRxJ|vd92V1C<7cctHWEruLDlu2~pb-P*4A>@Sli%{HUG=^QRQotTr(sgu>Nm zzzjKE7)9+K9`fpy$@R?f9Czz|T-oubwK;90`=0+&?AHCe8)vFk>CO2Iw$i+V^&i*55w6F#ZP?tK-;8_+lRU+n& zZyo@2lBtX*VTKssG7k)kFD~4=g4Jw~Wx*dW`KF-a2ClGKVR8Y0Of){qFe$5kuHL8B zEahF1!-bl^hkdo{2*Xa}9Q5Ht20?dJ>BiJ~gCYdOOntO?BZZ{b-5f^LjVg$do;9c? zSZn>wC=KBeakaAya@qWmiS0yeUgIM>I`-wSMCX#)!hYRxiE^g2U9CPAqW1^0%;AeP;{a-M5lwrU(OPj92xs&R9VdjR*&5-!;A3&}BP6n^m}9+0I+VR{EuoLYvo5-9 z<8xAUqIDNQxlsmYE8}2kC`*hx%l9O;^n!gZJ`N6o?GhC@d4GU9SnpQ3oYbT-*I6Bn zJen@lF#K|G&e2b z-cZi;JN(PBczu(ot>SoR;*yK6m8*DeBTwU5cgN8)9(v`molmsi1K)B+I)lH*H{6_2 z$m}V`9XZ%xs@x^-u(`3)zw#-J9@n{Gd1nQ2S_F5Za=8xnq@vzL6}%ieK$yc|kL;rN zT_#lc2J~Xp>zDzClP?0OQXbX2p8t$5cUv&EHf@_Rv7Zs{ONBi_<87QleK9}`5L8MwsoagbyHVK#GYuS zARkzWtAL9>FrkI3$<>N`h(KoN;s@9)_~a5isp{G(0^#*4ofjrC`!ri_l@d1HogDME z|K4(8tNH$M{I&9kYC8J23QOMl zC-O7i_Q{8eR#zy8^6IrNQ@q8hPaBU|>o-qs0+^?fsk3naz@jh+HR8&vNz3>~05t6V zl@sIAuXSbI?!-OW5egm-&_p^SF(RADbg`W+4gx9C$!XG|(q`h~LT-AzDR@wxP=|B*eHJ&~F7!TzRk8%sM(N)NCeTY=u{nCe??jUsi+$ ziDI?<_ghycpAq{soxhEbx7^G;G9JuP$Xqv(CaTBUsZz`t=P0`4G(A$N{*&cxNfX)E zBiJn1NIxes))#G!>=8{2uAA5b*JMr_g^ABvH^26sTPl9E#6Q}|K;g=#f77iSd&T*> z#(`n;iOthGtaqT}0=J|nI8-nhICU-_wJ!O-*@3p!5fOG-0K5Sg+Do7MvcRo@AV4rJ zqN)I_A?}pkAL_k^KD9&;svZndpq^K3@3po90z!>QI@DZhM~@=Opb&i!EMuN>Yf_&@ zB2lGKcQGb&jzX0SHcz}?6=bShrXVq%TZN8McKonvWyNM;HQzDORc&mRs6F`c@S`T-T(#(kyY}I%X<2V7EKV@VqG^9@|Z=t!!n%tHJyF`;l z;{>dT7Tab)9kHSkqxD~=j4zv#j!*7ri@KpW`d13|vpG?phECptvlO4P=bf>gF}mk>p6|V0Qpa%i#M^d9VQX_yiLGBE2*`=K}JmFj1P`FCnKQX3v(%&7GAA}rWa050@ znl`Jc?@KJOoHyEKJk*MWi2x);1Spo^j}w>GgWo-4YZX$=v!cy2>&aaW8l}dF<~g%! zd8;{_jOd%xV6sPuVC&Mh4@g0V>m6pe2Bf>GHRbCE^ap|}3}R1(yHoE`P0o4#zvg5~=Q?1ftSLM{8; zdq@C)QVTQa>$K43I2LHcWbKH+9-Dlx2er0Kk#sBoP0-gpZU{1DK}*HPM+!&k>A|x+ zL&2QYM19b`v|vL4RyK(yr-03XyGxGij0lLAV!UnR`NeJ3b1gk#>` zuhHg>s3Ry7r#5@GtJTFtyaZ7pQ7(y-ddqAiU3}=z9rBR*^Xzv0Z6KZCs2wXl*|LvY zN%U-<7;PS|`IwS}6X#4zz3DgxvQ$MsRCQ3~(=YFukO{0EkQhgfh8b4VQ2}NXPK3|Y zGyMBq=Y7>T`V*#pGx`!T;U@l5Z%(ahWVH_1CIzHg`t1@@u7+%40uXi6Sf6JQTL2Pt zWz9MtrK85E74UjrLU_zZYf5AAtti!G^m+;0eJb9`SCIz?>0c3qhm*pxE+p3gdnG|9 z;pSn9Dez}(vl@|7)6b-h>3gir5s4rp0r1f}AvGcn&KDTEuT6Gm!3V&xzeZQN!oq?H z;rsWy{^LR2K5f_+2kk&RIdi|dHhxOhbXtDq)&LltB6G{cSU%v{LO_*8MSKQ48&saj zCO!T*V85=yh@{k~_4>mJtjGNEX*q>7qCyRfrlDt8r|h>qk!9C)sn&dnZX+-d_Zb&i zsPHt@8a&gE!;wx_cXgwfZ|(u z?cDue{P`!%=hdDG)EXx7iJXdT6^~xRXrs7wbL%?h#E zDkuqn5)mG^QcD|Ho40U`%7mj@?H8aeD&g^tea1$AfQZ(DWAq+W7f|WhVz44a_FdW6 z>bd83BU64)h<98+yunQnmtDi^1u=1QXYMlM3!~EdD zUF`8@wn~pi>%88GRc+?F=WUZXp0QW4`Yq_-tv{xuo*zc&eAeLZ+7$)C-=89fj*{5BA*nJ-j7_XKoe$}DZ_Z}LCM>b;!Qd&rhNV@28;YtV&(x@*-%LIdJ%>QRp8 z!%4j|Iml4!qkd#N`;sw6@}gtg9%q@*ur1(O*!ibjB`%_##=}HpH-s82=aGlb9`p_A#pJ%<0Cg{WJY2O3XZt#`6 zPN6PPQMx*+CJoj~AT>BhHTz;k&M%{A?#~0y>w6z?$3(u;_9b5I(yk^V;hBl&RHOTp ztz4*W$vx!Vm*-D3AQRQYI{&M6)YAn~ENvlO_2%{(g(JMKgd|6`ic4dChId-e)ydn) zCQ;{^UkeAWwhkZ*mjnh$Xgk9n=c(@rMpSjJZ>vA%Z{QVFqe&OGaH`p7QD2KY>?hTA z#imOF!$qOySC*f62JhOk+M5w-A6Yh_aWt9t8QmDXRTw1-NnFCtz9N2wb|sY7X)MFJ7JhA5+|$pEZO zQ*ERE9S5$)<5NE+UF`-*zP7`CUv=MDu-V#YQ)YmCI6F zG2g*u3vik#Xo_iU$! z&*QZu9RI?{ny*)Ua*?{?o}$W61j{w#{hxG62U23-_{7b$pD_7qpLu3A zDQvc}Rj9`h`!=s4LOqGqG*vLw0w5^HMWD4iFbBET@4GpQd1Bs9Zw$QlIs;iFhDD;t z&se%wsNL6sknUnNcVKPTn3>Fx_^<|<-Pzi^tF2G$(DJ!(nSeF(&T;@?eUti#WY>iE zXqSS-1gZX;(I-XEo~(bTKMQh3*N$l<`0%Crb3*4`vY1e+(v5s%9RanQ35#&zgafgW z?^bgi)a*%!Sk{+xK!GLP7dZ1@A7Se((j&Xz`K1mMtT~EmJ56hwSTk6>U^IzylzWlL#z!qci#KcUDABB6onk4MNo^6f z1tz~U^&7WXUMz7WZd_xMyyS{&@shO$l2G?R;|gXq9b1oxf1MxlW@JUeXnv z{%OaKh`!+0`|?mPytei6CjVUh_0}S4m^bMYzo(P7?ZV3D%J(BDtaCGDvqHv4Tw_2( z0BMUsCgzlD`bz3=ZJ%{}UhE2L4PBog@d~}|qpV$Lbz&%O_^nQO*``CiIge_+8H(hiGCYDtm{F5kr`w15IOiCa`}=7 zqT@(c!NV*BY=!S)6ofOu3#49P!hDF4cBnuEG30}kaIl?)O!AO>s5)48rLvF|yIcu^ zDZv0=x5Ke4OeumoIV3N!eS>FsbTf`|6bqO0kL>bY#?%oGlKrT4YvZL{hXdh6XsNX+ z_b5&+N-FU(^p7f0QyfSfcv$)bwhn(Rpv0pnjjg%S0ZKfIk(Wxb$g(9G=rkQhQ*jB^ zX~^BkGJTKLeArE7=pVR|uCD2e2tB3|=3ZXt;O~OX52!HSf3NxQWLQM(Ib8e^r~KF4 z2$r1u^JvttH2OhX&%{Et)rE+6YuB9lmpg=yS@!|G`EBh>pk7 z_*@kPwF`{HV}@35ubNgMR~6h<2%{cxi8+)-Uk^w1RMV8mv1xp798W!DgRLuNTA`e# zh+!VV5As!7*XR&!IIF<~9pbcxp(&J;KAic6#DS*^w^B3*j;(9AFMIv#2u!H{o z`+*L#zaJ?Nxqt_~_GLoRHkORb>PSilmMf%d-YHPaFva>3GTyu&P;bx5{cz5#bEG7; zVof~rlN>m(mUZ6n_;_2s!Er0o!)p$Vd^5X7S2n!#;J}?r51)Z-x}OH`W+q?%*MC}m zIlZu}IHhedOf`Lq>sCvf$&B5;|3~rGivh!p-!3c}Za7hvYB+!IBJI#L&Cg6I;M;-W z&vn2ydJ9JLdyI~_;)kE*kU#)njV_EpMagX2@WbYe@vx)lPbQnvzeAV3afc*FCj z0jMYfmLUjAXt=A)BtTP^KvpD|N~VB) zP6Izs7Lw>k6xg5BNh7Fa&x%m!IE=I4EGk&$jTcB@m!MNdC*}3@4LoW|%q*}g+Ya?P z8Ke@XGe9|h@^!T1$MUU5-Wk1dDgDHoEWQ()zo~XU&nkkW^a62ji=jnicl;CU8x|270>$MYiLAgEEIeb5%0KYu4EzO z#Vm4aEn=xqeGCYtD40R+0}#Ia!kzvcZvs{KF`bPoua`po74q2e3P&{)o1Hc^F(Mwx zWk}A(+vtdK2e({3&jE_0iFZ&BXX-Wh926^~NUot#QJ6VPAjOS5d73x{qKw+0r2IZa z?5+)%>dqj42LxWy*>jBd6n0#1S~=eyV&y8d^y1KU8kq2FTuymdN}t{F3}btoul>y! zpE*=l>mBzdN>l5x=#B47);}z!h_?=UWz8Sm7w)VhA1*bMF$=6R8a4~s<7}d0){e`X zGH-Uk9Y+)HCl{7%8-Edet?q2l(fg!RqtG*dl3sc4+IUW{9N)kEC+~zi&jEWF1MchI z$DKJf3Nc7bPV9>%t6b2b*Gq-(P-A8ag@lanURJ`mPNQAF}QR zT&B&?J{>BJ(ZB3ixZ0zlK%Rr6`#2OCb-M2e0=zCPP0COnZTY|v8H z>ZGZ+hk;xX%&>q#xT=;~=xsD1QSOou&E*;IZvA7WX#OLqBvt3TCqlm|_5Z!F0pD{< zwMcDCET9 z!LyFAzuN{@Adwq8G+fKowxrE#9hvw48Xb4C57V^i@)iS@=b9HI9qKwq#d%hsBMCd0 z&+KmWey?-5xw&_@JbKl+Sy4Z_pUkVRcieZEO3!@uX6@IiBI|H1G^n#NkKCfS8k69FI zxFV<@cgQm#2r8hU7<#MIR4kxUc2f=;PWyQV$dGE(3Ai158nWKIv7>D>soqCJ^Azy$ zrmcC%Fo8Oc(cNFL_t-s+8uk}Ust~yuaj0Z^GKO5ZO}aDQ`$l_Kw%?*Lh9pGPN|d`P z>{=ZSe^ATWwm1EP&wNm9Ws)}|7>LFF_q0B`mQROYcWr(Fh%wWZ)qHyEN z84$EQa7_6$1_@bGI~E?$PGMUuskSp!%f)!Erq$M~mSvYMFJE#!YKOnvkLN~N-i)@q z)q%RjR=HiNbG1T;S(uP}Tqp7a_5u@qvtH+9y4CxX!1sLg=ZdWvsewEt&v8eC7ubxk z)ZK#ke6#tCE0+yEa@RFs^i=2JrxP$Aq73t7%Kl6WJStQByO9b#vttvWCd>R1h3PbB zMnvu5!a^t&k}az>Hh_0Qm*}w*DV#)1e3<1_~o3Ra9rTB&c^Q6_rF_qIIbBhOAV6 zkIdsS%1J8jgRNbcUsXogW+8eTNTW;Li2Ma#9+|o)xlsfp{2$4v{j} zO%n}x!JEg_bEpp5sm}4a!R!jhGcwf>)@t-5XbY4S%fkl72ONkoYf|3sALWOihc3vx z74-Q4Hu`XiU!*bQFcY5ZNcN|IndOKf0(4WVO9&NCr{Iz#=p+pEliyKaHWWGx_hmuN z=hawDwZjxh8cS(n4C9K|aq~aW$<=<=fx7C9)(z0PHih>XM&8a%zuk{Ks8o^bY{i{) zEgjUpK9%t2?#}GcGiFhne@q?xM#=Zsi4inaw!X)5Ph@Y`On`RpW**CcI}8<%o;DIiMt! zg3moSvX4gXruwQ+gRHL!Fl)|>2_I|1+1aGNxJ^d9R3L(^m! zS2p?z5d9#}$ur;FSEhQJt?9}Sn`H;M2@r4j2=7s)FH&fYcjQ^U)v&o$5MUDs(d3+eGs^=WhP@rdv~ z%ACxC{yFIKa2CGF4iX?x%Vusk6wS&x3*p+kgaPmn7L+NCD`UbI-(!6REx~MPDpe`x z`)Ya1z?sep{MG$#hQ<+Dc5a8vbYv~cgL-_h|x1;5oJNoLZceZ{19axmAQk9zU zOsbl1%-b8UdiDFY$(LUT_P4}T1Kf@V2 z!y*a7F-j3IPzj(QHv@qB6QivbjF@YgWF!Zngpi3 zVCs7o@=&ILMqcq)fc~H~Vdm8k6v#t~@+<~{6qym&&@UK7;H4%xOG*A!ruI{!e6ScB z#;!@KkMVzZGGTf{<@)H54s};{*@HOp0Y`rt2Krxms+^$h3qU>{(|Wa0Q%Gn&C`>=q z>EYL$YbFf1200Tkq0N2jw)I!5S-`=1{_e~xR=M-7o2v2T&4VpZu3diF`i;9TH=(_~ z*JH2NomcN7ymn@kuipKHcl{&gy5W3#`uMK5x+)o?m}HESNbh2n=yrF+-V_WNP}KN+ zsDJY5#>0~O*SmpW335BzRL|btGBD}93#6MuQK1k>XN^$+!BE2?m7=;0!}y_Bf@hO{ z?8XFR&}34G63ryu%_O2ewwrL7{U_PCtC}>1l**tHl@7|~P`Lw33ngm0qJ*yBg)v}* zIDk^ALepUN=|p8Sa~*BUebs|B929I)Da1iiW)kP?RF2D!f$r6X#l(_J-w6LSm1~Vaqmqw}yq*v>|-gM9PH=Xkp=#$#RqDto1?YLqUPv=P%p zw{abSk+t2s>x8Q6LdovTXdU}7o2N&H3qfpXI{-UW)Y0eOiKelAnd*D573+fSSMLJ0 zW6;SglF3=)p}|YKVO?L6NEB`dE=A-sa646kmY2jhZd!jEDSI9d0ju$~iYVYs`#}eb zK|8)X#m!+XJ2p8m%|bPg?AeEP&F5Y$!n!VC;(0IN9H=K;vw++5?x2fRdA|b&{)G?w zT)t1<0m+ss)A@VkqBQS`RV>-H_lK-rU%RHgr{aj6TguOC^x@X}`ijk~t@4gZA`&Vt z_D>v(Q+=dppFW@dl&R3e17ATBi0wm#p9Whu)JZt&Bv{)ib=#pR%*9XnSDT)EzNuQ3 z0>9(rReWFR2tgS?Pb5JdntP1ZSU`*Zf6NE8mKtgenTW`9aI6BY|8OGt!GG~KNk~@p zVd+{%jd0BZ!6{d5RNG!w1`O4_stdg&3pt={%9`B`Zr3jtLl8*xpmIH51 zt<-kTJKZV_UU8)JRN)IExbp#Q)y<~03z!W8KRO5O3BVJlHyn5uwqXOzJJR=;)#c&} zSV+f4OR19Z&$PXE6jRn6`U`r|Me8&hJpVI5`1h*CgEKE`U+T zJI6n1_ib@IS8usxQ)N`)ljDt*Z;7azGPRp2Do4<6`_T!QYaZ>8@@C%Sm#5l2TiyhI z+&U!G=O&?F36!sI+;-DUxq^vc5pK%?EnAB7A`G(7SoV25uJw@1gLJaT@*c!~vV&+1 zY#i2k3xdp_NsNqgOCc{l4K1QX%Of||>~_L!yRvb2IP9@ZZ3AWf#own^!D6Y-kQ@Mg znhEhfj{7qF`F`0g2e1b-6>l9sa&4s6X{n+?3z^@ zHkhY}t#KxAFYDU1_zlm&zDtM;qd>I!%*dy431BU$zlt1){Bv*>3Wo|^`Xw9dDWJ7#`UR~3t%J)ok&Zk=3ktalhjKQkc z8MhK{N^;r9UhixB(AqTgI;B16+u-h{H)Vdi?e<)l0aQf*!t3x{b_9Yg73T5#pXT9i z-12>TCk`&Am}C5&$9;MB9wLvGC}U4pe0!h$+Xw!qaR(}X$A688*V8Qg!SKJ%dky>G zDS*=QAbf9wN-q=nd)vZq7~IHwpJoQ?bV_)<^Xb@*?b?r7;9qJ_FkItrtP5{hNx)ug zJoI!&YON?TT{FE`4Ys!g`uN&6U#5cGjVm9Ci^M`bF;IUBPj55(yyIfz z)gYwC#s^XK-RknjzaRFk%RF8(+LbV0EhB5`B$ib0TamhA#8r40fG-#)c(0hRo5}EU;>2c z9p|j&TKc|AR51jJCcFDeC!zToB;T6yrrqn`V-2kj`Whb^EE;tPuRcE6Kcv8ERCH%o zOe^CoLRu#-RKV^hJF|v6jK5!3JL!@%z7$a9A6g$=eB6A;*BgiS`E|Shduh@fB4E6_ zY5uL!Kdd?Ci;Uy;^UJthNO#-qUt`N}J2MwviH*TaA9xG2S>eSQ4hJ7$fH_P;Is_QM9Ka=b~MKVFG5!MOQYoZsBSq>-~dh42t;xU}C6w30fYW{+P| z%A1a*NI?ylFlj-K`U_(SlIqXXB4JLkxdng1rA7*!lAy9xzcVjXGk#4x%FkieGB?Ni z*i%jpnZM!LR=>md*R9q$D|n)8rho$}Q_0Q{I90^njH~mBg6};0&m3XU;6cmNSh@Sm z@v;1tla1n^#ixFo`IQXtYujgEqE)x7h|g)f%Y3!&>T6L(%vH;0g`_gsEzXJK**~B$ zk3zqp8s$nYchyXkNBtbZBMNt|8a*hN)kvWgEqqB)>)(${h?ncUN7$`NiFR zk@0Jq@_kVY0?oL&)cQi*kyOUs*7?ML=gHPZ9-L&cx|=BF6{ zYkxdhCr^O#ti6pxdA@%WzD{5tD{8>99<7HNblZI4z4UALIB-wB-Q#x-AJI2q-XuR) zviIeosWX)ErNQ!;KNN7r>|wvYqVz`*xwmY&Da60Qu0{O_QdbT=+ng^wT8DV2<7SM^ z+j%#z$E1SKx^L>+%N#BKc~t&>SkEGtH8Nu-)-Z~p26pR*rpUzhsHD`D5qHWBmCmPslBNwIQmlp#I1NnC?M2)NR zJ_6f{F+qokA?)JyHIYaQP-!5D|=vy*m+xSLYmcA_!4Y6 z<7gMO;6;P3M|2n}=Vu6ik!X&Xf9&>{W@>YI;JRFgxH+A&jrBDxCF7O)L-2}eSb)^1i7Q{|4!8(Pg8_yxS}o0v*qUD7IJ*A%*DgCx(g z280S@eVTwLTxVtioA2hH-+uM!ZS&YGs02j+VZH=Lk*o`mv%8G?Q{c{y2Rz^6eYLlQ z9?fwv-jP-UdHpA)%CaD8dsJckVS;l)VXEYehR&(<9a|kk3%Q!hrJ-vxd6!aI8m1~1 zg%!L0tH1{|=!*`ArOYe$ciuhj!_bh!>1Xo0s!UA^ZFj!(vf^UC9)B6Qk*oZCMfiHd z`BVAcmzqzmhzs1OIcH=4`Vry*vfacxqba-(bY$r$O@{^A5dhV*?EsYY#c-y7pIQZw zXBhNXX^V`e@s6ru;kYZD;v#;Pox&rD*ibhY&f3E%0bhz9pMx5jDQ=s8||fBp0e3UK-MEn*3%ZKf7bFW#6&$Ctg(D zJ8RhPAN;uT(ks{>1rg{5CQy#gc*U=lc;S<|$8XK! zHyVnLHe)aAP$72eM@#xZz~)bjXSXag*gEaGPWf%a?76bNP6Dl)n;rCd&SJP${W8iM z_9nTULKq;VATRtKr=(E)078@ki(ms49m=a&qFWDTP}iEMlECpP{4tmOxk}b=iliP{ zj1?&>undaYGyV2WiV!3dFd)}!x~8xJL5k&rU)j3HB5o2TQ6)>kIZ&Lmf3>7oRuD@~ ze-Pc-?*1d1$Csb)pg$`;Vx7uMD!KTnD^9yk3Ne<)z}5X|m#D#;_($!h9a8Fyy}=FS zTw8&HsMvKMDbM}yF4tigYEO!MEMDInvXctK>fM z!cLrb;S6Za-%5};lf>h5*PT4Q7EFB?fcqJ9J5hN2RqFZQv_kM$-QEm+9t)Bp%&Lwz)|IwvChVk{V7-}I1_dkx#`=9Fn|Nmz% z>m2jg$KD(>q#VaO_A#?Ngp7!iXmO5nj$B6qh`ZLNiu&YNi@%`wn!uFI5(G-Mtz`e3DqSlHOt`Om33T`mX zs_(s56+4Ew*#mjpYG67Rc|+pz z=<5~f`)5k`_;MvDfwIZD8!fqi7DOena?=FhhlR(VsN$be#Q&apvU3Tf(MhboI-P6SR)(VG~{!nd|E3%3=DyzZ6mDc}?{1F&>`)2s@kJ46DDXbM*IP1h|4x;+30 z^`;>>yd$@J0sOw?+XSjM0&0TG4PD7XsFP8n0HrU_23hHny|z+(xoBG;ddyPz2K7x& z`u>j`v4;B>(~n$ApAzX!!*S_nn`wLlH&T1=46MnH)Ia?PljD*CQbW@&wb@IQODmL1 zD}OBQAv(WXDE>{QmG%o9;t%-)RsYmIRuiihIV^TQsvi)Y(c70*;3%t+Xd@u7E|;Ff z0?Dgm4dw*|>dUkV;CI~4BNVVX0DNIhOUtGFxH|s_0^(gV0S|y%t;>2k=8AlG0V$3( z|CLwPKPtDXAQ#3sE&^qULKRb3Z@=G0*Ql~6LseFi55E{yVw-PnsJ2)irDbtcH+WV=`UWDs0Vg!;6XpBkm zi7zM}fH=!`^ct{1PEs@R0&mo~d76_xGri)H2$0@ApBaGj#j7+jo=L`oR)C%{&^Pf7 zj|YrEDmQ@2Y*2#O`~c+5m3zvU>Gk~2i&E!{E&P1n_vz8e0`e?n`SlabZ*P8&`zl1{ z{%!#&4VXoB_~Of}3xg!G`&BfW1(Gt;$)@J=^PtjOrrD>;-X_!3vD0q7SovhoNEI++ zi6xjKb%sWiu@Y9akZRp>p;z`7C{zRzIAY42rc7kXGP+>d9;PTIGs}BW4Nyc>vTjb9 zr;1jp_ci^naNFMY)(Vr#{DTP68K;vl=Mg^50Odd>5xKj)S)L0M+I}T24Z7-tB)>9Q zEx#eiQepv3heNv{xuk2FZjfv+6kcZFn|=hhRA^hK&bAynmR=cDCig|uz8)HCSJS)wmPrTS#F$weh5jF^ z9CsY#fBxcUbeXK7a$gJ!jlp1y{2sl%@9FTV>-NxjhYQP$ded4ucKE08bvt!BaTk2B z=H|eL(0g5^58zG+MkQ6$b_^gJML;bN#pYm1BYN%!$ zBQ<_i%ZK69yQ*=(@Iib~Yh)afv_mN{4e?X{y`gI2AAHtB^%@y&9; zj)z&PR|d4RbXE#(TGfQ>Dl99H2`(TlF00F~4zAG`UBwu54&`hYQVV13E^QPpKD4oS zlRpzt93_Pd#K@Rc_#S(Ojx(+#Wn_dxU7FL;u6l&s4FAg(201IC_vo^pAWrIx40Q(e z6J7QogTC{D;mYz?Ur(j1`eC#DSq#5{b-!IkDkn0>6_LeU4cW~IIsB*qSm@;HNp)uK z0sIt&8G&b(n=m8GH_GE+QEb>57VOMG)EPv^P1xq4%)3Tzv-V)4HUgfM22ZABL{T;y zd^Q_`qnlheqbM2YlcQTBc+qWn(YKlKDf5h5f*E%NGg1-osnCqGz0q{Um!7zoB>b0D zQgoUXtNQ?+Oh7#RUrbBH=A+W+Bo?|62LInqO!Bh)aPa2fCwcyqh+InLnF*sUQK>Vi z<&B4nX)?$bOs2xZr>5&iLnbTgFPyn=eVV_tLUl=4VT=9fF7vxHVnMT8APRnANsx003lNS}m+1Sg2Rs9=v z@G@urNmLN`Kj3@bfA-ZI`%jpu15D^iE}g{9AaM~HpXq5|x=PW#J)5cIFHN7~8(lY> z@-~}4eZizg$FtxD+3*90gc-*z5gvS+ww0@1({$Z+vJ}C=EH>%)r%c!jEwHPaADq@j zo_J@^Q84O`A<6;!+rfIiS7UK4r(vKvDE1GhRrBE(5=CUfzV^zk8nE%*d&Rs@k#q zO#cf>X8KVQ3$DQmToXgFumYcr$mV~~u$YmDHZ)&u`~dS5CEo4Vw%cj08!zgn;HLNK z3qD79F&w!~j$Cw~?iYW{=zFDK0`tGn(VPAm@n_bzDfwJadsfJQQP30d?v_8H@`vq} zpOKD;b1aVu5r5b{{ z_3da@2k9)pt=$SGzJk4Ck@+Z_gCxFP2L(S z$H|udpt&-f$9Cmhp{`@})ZJbA+i~(wlIbLdKOrTnI`Y4W)#4-nIjS*FW1|XnH!AHn zen!AT)fth=q7iIZ#2AconwiRm)t+UhF8`)4|8{wwSbz3UgBz>%6|-6GlI#6HXV^Sg z+pk3EX>RY%MmYR3S>msc#NRu5mjmFNy{doDuV3iqH4y8yEUt{J#$I91RZinZ zH}TZ%)!BBj=ZJQPx1tUO7Z;^2aF9}!<`&KAdrRsBPP)lix&-y z{*m7v9d!AZkVB`l7xs55qR3t&v0!Jt^bXWceuH!;u@DBMM=~P-FpBA))JRw;VLOAx zj9|ecOnEnj{?wkhRBz9!znEJ8^uLC)+>G)+sgv9G8kct6U^{SD*!)4rTb>D5euxec ziHO;CTO6j(3(C>4Tb!Xtltk#B#7iY&5^w%0zr??9AD`zfQ`_Ko{yB6$VSN6cZVz~o zIz-<;)o|DUMZlxvOVUSNWr-JFx4wJ8Kvx*YAkThV({MbrJ@8t8w!Z#$^M3ogQqZvn z2~}Pp_tP}nBHEje#6;#UI!j&rU|DC^j#wMV zJk=urKj`;s4}E&w*6{Vi-6IahDzO)3WGO7U!G4pNT(lZVs^vCOV@cZgT`*ircaFAxAxH zD_>9Jf0EVbEXR`5i!(l)@t$#?BV3qqG5JU@^HV)YFZN?TN@r6NfF;XhzYE?k-0dVy z@<{USOE3B`7iKj^PG5T6^5%36LO+9SbVYSGvX<#+-jY(_ zSoy++?<$?`xWZb)SlsOZ7p3lx+jzPTZ4YI6n(5DFdlF;IH!bhm@NBXE!seqQTDP`s z6o`GI2rW0K3x?vvE67tXXng+ZIRWG6))idCjkD1q4jM+QfvR#QuPm3P8b6pGyzsO|Dnq00H5D(8Vw_*jymCX zd#^^20sIToEOPuqzXt_%YN>BzlNoVMQ?)s9^#%=xL09t&;SiDB zWVx1lvS~x{W?{>p(Lai9=KC|bM+nG|GyWbQ9YqY%%zEp5eB1xJF^yWXCQpRuzpDvR zt?cy-PHu>f=^U@TQEa|Zf2us>0#d!6xV&onp|fj9%0W4Kk7qb$8KeB$?UD2`$%Y6| z#Wg=AAJFq~&4#ETfqk52t8%&4g}<>*A}?}5wA5?8v<$|$We(cT zcALZ>f=ja`X0Udp^4fT z2Di$ItaNthY-pHaksp_KvFl$%z6X1v;-VCzOk$@a;_YE4u~GCekK{U7YCDjQHBTSE zGdmF@OO)!I6;|MQwP$&*%(Y&D-r_HqQhn-(V2f|}&>x1|sT4Y~mxf9kH*9oVGaWOW z*K*@>K4_UbT+jaQfxY{DJz#lSnmF|IVpMjZ}3+ws4ep8D-ACp+^3f zeAQlfWxX+SHc~PY)z4bdf1mtSvL)tS{B*jEx1i$%^N|q2(@(4^&tH02hKZhU9zJU2 z;>fanSR;@SiKZ1plu=ZBox)yWwU;(T*+te|u$Tfj*WG&|24yNz`v_I=!yZ#R-t(mP z=>rRr-Byv$!2rk-SOC4^&5t%26vhI&M6~0q9Pje5hD!8-T0vxuvz&4ZneO{tUfgC3 zVwTBHYalu`%um}QKS>!j)TVT__*O-Lo0bMN6k7J4J}KYgw;#usUX@~wQA`a;I+`q2 ztAS(^gZ7i5%pM6vY(ijJzRyZd-Q=~K(w$#-FzpZy-+To^{2ShBV*FldZoL9 z=gW=8`Oq14)=wNk;QBR7GJ*GvRx!#79m=@bKVNqNpB5+H)inlU?|vr9NKD z&LIeyBXR9a*xPcKO|G)n^_}eu;+}wB$^tKky{{DZK&<+|yb8k)Vj^X>I-0MRXPoo< zkXd??O#wL`I<UfB<)g@BYe7FR$N8E?88)m1D6m4bBIuS^Qs%FMS8UQiCdV``-Su8vgXC zU4;-s%dn(Vt#__wV_ba*Lnn#clim`dRzJDdIDbJ+=8sg#eRydtj-hd^FK*{aZ>!#Q zy@226#l1FHTi1KMH4~SdaPVHyR-Q$(#sZ(=&pkiV|At~YrnT3PTv@%XEUPQT$`$^g zyfN?K(J~YW%2jC#t4!~UFv7t)KZ=)Ty;P_lskt8X)O;sL?#He~H$<*Bx%pZ7%8vXf z-fqn1Nx&DxguCMP7Iypjs`tPn6<_uL;WO^a-OW)hn~QNzYksJSzt~_uwCi}};-N}O zo!%NF`T9*ycg^&0b6L^p8k=3{SoE+fml9 z21&qfbBfOh+o&{G*eR`@JSen@RLl;UaPgtwUKX<2!JUS4tIn!62~k_;%yYNSHAuzS zRdQ>q%BOdlv^^1R*9(S(xfKg=A=Hpm|1@ z?|o!jUH7f~;%zBKVy{P$jDD}L>7(ZKXPsGk@!-{&vzX_9ox6h@);;Y)wotAQ z`!qNAf4yT=P34*ll{F&rCi$zBRYZ=(KTLgM2dUAo|_(hXEXB7#w@IT>eMWV7TPhlM!eB zoi!?(-xwowD0J8Q>1oL0fYk9flRjeYfyOq-%(L&7 zzaCehlG3i`+@2pkSA1-B^V%~WDm$YVT6Q)j=6~te(nBy!Y`Q@=+O3jvTC*H!uVx`4 zQJPqS<40y9hmwmUodZ9V3~Af&Z;TMHQ=VVvi1YPH8IB2ND~#ZaX_^UgL(?VeTJRU$g6#Df&c_kJLNHN;}gQ__O@-4VO$zoGKRR#EuTc3|-lMhb_hw!SMl@wFENmr2HHa;L%}zDYOY;np$#5W!*!>{O zfE%%~RX)$!eVtt>L|)jk9**A9=TF3^>-$m=Cg3j(%k{H?x2cqRNI^l(Q|>$^nV!Bi zORPbR9**_4_p^L8CQ7`<)b~|}`@`{T zAA_G1t9J>$@Lp@5Dj&lRf0o2d_$oK-EhQzcS%q1vvLF_|655J3JAt0`#PI_H=^=8x$5S6{xR&?`YmLKb;$HSR7Q=|}fO~Aa{ z8IMuc`1?JmOmVML$!@m%>dO0YY+1R=XPF$h285NcXosgnO0J#$`UFo>Bo3&+@+jAN z#YB}_pI}wGW~a;qdgf9bWJ3dj?+v{AJ##a6DP*%Ubm#NLzBnpMQ9Z)u-}xNtWQe_M zsOHG<^s=1m&P$(^Cx~ilK=$-A`!-S*_m67b%Vcp~GggL)x>}Wb)gYGcS(Z2s-(kmFeYT@Mk1~7dq-q zm-M!Yq_To|vjScOTX$22M+c+b2I}8zd!?P_j(Xx-k0Csg%>)ShPhOu>fqIflEczJ! zU)Q}{J!FXN(2vy9htQs6rt+*p_-}bN_okYhX^TtbqH8aUSCvL*L8KMC*uA>4TJaw9 z1-w@+NyVl@bwn+0L@!6HukcG%99>m>SZk_^B9#@&e4cvnghkuBpZA2iAXiU5Fk3!_yNmbsmg0gmCQRPQm z<;TKH(-|$yq)RU=lw(K3ouh6{o~C?xQt-MbBBv<#3%{9izHEeZds9%BR`v_E;Cea! zmCUE2syCO%kq;Jc^vvIIRX$r$ef#6aC4lUfsi#pGh>G~?MVj)*l3e40ZY$S`ZjF{< zZtlnkTy8iSIPm5!7P_Oq*`+-?%ho(u9%&Kf-rG6l-}xr##&rt1aAER#&a#_C4Q-h$ z?ETV7C7k(U@eI-Qvn=tmybn^Df>uiqF4B#P3G_Kst0tX$J?)4h9c!He}?3#^8x5;fGynZCm9_sQfLS4BepvuC>VNc8{Ls z)-vNUUZOQb<+`$V)W~bhZ&6v+l1-nCu0f8qnT69kBY_(X=#>o-IV-2}gI6gGXx@Ot zec6V$15+W0!cVPj;_e_EKS;{!W4>b3BO_4V{&2@c+~Y*_ zo^75A*Qv-LDu%7n;0-^0L5he~j$0lb-OIeOExxpB`$;FE^2{xa$d={E>C3fpKSD!= z+FSH)`g8ajbxLCUKSfQ1DSw-qO}0rlO0$2ZtWJ{g9==xO!zpsrwlzS-d3NZ7FP8C= z`Q{=qE2#90C0f5JV_(}TMD$eMx#)#;QoC~9O>f&^Jkz6mqckOd4 zk6oT6jXnyvPk|2Pgqr{?QX;|;DL)k3DK*4&wEV$^*Ew%!5M=&-H0yat{GgB`X-_1i z7Lp@&|3oDF-SCsgV$h%KR_XRWM8Beg1Y`>lI>P2%**mI860D%vAA~aAa73N(c2XVrLY#|`eJ+gElH`t3EqLza;F#xmcVt6a)!dtC^Vs>i*B zcfGK?j(`r5LC7$1dH_;VLsX3jT>)aQytMVQwh9ew3bI{1wrwrCI>>!x8^#`lNQU+* z*rnhy!)9DIb=cD&eGRV2)n7r}{3c#;aj;XFRD272SHN2o#Ir$vw2pP*%`pv`qBV3e ze|7y4wAmzkm@edk4Y3jr-t}RgwL+`}=y&53_%I8$60L-A@sH8+7LB2&%o|CP<$l2} zCr({jXD1((q?*2^ zTx?N4#DCfR$QD9jE}kLt!78katD0T&RBY^(#oZS|nOiKG&@rnq#d8B`Er6U5y`o9O z4dp~2Onv|A?$N>SZEMLZn}FtjV<|*;=;2WUYAR}EgE=KRZG3w+e&(m;UsL|j331G6 z@5tD3MGifZjEqL`AriDms2*FL{)Gmh#ZYz`=a51BiD8fXTAC-=1DdNKfE~@NO7N_~ zxW4G1P2zO0V!V`!Sl;>cnSvUb4%(zQmIO{wg>j!TjZ8-E_II?uTvSC&g_0oNi z!2ZFe&ejM{q>u!5yx3CfRP;Hlq*VtRqg~F=7q)iDV;p!$`??~3nf;Gt@QQx*zV&58 z3;>`gl9syNsdAZ4lD|+}@K-eKD)HR=8DB=(pM9{7O&ZE8aTF5hH;C?;_m@rswDp^+ z6ZJ{UIp?@Ex8ustLTOBv5z~0H5>%Eps#&X?`J2svr6? zCeeyr{PWQ!$$E(j&~p`k`A&D#-@od&d@^rgAR2Q2fo?%2_(33Qq_^ck>MX|;drQ`a zi-$2pl@V5Huf%Ws%C88r5>o$bFJ)ZFgX!%J-@IH-!!|A(92;B7jnihyM8_L_UFpP8 z!xo(ZXaLY_J0Jf$vjG=MvNnhSw+;;gWa0rZQ;Grd`2=mOaXMUs&u?rI-K9FBT^UO9 zMcK@B_ZTaRI27C7wfS_Ci7FW2e3GxxAO+f_4JnM%ZJSb-$eD?QsZcvYYBs4tj&@tA zBI2a`Xm+82wGSp9r{}0>CG<4KrXCe%d`1(2Jh%Y0;8@+rC*_H4ku&ruo!QWWmzJ&R z1sVK$iu-#{6;OrDA^|r+knw`F!xqXcCyi~8ux_jKhn(4Cb6F6&XsvxugJnZGPO4w} zYoqztuGXb6)}7Bk;uZwQOKUhWRNKO!pgb!C`G`g=D=*^ggossGQ$fY^uw=TFC{4?0 z#ZmRcwc=-!90!OBKIZW?n|kquTgS8?w3ZEt)z&Mv0M{1@;3xGG^~=xn?QL`K0d~-O zkG!jHucYxgf2PeJ24=kYQRl<^p3&X~GakTdWWbkQAz*XUFzT!2pA>|iSMQLj;fS^7 zmXHUFdUY46GqW>qpJQhOP*<(Iq!>>{jD7A#BQ=1=@$R3-uixLpv9fw*o@HV8QsVRGWY zpqt};=#p-`ei9@CUt~!69onm9Z9-7vw(kccahbomd``=K|7YrXfbQ6tH^Xv1=IM+G z<4z5$R5x|B(bC-{kS^=%A<~bKajupyUO`;V}h5jCYHR|y|cs0a7%hO_0 zxNJ{enfF?0;&f;z2LMpR<-IDj0a*I>JKE|b1O4POOBD^rdiJ*U*RN%|8TEi7bHW7C>!J-ds zpmAKAu}_ed43ZRI;X%{!nGUxnnm(O#U(UF8{fffGDAyLaA0vA_4?Pi850;6yqprS5 z6MLxgG`uEDm}$8e@}qp-7I)5u*&lY7yQKZdBPzbH(Cb0H@WjQ=a46*z^SlLJj^|6` zc{T>2buaHLoH{#*@<8_r8BIA`h4jBQ9(p!Of43h2{dETN`Pb|1eebyYz%G648)H}c zXQ})I>$v+aaSb*yXi)bSP#UKn$VI@ngz}o=3*eZ*Wynh)$aoT7JxTeQXrQAIA|(-p zw!5U0fGEMiHStDWWyh{R@w?e`4zm(fJ?eh%O{h+fvTO&ieiD4!Q8doP~lXT_yj*t&N5IU6@dEb$@%=i{^gh0fXbA`46*LVX6eGc?^j~Ail z+7#34WFsc>*R`FWLO~fByn$=mzpg2OQu)?1C?HH&mv@vJKIXH;51NVs7aPn2dE9b$ z1$x@_e6YoyM~`+y$4mcGv#UWsWjsA`Mbktz$p{aTB|>63(EG2!M%>E*^J35%g_(JWaRY4} zjbXDBF#8KV(TGX33U~JjRH;>z;sRCTkrBzr<)?@;GIn+ncCI`Y+I=mu(ChQXYtDDC zso)}hk`|JNH>e~Q(Ze)0UrG?a+1`v4SNAd^_gKaNKx z)xsh|^3$b1|{*6(N&`RF$< zxQK{s09v@c0<%EPy74(BYNW97hzKVh!2z7(n>4Ith*Sz+78N4_(e5`Jqxd+0XTJ^g zso50`!srzZEC5KH2anEO?lUPdBMA82HQrek+~HA5=vJsfZTHZ%n}qS!Q`6z4KO8VF7U@Zlvx?ZW=e#ClB;DB%2mTC^z|3P@4R#4jyd zfQjHx*vuQQvo#_u<%?RXSx0E*GqH)f|iKaat_$ndc^e`cN;<;B!!P*7*63qvLTZX1c!l3@O+c^%BZA*FaGJKIA7f0bq*@BI zV&52N&^0bbZTJ8jZqay3RLgE_Qnyp|oKH~bN$L3ffChLzm%?>N7ZZ@JY?dh;IN)z{--m6|)xvy}~IIMnK!Dtes-vP4jcyP)-cB&`*N?*%K`~0kw z@YzqQ16J(EAvN0lU8*rQ1yM<+1Gil2UA$_~1`Pq3ika@;uc?_!f28X_Ujawr(6>48 zG>Y1V<;+0d093*Op@h(|DzK}!Zx`xxv(=0$QGp?B?S&Ks2l0=E5_;+Y#{%o^pZ-Sg zXS+)Pz}{@=3Bmv-1Z>;^Y!*9S8y;=gEXouG&ZXSQ;wwH@>mjI^E3{?ev1<~4H*t4L z0r6LX<%fRHl7H&~%#g$cpD6M9eY2j7tmocj5(Jn70!svx`5A;6r5t=I>5Xc6KvJwi zXv^0(u>t|H1K0Z_hk#iBAyua?KHro3wMpbQ7vS(MAuEvLGKh!cyUmt~#OWv0!duf2 z0!`@m*|w+B;7Vf_3tM)-y{>C)xcO^E*_wyUKY;sw;9ms@-oXomA4TO8dj3C30>$GU z78}#XczBdV&9QwaO$nSV?8<#aAowg_%rT^>P7{V1aOY4gWRElE!vmlLeS<36y)Z?8 zE0YvWPZYgL%8D^a>}-Q{Ry~vW0F$Kq_3KcujIEoq+?y>y!!8YzHJ(WaO4L`M5>sko2Ut<2p4_4*C=I$uE^KwxWD#vD+~NkDp5r3bp3q6gr;RoL@{y0-y@-ZMZ=6K zm=5>3rS^-Zs+Jk1>er@`|6LAG&}ZLq?|QdguJa`+&h+NWQVr&}P;Rf}sR$s?2Xusj zPggf>1y{loA{YOni*S_UG%3TyKR(^7hFi2{J&Dc-+8@($t zV}c#IH+h*1Te4DFF~iUU;=`>rqdP>ScZ> z@LGaP(9l`c0T=(hR1rtecswdO{#6r>Phxj8JJ&X6Ux-COrs8|5IR-*L3ws4mp^(5o_|TcLz=I@zYwBtp1l=@Jk0V!ozt-YD#{;ps0b&xE!5MkUS?pJNWL7 z#LIIZuVGU3!o)Eh1^AA|ThZREz^{B+?XE@f=&xziKYKz~p_oe){(NWj^SV_GCaOme z^X&`m1P1ndos^Ouke?Zuj#hoG|#A zu}H#8>>tyO0`sK3-q-I;Mp_?mmvX{)Ba^iFHr|E#>^=&g7n4|7iY-5$VK4JSr>4`8 zFUir!>Bno`OYJt#mM6ciptD@L-G%^=^bcX;sc%YLS&2?V zf?qVcB>UP((S?Bf?PbDl*W~{W0!RsBjx|cBo{9yvB+5@K)X$1Pf}OFp>l-Kb-IaMp z8UJ*Hk6G2P(_3Qs%Hr@I*YCn&3xk=)TK$+BxBg~|6nKB zh-wZZGn8C=L}dCB?J`>Jzv!cCJg)v%L!j4x$ZuTqvpj)D+$tM@Oe4e3a$o_=kRYBp zDZT*ODUa%iN_ghi=@PAVF<cem_7J6g4FffP+LtNuDxgVEKI-So`1}r{Kfr09NLF z*q(j*GO18M^x=B%mb_!34i6|PSvU9?q9lZVxL{zFUSSmEgY~N+y3q*sFRL`D97qF? zEqpb4Tb^7CcG?rEv0Aqjlrf6jZK8q)MYSB;)U$uS&$y?wAS`O-G4hsr=4qaoep0;; zQcRn_n{$zZpeYZXJNC5}A}Dj@IDHZy;Z&@CJbG{D$Jt74w^m5Pity-oxR~Za#qAoR zU9S3x5$A;uA5Tirx_GBgDU^R=KQjug-RpPejzmd9?Dwu8G8io-Q(;5W8~! zK)*#P&*ogF63zJ>Td73KrlYj*4sxQf(@vZ=GHYHfzq`){hFyk#cTkrBix zt)}wmEDg?T*0WaXISXg4_Elk2TgP#l&#wtrX9@@s8nWwhm3Gku<-F-s=kEj zJcov!Bq$k8L@$R5yKxaOx+EjW3M{GU$r|RIkJgLsndn{`a=sDEa$B_fbH3hs2iI&$ z^D?#K!RF8^QPkAZzfD=G0~IiH@78n%&ewFMxQI%g8ktEowS@?i04X1C>Jx7%AjDqB z=mGq|fMxkV(YbDEpyHT~$EkVPNCH1^TmhaJibfL4A_=81m{#CMy;uMU)QE^#Wv2O53)d&S(t7KlFYDl& zSwkIp8+VJE^XJ(esu5z)Ne$jr2$t~i9fS&)%mY>hw#&nWz46w=@FFc0ig2FZjEg$&5Ux^oE{FIA#M%_T$zd2p3W(0ZA^v|!vOK#;dZejA5uZ8^k9ob_mK_m_%(7vE%? zkXR}Ym zG08)H2(N(3XS9Ymg7KjeLG%cfX6B@3>0RM6ZKyz)lG?^fmimzz!6;odJEIfnL?gUl z7!`e;1d&#Jt=F_ow`;4L%x)aLjry96lW%_WXq8xYPG1Eo(>%dN`P9z4s3bMTAb+gw z3Vr(a#X)@DH*=R8Sk#eo0v@$1QMXtyYQn-E2L}3^cfz0RHMff4IBC{U$JPb`I3q9<*dp- z*bCPrD?IlE)(ZZgiNR)F(`4?9XzI}{TcU8GoNtYAEDui?3uS|)%=!FO6MRh1nm#KZ z2xBQP_n!jMR6&#grF%`C(x zqVk}!UJBGn_A9x#R=B|Ck|-!^`vIa{7Zs)Kwul`WAU@Lc3JE`}=pZ2KP*~LWAG}N> zFX}5~{E&Q%%AU;KpYC9BZZJ=Xa0hNs0AT6ap$8rMaZ2l6& z0{absWXS3Ay-aj9J6&;vz}(NO5jue4CI8^FlCaij76Bp$n8F1%uZlh-+oq@J3ww{O zdNcyW)Qqfzy><(6a;oFHWc}9xm9ZkryTE0Fnza-<1h+GvacpTN&%}37c4we7=Zn;b z2a9tkaLY)yy1nqky)@HlU2Eu^XK>F2-r0~QL6XXO~9=$fid#sCa;u2W)I~(qm+KKdxC!(v!3{93k8h(9fP)r7?e+N&Wt=^}m3Wwxk+395t zoo8`anv|v7ckTAM0-iWK4kvx-Mv?n{aSVGI16hGvayc~NdK^@~BN^oguoJIOgAU$3 z>~B0A&F+f1-)U^-UVK8y@=Fge)#{)43dKjOqc3Y5H`1i)e$|$Vl-WG{Ykxu7sMIah ze0nK1Q;4#hE<4s1UUu#M1ls!_5p-@Ocx5dCcVi~w@!2Ro^Z+Ot2TNdokpG3YUmOkj z5`1#_%@?lm+gKf;?wk8xzYk|@Jvl&1gc>F)MD46*vRbRO2@g+!wY`U zj8q4w@G~Ge!~RGYlvi89I$qi4IaSO^QTUV?A0i`nQu`Dc0+&oFHQN|>CfZMaYGFX= z9JOO9=uFHSFI@e9mp}I=dqjL}aLv|?Zja0V-Y)r~f)o7wo4c19P{8z8pD?>_yF;j4 z(xn>=bqYSKw>bJGWm}%U5Iot`A1OS)VVddjq9x-`y1dK^!sQUZ$z(H9ji%wYx!tK~ zV=;C$Vp(}@zTT}Lbd0M#P02^FfvPwIe^>u?hM zFz}D&$v>LDWJXj6RY)VPV*a5*jCR2oA|i@o#=_)Oyw%CCwV{|GXMG~Q$L zrZ72mSiYXI?6-X=abIfce7bFUMoI@WH4T_0DS>wc%50&km6}DOlophl&A=Ejef4xj z>=Ce~&8d-3QpissXBVKbQJ6<9?*wEE;DE#P)uciVNmJ?y zNwlPs`bQd}x(g3dMjRZa?O^g&us|U!wZX4-jie{mOM}j)mxY4NI0OD4U4pPnWqpLXCpI`cJE!nvMys;*zKdI z$|0`4(swpY1VuVkpZ8EnL^R(m>en8JUW*IoQP~{`srW}@W~Fe4Qr<{KBg9W;0%@uk z@Q8{prCF=R`85q$nJr{mGlo#&>qOPv&eUGoK9zcO51#*HD5@)~kKD}gaA433bXTx7 z>)E=k04Xz@l~%?1%s$Pmv}-7dqqpv&)`dR?$T6eJYQshHRsp2<`JDj7YeD(-fgP(XkCt=~wq>W#!#`03uA zwZjmaDy&ZZv*hr0kdHa7rmP^0H=uT|3%9d%*}~b^Fj?qwr7vIjq&Bb|wjpvIOWvNy zGAPgSKQwUN?kpGHw0eB&R37?gJ+)ktvS3?Cp>0{vA^h|oYOnlFIQ6l7)! ziNhk|Ix+&RkKVrwbpw zFr7LNI~;Gv$6HZ`pFWJqh*xL&VOhM+=%KOx9yf(fp}(yGX+(`R^!;;L;*&EH%Z<@y zkD@g|uSI4FVqPd}X0(F%2cwrC!o1?Ts;FbKg=?i?Y$=RThhkAlkgVVIS68Ks_(O&zM#*{VkFAB+d{E3X3pM;ufSN*5^(e)(9uPyu(IV7y&#ZR#<})><@c(5b#_9KNV!tKKd2d zQ%p^CIq}K$1{@HRjn3q^Iq<>uUb`VP?4Wolf9>I#rM`t(@B}xeM?64M&mg zNKQg@RgjVMu-M4VaLzPO`zkCH{o-sIJicQvxF{nT0jKPC1QHlyN`CSE4FMi7UoIGM zr9K42?9s!;`_#zvpfG+Jg172AaCQ?Y6A7$b%Ub-SGlDCG{p7Q#eeK)Od>$w}y>%Co zmKIU~Fq#1%I7r0g+dkvwHBg~2_4Z#;zRg9$eS+t4 zbv%|c1trMRw6L_38`SeJ?6C7`wF-n4cJ*Tx0B#C8H=r-u{>J~=iPHeM8yg7r1-Q0S z&&MiTjGFUYq#7{n=kW5hpCpfe&ahbx9+7O{Oj%?w$AfY>wk(ImlV0>^!_OqcP6|Vp zY#712@Ng_5d>vMp%=|tF^Jitq_ht^H+!ZrLdXA2dp39V&NBmV${G01KGEOC_s0p%w zGE=7i+4u+X8=yvRm3C0&`3%Th1Fha+qkh@s2eS8Y<*@czn!8yO;>MI!S!x;pIr-N9 z>quso;ev)^YUbUgX~zLP_S>oIyz$o-=8w@GD+L_g34uE4p@HAUD!oSdeJFHjo4 zs$@cSzneBAc8@dhpi<+WHV%Gua>`#RqwDYgW9U5mq5k7IetSCZ>~T1IrXz%myR#*o znU$=vbw)_a9cPO(lf;poge2`FDiuS<${0ra52JCTf?W*C4ph9-Quu#=Igh}CQMC_pC}<7_hV1X_%*kvY6vcdw&LF580lFum!3B?H39}c39fy$Ya;SJCO!Qw zmFs4ayVb!kI)CCYxFW$#+o*${*m@O#VEea7q;vy}G1k};s}!4u^Gc~W#1WN0jBQ_86#u?;8(wyeCnBFoz{27PSq6ClG4%AqGKjN?Q(;tC!A!cP3?jn!En_x)0+NxK z*)zq2hjRmbsIb$jA(k`CMf?5SeJaWjlG8-5=x5+N-d%f)Pq8VLjmCxmZ5@<#Ajs}L zlWtfK>wMOfAnozXy7x~vwF?1I^RkpH7M@eX3^`woe=De-1Qk=g4#7-|L+IAX5c;`m zS^FOMCzwV?=9#xT;;BtxwIuJ9!f_N4nD11{E~3k{K@Zpf+Bm>xnd40e>JUIps%sOb z&8N`Hz?IPxlu6TDwF)vzlTbYT_fkZXFnBW@6{R6A4yBSt8I>^dZw9z8zq8Y9a#}zU zqt3II06unHP9}l2FTzXW-ZIvM)urJpmjd$kGL~mTzL(4f1*eB+4r=$qF9c>7u1ri20v(Bb6PV4-H+`NVV4%scZp-7SjM{|m-jbZ`JW7PLuXb~#8l+oJgdY7 zt{T?O0&oe1OLj82agdNfL9f+fx&%Uqz=#)oAnEW_rDtYeW(xn^8N2(;3sO41n-L@Z z8f%im*1n9S0hFsmpSUnfO*1SKK&h?yP%3?AmL7W-pu?}uN65l;(^Zs^itFh-!?@4I zdl^v)>9O?L_sWPo8F(<;snFoF(U;|*vbQt?c0$`=aw8m49uSc^s~6-`>Cpa|fKd7k z2dQjHyh=fx*Q}d)Hy9n7Z)|>VJgdq8*V3IW5`4O47cO%S>9Get_`CgH(ovsH;Hd$z ze-$|ke=9W8}wm7X4CUwmC$#D#09Ex*F1SD;>9%=5ha>{0kf1&QEjV zYL1qz_Wt-hz#k_@wf~E&a)>dLmsUDUW-h`)jljr*bqwFHP&VLCxjl*o@(hMz2{FIL zGgVyaU>9vDO;|^i1azt9Q}uan!8ovQZ`Y{H)gLZyzF8N;7A9?^lD3R5JHJ zmBEM%+N}He#tc#rJf>{yFd3{F(oQPX)-8=lbmRg|z!BxA+Bs-l!4&AS~h7!aEWys;Oq69TU4DdcrTa$`0TW%xM z3#V`=-u5$Sw}C6q40@s(UOi1HO-_ATjo^nZG#?*n-xY2LaYmNRiMiiN@4r zw{)ZA$ZG=u*Jjd#+CCI@g>0K7tWLS&G`!>pTQJ%GGOJ{ z@|D2r^~)of`R+KVqw9cY%TeCSwb|0D#&g*cNRx>7Ez|F%T{R6eU)ue&yg+K__Kn4(+2(trn-k-(TuGkG zP7aN+l$k7pr-nEB_Dn#9_Z$9Wbi)!@A7x zk^eP(ikP@7Tyo3RE%xfkh#ASQ-NXEzM#1BqWGQQdt#`cSx0SnLKl7y@CH&Z0EDDnF zwuj-cE~&XR*9yW1%Hp};-{8Y~e@ zHCHSnO~8md%TA7F-)VhVO&M*xC2@!|iO};Q_8iCTPPv-%t8zU&1Pnv~u0UO3I>;j8 z$IE=1yE9l1%rPPvCLB?nYnawvQfC-FF^xURJN4BPcW_=)LClL8^6jj#4p_kWtM3dI!sjy8i`E@yfpHcqZToI^89Q)j`ednO-N8QJrr?^#TY>fM z6RTd4$5VbC588{UB_p*v$Gy!*BwmkWO&S}QXqrFA!5DMycrQw$m>Y)d*&7DkRzrkQ z+CWHQd@4fuOM^K&a^9KpjLVwR6;KUm%oK9~3OwRjPMZ#BKJ_2jYxa+3?oazzWMc^8&*M zzUhDy{Dg51>e?#NqK2eepo((>11)gwJm@!DY zEFWfltU_q+{Oyf7A=YDodpYNOo%W=>I|Xy^CsvcjJvRU}7sjl=v%Oy6*#na;_U(nE zZCND8!aMOY=lMctZ&Rgy{KO&UYxDL={}*Wom^gA@4+YyK{12w+(k7L-XZwVKRAz21 zl6x8~Ko-gu6>s;LiHDnG)WeDUcStW5ZA-6=u#50I$qL9jd2EZ;H;4M82mnzvMf<$7 z_`W_rb(K#Ep~?q3Va^Mib`=f?9zOT&)ZV00=|tA)lfCb}SEg8OiTb0LfH31rbO1c~ z3@|#Z2OIc`W!VGMm4S?gng%zD?wE9qvN~efbIj5>plYTC?WGh<<_nL$ z>6#Ygx!BW#4JR_cd~b$8Cpeu}gIL)}ju{|YtSKzshs0f`jw7EWHn>RJ)&;g0xr51!UfoVGYjBMTj*W6+qpSjD;8J0 zLwTvUs%vfZ9srdMFQ-cMT9DXyDjnR(hH4J=z!!aUlH1rw&mkaxITmcLHhgHI7Ew3z z$Nl9BJ-qg+8;GAoPsh_^Rux^r{Dgbm1$PMM2@FK05E%dHmB$Q&A(zsF38mc-99m2_ z*<^SrZz^Yr5E*tl(FIw|kKh~8nHGGZm17pgCSuS1W8S8wr zV;qsOCxC*%zVcd@J~HNPx%by0-96@&PuvdUF$?P#&K?*(KB{9%O^F6t{y3yV25}nGBmSsEP;B_r%AWB7Jn^*azShM`!_q?(B^(DNQK1aabSt zq=?B>#l)xpHFT!rh3BM2msd}!IMu?(04oVcUR}NLqd-o1Ylw8T1BgewBq$SyKDMwR zf1R8fenniSitH!?(44}(5_jdxu_ZDWTapCA#4WQ?rH=dPzsrcQF!h}eGpf(DqCfpG zqkjr3b7VfF9BPN~81&nU93wS=rhXO)bD{eg>KM%`@)H>ozBVJlcI4@VWEx9`#|cH) z=aIoW|7yY9Gk1u!HYGXoR^dN=jWOgBYdI#NU~J7yTlcsPuGV_~u}99ST-D0gU`E%}d`(1r15miJ9?k&j+1CEC2N()Xl;O^A&1rc@ch&O55=WS_69z3W9u^c9*f5P2zD zRQ2SdGOWONkwW##SOIpY2nyro zEj1K2CW1i(*59dje*lQ%k2mCewU`fc%=ps)!gn(rwe1L#oBqr38e5ECWgMeIE!40x zMZRQ>KSraim!Aa(sgpkh&UJ|Qk9`$s{F(Inmw& zcs6@yg%>Ut0^9~^2(GN50QQ?muFg2 zpd;V+b77YkxEOs8p?ddWgynFEF&Erwryt?$==}rAo`;E>x~c(Ols%}as>orTkOI`gFxPxY}*IU-1Z!~tY~ zK`6`q`Fe%pT^MwPF6lBW?=LOCA(<8;{o0YKx_0s!>k%d5?pkBDG;gC*qugbs zZl8*=XPq&iuW(6ojnp|T(6R!9RYcuj9}94uK}rw%Te<~a#Rg_^(d|;ePA{jgOA&2v ziZ;{9A>PDFp%O=lwcyu9;H z2y~(62<>{m`*aatNm{q>7K0aPnZBxYQrxm}Qjs#t`*dnLWZFWeCarFTmj=?GgsDg4 zM-dBX%f>vv5;~f@JbF%2Rm7EH)w#Q2f$N(bXIQD8MT^yC6+s&9QU<#mPeH zJfFo3*)dR2d3~OFtc;m8tfK#KkX$sCdGt<|7c4O91Y zK84GH3fdL?bY-RRyt*H!hYF|tCjxcOBgF2QJmz^C#g4femuw#aRoK0CVVbWs=!9vs86gpPcWGL^}iF0 zM0*FMtK)X{OT9k&U8~=YeSG0$>9NQP95Qy*X|G)(cdPPsEE zoL?u!nnwEg(O#p|EzDe8OoXfGYk{^l*T!s(|qW4m^`78q6sF?}VuvU*E`r zsgvLkJ-5kn-SJa3kWNRj0SGGTHxwY_WMP^@g1I15`#oV0a#-fNS+}N*PheQMVnFUJ zAacq3@v1t_wZ%w&S`YFvvP5}o1H;FG><+eelBU~69qL)9PabQ%bb0F1G4lm+%LL3! z9A;X5t;k4)Pwk`gnbIJ~siy~ozw}sNNVrm(c;(4O{^h_A_agg#l=YFCt|Tt<_cXum zjAc^zTI?(&zxw9fT~IRbcEN-$OJ8@nRM?lKuaLT#u4(J7nWQ9 z?CYU^zCMBY@2><0V!#+SVAT28PfYtcDy@KPHUDZ`Zs;?IcTy!YgFo4o1%=l()r5x4 zYR)}w&e@Pv*{kySL{Vpg`KFYU{+1l0cy-lY<~m|#gjJ@xtWedXHI1V>2ZF*>m4cMs z7u))TWk!H9Icu}lWoK|#&j=?e0)VwR&$sZBlANIb&JHZjRlHx?uq^JG@VHi9NmsS$ z&R)>Fw0yV14$WbrTAxgj zBQWjY)!sv_lV9S@wbY_Or;qZodP9xcXRCW(-=*kl-ex0E<_2Hp@x0-paVs44S?Am{ zQSX99?fksGZEdSjH%HVdJw@z&a=16JKmE{zx~2?t~fuA#6tYh z3P3)EeM=8{PzU-)!qy47f(a;O(OQIrp#oyi5mBhVxwysw?Za2UD?% zhs3WP&^8x&-R$E^f^-1pzOSBbZRcX%zIJDr{wT}Kic%`$U5@V1mJ;ike?&)@`0k&o z9ZN8<8|Zcd+jHA>1QMRd0G0Kr;Fkz>#!<>mDH`hRnA z5m(#>3hbPf4Ab8B>fz!}LOy?J=yXAx&sQ0Q3?GSvI^QfJnk`F-`h64qV;c@RJTF9c=IksNW^p|-{lL< zaUrs+5vV*xwzAr1*vl%G7OJF|3I384y#1fjoTkCI!oAr-FGAOTQaOLGQrGohkk@UL z+VJg3#aE%Xe}1k-W?c@>jSG4DCQKprQDtntm9CQvk91y<9_z1mehcp6Q)k}BIcGU_ zvHRx7cKEq|8OtO`_@j?KECOxg}cAtyMR~Zxwd!aKvP%tv?u9CABxFyI9m`Rt2(6`psdalIwY< zvnyLY>lrm}sim`QKi)IA5MH0j4wc_{ij}0;E4KT?;Rie|cD%Na7xj=&bT?)#1=eto z7u7V`^sU=utO5yvucolGC3z`Rz6-zK@eS#v5V8v6`K}zOGP~L2tm35T*l?n5?_Q{$ z`gD!^+VI_*Uae0+4|iW&_qrB&`r*YB&OguoetPm@;-_Y}i1>S}9}=HF+rPf0BE-b0 z;RPv{Xi>Gj2{z6riEn$E*Pppf@#SM4z&2kod$;>Z7lnt|Mz1enI0+GKn3LiiwEsh#* ztdC^j5HG$|K_mZt1ogWBzOe@(y_y|_q}iIIx_rIh{N$~1r{iw>kGv?EN`{KM05DVR zsc>iI`=P;a-s_*npK~coW;!|$tf7*T-S7D)VGrUbZQ(ACR~c)v8yz_$0+Qz2GcDT5 z2Erv1ef`|jGpS#ue=4WEB>dbv4|M;v@$ggHKYmGo9Ph7JX?3a=hssM3m%d-Vh}^U7>`S*aXfq;sX9lcqK9cV)zUGs!G=2 z1#k2e?E!DrgkRHX)Tk}D3sr4~bY!RA%odbuRc`ECly!sD^bI?=3a_J5m2Eb=-~;<8 z8K`oZFB3T#o~u0)N&*i28jW%o1$BjQuKC?UKpdBpc9LJ~&p9#Am`n@VN>7!Nmog5*=1L}o;Yu6)Yd)8!vnq%ld2!K6t*;c&ci3VxXPH%#A$NS;(RG zUaCF=zLRzuY@A!ZKi^kew?E{S*$cM*CSVdw2~@hlg>J{^Br6^xv>Djc8msELxZDaI zKuSg9OZ49FC)CR29U|i#)%?Ok)54w=M-tPzk`qERz9bAB>DU%X;697&A~Me5i2xuw z4C>aFab&YS|Dwy1?7y}_kz3-Hp>15*!lDI>@jEj9(tReSukh;#cFURVK>~a$|AC}G z3&rw9=63fskPOD?QhRKlhTkqem6ApuTIpoP2LmWWqJSNBe znV|AeaGdMIrIaNxMGefiKHBgZ-TP>AiTRkH*J+8gmK zwb!7()|WBXSU?St>2ry;&%{e^tfs*V7LhmHF1K9A9DWd$#~<@8H+dW^`$K%mBscha zv}+#Bjh<@$h7Hx2?G=qMsF0hdX6e^99NcWLkTA?P^u74~#D}?xGMjr=>BPXZ<0vv* zJPMn*qX7M^K}IDFnlMM|Ag^zV%Uu_I!o=7%>PZ%U;*H3&ZnJfYgsR1#C8-~ok8o&C z)y+f>bttsa`mMsW$uc}YkV*EmD&~c_OC$j9u3Qc0(bC8UHa$qmz!ag*SHTJQ(}F&q zW7y25oVv$`>DdScU!3P2c%;U%hDr_87AS2AeaFMcoX#! zP1{QO+^r5fz$V8y6Iw=0PPii^)Y?*9W&Ih#F`s%?E|t{{>{=YHtCm85AiZ=*@ID_} zypeP!5$Wa9D{8^1RX+@`to*B+U>fm`Ogzss#?V#lN+55aU0N%y+Pk72RUNu%n#J19 zCbd|~J$Iu!fvPO*A-B;V^NHu%_f2}x2y8E=kOWPAbgrE@PJl+7_b9v=J^;H~Pud_N zjI%%j1>&A3HwJrbv%a-=Z*kQqvpuN^#5*k{%QNTY_`KcCC1$v3B@=FHEvM}a!j5>= z$=}tVhFMFTR378I>Rd55rXRY)7yREAF#o>E4T;U))-(wY9m)p)NUoDFj(uDBN^Q;E z2?1`Dk04}4_L);JwhxH=EbKU=$HQj@X@50FrH$=V+tzME)0s4_ikTOB>Kdr3#_s1fqt zPzUhM<1uJOlAvq$QW%~i2R+Oodtp-C``)gWTR*F>{LF;Q2*?W>iP|WV>XjJY#|Za0 zo71{w9)4RO2{%}S4r_w4gy@r;C#93viGy{So8Xqz5?8>o zac8WI_O$upx|gLCN&Ei>TluCh?TmSVlovP(gn^ZVTd)mBc?IC>Un*dR>-WdNyWW3? z;Bq21AszVE$xT+~8|^tM?r$g`;6xe z2h-Y?y9kh?7=pDa2H&gY)J#Kj!+x_U4fdslBKO>2j+#@ptLTe%?UvYB?19?(L8e5B)$2r-tisOo^sF2!kl_oDP({&qhR<0ao;IocyKv3KA@J$D zFCoY$8#bYV;H9M}*&vt`2kOR!0a%)RpuTbdf9WxT@R%fjuZZTBtR@zGVq}|$zOrde zZ~#j5Atmgo3nXF)FOuA0zr|&dJD7{mf2|`AsH%If| z%6YOb+ufKmPL0-5fl1QkDi8iOvX0dsB|so@+#x=vh=213dlDNUW&T(xKK1VRzEckQ z-_Z>%mW`#gwiTgqEq0quzHmEEGd88!S7lc}BtF_Y;ODw^cM7C1L!FqVZU=z50bq7q zm>U~19<-@y4^~8d;rFjt$mS;XNXAWw@qxsCj`id7Q`2$chGCEsBe!g}xRbWx%$_@T z9|_@^PQ2o05^0Ri&K@b9gNGlq7+Ae9RDW4@>6syaZyM!Gn0h|K;|1XXaK*SzW?yC3 zwJ6akC&iVM{kkZ#0+dz@* z=;(t<1(=VP!eXPD(ICNiP2pc^Y@UNfFjKu0pkB%aj}YMgbVMQmo=#9c76P^Kh5GnH zyVh8RgI;uNi0Hns!7--0E};}+``t$M*TLRcxG`!%c;d^ZeujTskXs_^b4n1@Mq4#K zP{R;#vH3DkptmQNqVbkOXe4~m9GBE=`+}8+$Kb?Nq2i~;B?KZRytgW>u;PDoz`R?2 z5}GuLM~nlv7P%t>5{`{x;mi6DqSDXM(*4)d_4^4U+X%xSgPmh60g~eOYoq*(0>@>Ee7R< zAKu6X^@>6HF&6HF4r7@Uq8IO4p}96kL%PnSGljof2q5k*9!^w{4sC;lbXLu@8_cIy zy4{wkua@2;%Pz`E?2$$3J>tfT5J-wWgbjK?MJA0&>@8+3^oVA2q!(O{MfAw*?Y9wn zP<)J}O)0-C*OMA9DU6w9!wNy?*TWkj(lEY)z)q z_MrrbHqR)^1lMaTua-p6HqB^$n$$<;X*6(@)QY|qDQ^K4HU(25>Rn5(Yd;b2ogyB- z@coTbs$xUnXIU3};uSx9^p>UecUk>TOTA*NH-&Isj}))8r=8Puu%$;zc#Oa*F(;2n zEfi|U+z59W(t_5!FsSoZJG%(UJ6t+t6K^9mun2!%ARTY>145DcT>XPi7V)nZjRT5M zfa3autfAVov#GF=@3NhA$<>>JEnH{|rv}@A*%Xdam!s&R^aUvH*+PbcTgS7~42v8t z*PJ%n@9mjcsHu}!rX)09kHakz-@NY`32t_WRRbOnK5Mu52x$Je$6fEc zrCMyn!PtnHamY~@@EsAaJKG0$3qhxfx%@S@JQ0T%qObJB6~b13yomIxf*g(vZTsHa zelXfkMAX@0?%dgb^0pthW!_iI`avK)T+7e1`A+)>K~#~y%EccxA}4WXjGCu~{?_ic z1n(#{#QPV_aWr#thV3H@3SrCJwRDOccW-G#3sUeYD!dHV76;DyaYW;}w;#D(LK$1RFc-2mK z_&0(MjW`JO0va4{#d$*I!%X8v+{yYsk@RGukL8q<;IGc?0IC1(9u|z3n#1`Rp7pug zgWLw5;w|Dmfmo{mtnad}4nu;c&ju9RydR1+N$1`#rNc(J&{B?4{!vrNEqCVo8ysr3 z+~9*~u}PysvrAV1^)CtHoJrF`xsiVFKuF%@K`Zg+*3m0!bZbuHWI}K2d<|r;qyN0T zT2thW##+H0&U;q@5PWhxX_`vtUVj6TM?!i7dWn2yp^x$jA;@v6ITFypGJ0#|w z0l00%Py#7`Pm@=byge!V&}tuxfATvBih;k95hd~Mm)+0(n$1OC&vU_kQW-O8ib&M~^q4=;zIO3(ny*0KAcnWV1n*e5?)k?!4cf4+>wk&E0_N_L-SZPA;66oBX(5nNP4<3q0;dQHKl)0W4|8BpHgiP0%1* z-cy$iwo^1`d0W(^k%$zZ)r3=@4bM**C?=_^)i-y2Ij%)xyC$A{VFK&cOMFq%+@Mtt z>5Y^h+K}}xP#;$T9ejZ7;4td+JmWpV0m6^IxiJJlkvM;%hyzhY9)7?>O6`02or%)x z3Q`(nRuU#sA-cA8RH+d^tNZ0uxm2HjAzyW`Tm2_xw4yDYsVXyHU6v1&UYZa_8QWI* ziKkbCPyj+kf|e&Y^9o%QA}{;1M|72RtSXb%I^?ACBfFDCP_NE4jtjultkEs|%`^IW zN31&(j&$_oT&{d2*Zs?rvXoGyQW)x*mph#3birGbM1mLFNp)o?NXY;$eudezn?|8|~%i+cW_&qt9_+TmaGsBMRXBjFz7 zN-wS$^OO?iI7(xsT1Y&L0Bo>E@FaKcgxn!GH|#^l=~Qy+%q?D5=zBovvy5a}(@cX4 zU6A=sWxHbcsDQKuDNWjr2Ki@IrT~8LJ~k>!(?jt5FCWb)a|mAJC{6KvQq-JT*o=#4 zi?PI2%4m_=@viV=UZ+Q^?}}`m8GZBg^fLSWa$W9~@Y%K!*PB0YU(-4LkU3|1a{Ssx z6z|cwe;NO}zeX;1;$B8b1{{tUb`^YG>hR`a?3>PsXRklY8hCn5?Oaek=0i?cEH=6D z^E3VyhKJgb0?9J1}QCgmA_NRd^HyM@<}XmN+=WIfKy} zs;EKA7t?Bxigdn|0Yd!}E=##_vjc5)Pwo4{Ih1rf?$&anz6Om)Z4f*+<`?f{#H)1{ zFy$H?8kHjJg3y|`)>HB*Tf#XnO)8mNFF`+mM4lo=mpcyPKlsE~&XRxE{25x8N*=cW zyV_tW$WbXdjG=IBtDVY+qpzMQ_yoEj;4b6O0J@#jKGUB&0r@7MsmlnUjBUvcikT9z z{U~2sXFElZNMAX6Os%ibMc;0k1XsUoSPwht=)r;o!0M@|QbZu)e!McG_;q|H>rA;R z&%C$%u!aQ%d46f!N{C|cGl*Pxt90J7>|~b*Q{3;&vBS<`;L*@wO$9H_dTtL?Ea1_i zl}rMA`?z!f4fjhtG}jSMV0F%i9vlk6oQ#An{yc0|f7R-yU8hWtXIyW2YJTEZdd!IS zI}VuN;A>_~z5#Fl?zLVS?t2OdU9sjxqRo@u!1TsuoosR1*a-^(gB21KXSgzMA*B6% zi^8wVH<2#L2h5xJEV_DB$V$Em!gyM$4SkQcbgym|aqC<}{QBJ4UY%h;;RyX%QTPx= z7TtLUxNM}pqHq-XdtI)`=ClWP7VNFYkYkBGb7SFFAPkcPVP*+k-)mRC zflFry$CAm7K}v+<-0J(-WF66P6BNN7lOk% zprdvGXS-6NoU8WMt+nFdnIHvazuHT@OuBPzv9Rj8d^V z4o^f8qMyp9tag&F0}ntC2UA}CE{3R*$hs%^{ART3R7;5Jl!EyZH7X-BnYtv~Ozk&= z$Yq*!`&IGiZ=?pc^7Xc`d_hCKlH&lFv?xU6VE8mwxMQr>a$COnzJF|KWA27`lo(UP zCT!0!|3d5%Pxis@blYQ+c>zpwF(xbh&`Nt=;*L2^k#z&a4P=Sx`lUwD#MNvZnbxol zK}IK3kIxYqWUR?@qk}i)&*RQF)k25E$mp-NcUepeIr+&A+nN(t*pwnf+FfaYpGAi? z-UW)bksvLUCyWS^iX}43;ZpAtfh>_ra1uRVoTDJ~qwtDxVi-iYrlXz!wR8AGf1%4Z z+uJ^_nZ($X?2u>DA?xzQcwYhcL!{uQKd)K_X4R;fMBTbiSM| zq4I}=X05IOR=WVPU`Lcc9RMNh57{B|VkyzSQ29PK!jh2knfRyxrDx&ebnNZdwt3@Q za&*AS-Jx`sNuVH$1Myn~=zEx=l0( zRje5g(;^Q}GIK5GL#v@J~W4_SnD zH%MTNCJ4Bk!ncCHR`%!4o%P&1MOjk_iE8kFe?0kr#5#Mc1#bL=;nOW>tOKY6#mqV1h7D(8q>n-fh7LCA#2H z@6~Jf`yGSIg4LPFR8z2(R3>3z{ACkQ;YRAm;B$gv(mzqHgwCTVNLs8wT}&?9!{_KZZ1qWI0i$XCl zueQ)W*2n#e5rJz+X#>nFF7n#=@w0ST8V6bG=Tj;dUmqnk&2b!>i0>UoCuB(R|4<+1 zAP2VumIwmdG`?pn`*JOdNs~jw18vl+a{4P|v2{+)}i`49L)fh14qXVVxXfvjPSXz=e_XOcv;_bB) zo8L0dpL55ueQ&RU;(rTBvod6AbTX!a8FfkGv zLAmeiVurRTbSj2ZCEP8ZH;3)wyn#Q=uMm!*54(egx z%5e(6VPPs^FSkP*qAc%kOIcotKje-%4^I{M!}pSf zxW(4t=K=^Z*2t8IF&fS-7_kpfoezL|j>C?fhT8c;->&dklG0CS9_)RERTY)<`hlaV zx)sFBCh7rDEFgjeM6rvG*aE?Lif$ejhy#Oo}>It14PPEha@g6x(61#){ZKA6Kk(V>&Kc zd=ooe2x?omj|avWut44JIXnQA2Y{L&?QIro?l4;&Uet zpMUKb)*zLpj2zfPRhskvqVxX&SpA?1Y;*X_?t}yAf=~|dsu=j#)&U-y_<75O#wC8H zV*hdZ(pHE(F25(qC4y$FYOB?on*P52;B^;lMW?n7(oCEP&*dQYDVfmiDxMkv914Sm z^4PEkkTY!%^e`K31U^a!9l}-%*;NzhAcAkTIK5i(LA5vvB-vaI->N>5mEnyn$r@7d zyXp?hFgxJ{6y2g>taZf^VauDz@N>B*X&%zj3hdFPgy8 zd8cSxpEUFE1WlI3IQ3Wu9$Iy}e*~r1>mVxq?(T$du8!frxEj&6B<#=m45x3i< z2M%rNH-ewpy2VXoFMG0)^KA46=qRi8!!X;34MxNtGXi^7Dg}r|c`C(&MVVmAxGseo zB(;L5sxF(MOO;nA7Z}ow;A)qbRPBBVl#e5zxXU~avYAwl!lA#&`EGIqc8K*HE*k3B z-b}dG$u56D#d2ww>@BOxJIFdJHib?HxH5(AHe{z7gsB+3io#!P(ED$TFAI#Wr_mF( z;EOist}V=_e!2v=`|^EgM4$aGaKjfp2xZa!dkPI(tJ;@LdDx1s|M(M3+v8$0Xn(`qCbS_ZdDzn_ER_e zzo8f(2+K!5fjPeKMUc%&E_Fc?{m+N=M|8U_|rZY*EFJo@c;SZ+gBYgg&Mf)O1l?JJ*44R zefj5oIgciWGPmH@xIhFJ$nPubAlJ*|`pKk^S`UxTdSk8s3Op1Vo31CIb#RE3u~lQ7 zA;C3Zv1{zxLAefBO)DWV^xGjy3>dHpP_UAXY&2y_|BUd zhB+U$IgXr@B(cqCBt~)y&G}eSk|cJ*2q8(5Mif$wkV<{$EK#V0RES#XfaHU)@BV=M zhx@wk$928m@9Xt?z7o0*1fYXmk1LUSksdw2Sz2Dav!z*D2hd>rEFbh%IM}xI4_7$F$Y@p>b zZQGR^-7XiBxhToXDnor=Nhk?5^+c*`@p(55J$^)l=C9{*XztelsQRmWE~cQwnCnlA zGK%ZS>1m0g>BOJPK{FYMrvSCozT5m;=soAIyn?edrgJWekneVyR#Dc3MHMgfE zhFHz100{(c(_GMk`74ca2i(%F7mYUgkF#&}8$aDp_74g;z?4Js)TQ-)Ojb z;PFt-BI4-dnimMcn=r%Jua9JJ`Out0r6VYiXcMs8hQHZ z%Y@(lE z8*9E@{d?NOcTTDsuih!E(Wo`3{Ps-7AG!-ke2oM!)?8)A!cL$09AW;F3FZ0hc z8%P)i68I2hJ_I)mL~rnZd<=HviyZ&nb?c`^1GtQ6O@t z=Wmb{y24hp^?KLhr;hZS=E%e8ynAK;rTP<%sP*E^*P)S(!ajg3Hxy6 zmcy^dMz8IP9ccE8&GvlXan0b6$yCdMiC5{yEqRg~_pdcYPL4*uh(LDP!{HjmYwxPQ z+bjB-AKzDcc!!gs_mh(!Vs;s2WkCD2=tKa}j|1MuC;wX&1foUKbe!NngG?Y~6=t`1 zkuj4F#^`%bS$6@DU<`_2Hceu}ukN&mk?;;MWFaq8yN(OyL_fmdOKF#>=7ymK@rKDT*#G1;YFU=`iy(tJQNC)hRLQ`G$aIX~6MiBMz{;q>C=x z0e-A6GBN*3Ch}V$;5f6_C@|j4>cGN+TNhOu$9}XSzD75fO>P+61$_U9(O$8k&rHYp zuH2Z#U0&fK2|tN!G_J>z;~M+I(sRuJ$ZXwPe+O=>j94UTBO4Gu1LIKRnGg})E~@3N zM*JE)ODA5~QTP+>q9uD~dxRn%S{TSx_#+)MUvgyb+|pBat+9b(5I?Wyif_pJd(Bc|144c%2#)7K&FfiH&BVD+}CWu9`qrC-=Rm$cydbedf)RI1m~&*Z97 zktA)WOiV1J6(E^w=_pZf_SNVYj4I#C`QOCQv&5i3SlD>|1xD*hr`k4Yv;LGj)cf}m zJ>5he&9CoVxH;778d=MT`x2ori}ihY63@}EyZI|qqlgC_XWwI2Rf_KNPLKO})4%nM zcNBV*E5%Yf3GXAfJsg!T1OdM)9J%GuU*Zwx%rEjF?v9_zG~0xipOI-2@`4IXLYm$6MTezWl2qTU)(336F}|x1jhNs3pp64$e<655u?PG7UkN zc|P3Fs-EqcI~#<#J_WIu3bQMCR1x}!@2|jLMrOX6?>K$GM8eH0riIZ#xA$KJ!d{FFfkvk-aH7yM@=paebG`v zlu1}*MSrQl`RXCfZkY^HrtLNVln9VWkcWfV_e8tWaMhU>JV1{@&(|*O`i?uo#vppW z)OMC8H7g^&l1c=1Dw|I1d*N<-0(oGOoNyXT9{$-7aG0e#Er5QAX5ou)5Oo^<#@GHn z`vfNHgi)93*!SCKIh=}^4!DYyUUe#PeD4C|F8nRDtC?6)oWp0Pp?4&*cL zWv!O8sPK^yt4oETkd@Z+R>Bb%hU?=&RkxQi-`9jF?wNO-3*uDW=;qexdyaHSM`LGx zQfrA)<9{#%7eeA(jRDk4^fAmX@8$dExs@v60h{(VBi~GRx)DLDw9haqu}`6v7(5qp zc%XK^&qVQ3P_(zob1Dpo^j!|Y$g6BS!UjZCqd+wJ>ImW>?U7FLIN=%)La4tQ`q2WW zQcxCIu|Aky7!h^kg(`<5O0v=*xCP<2va_fQTrtEnzIUG>A@`jDSyot@ssF4JZNCYW3W(wuZ1$LD7+UDbnPhWrDBbiW zXup5!cfRO}r;A%iCE`9#)myncaI3T3i3@ zOx=mpi_iNDy>da@<|+htCyPSMU(MCjY~-)uBHE6h;wT4I~9ikNP(pn@^c? z#l-1cwvz2ExhCS9A3-GY-V57*VmU!9$=T=E)hvP1?JmFUn~4{3Gqbpn2fe4uK)Lwk8t=V#A4OMLvs#17r%D!Ue=Q1F!)7Yke zXIk)a00IEe`SqVYpkUT=MD63v8xy4E>m{%H96y$S{g1m(v|c}U4#2;0{^_4%Z_k;k ze|qxjA!u_bjO(yIFf=IWy<$X|#YO4Mn zhh_IqJsf-_>UclBXqD>OA@h^=(cY~QT@y>@+MhqZoD2WU2Cy~$LuZ*dF(0oYiEBA6 z>~Z+7lN%3W_i|VcYcQVBA65fjpIS(v!T9XLn0G) zLKz-~u)RH57k`8sX_j4t%Z{|p4rjn_lWm*0<^K@7gWB5V`>i8F^dwX}@sQ$PD!HdN zcPN&uAJ;i?j%-j)Hf-n|+Ie+H?(UKI4+Cv1?tm5v+;?mi^A!hln_!^F8ON3xQr!KL;{chqzLat*wUHss??Z3_j9f-`GxfLy z)vfYD!=9apex5YJx!Csk%d%USjhyQ}cJ8=P_PiEVme^RPZxKRXz?&q!mWQO~FL~^g@P)spXZpGDI1;<=17Kgbh zC=sPq;uVw`%+r&=ElRTn8iW_RZ@GfhSaAn>m=cz#c6PGwLdQ!RN|O$a0ifQYBe ze;npUB5r^rcOTkG+gEom)B7FKzL*)GUr)vj@>Pb5Y zLpaKHMUP>}#Z1^*4t+Ya%NB960K5kW-ZPA_po1*XU|52yLp(%}$Tebs_w!A+RIj*M z!!E4B{0SN9RH!_rzW2h=aa%18l~5!SkkkV*|HZd|^^leNce4Vd{K)`0+1&0o4$MRy zKHt8d!=Q4+2T}*%T>0I*Z7+c{VDq5=oGmb)@;05@Kf4GxZYX^$ z#Vv3jH?6r(9?49Z0%QzgSoSwl-*B!UOjqgc* zw`Ul%x0XWSvADZ@2>>?h{5uN(IPbb|_GOR(0Bl7En*r1V;vfHA%laz!n0j-RX@2ph zq)re{;xHP*7W5QpWzj`As6#E}IHAsW=j+uXuz5d=9nUdRW*N1;PA>pEm_aI!v5baU zl7r=ik4@vF>HvQrkx^N01jqrxn?Qhs?|5dSS(bK=&G=O287e_%GV?5vd)!vS_2B*+ z8$%iE*QbN`hx=yw(K0l~`_5Y)zQ^C6`=f8!G60dRJ8Z-K8Ngjg<)z2J{f<$)ewOz? zf_u6xq9dG|64#&A%y5;Z-lO7E-s~5AV0VI+6 z^NIP%iNMJ~_w+%`%G8IIfXnYsE669)KL5PSCQ4)5q_2U8ACi1Px-`O?xV6S7y>&J2 z_c=4f`V9qJh?3t~~P?fzhHYDSkS0g$sYY$b+0;BF{1Ed>1i#dFIt?d z97zN;S?T@W=52>8thp}KK}ayjJRag8R1eTL2vjrh7elRi z83*ZL8~$}RF@x-g&`B9;IS)@_%$!-vYPk-7c#tp&d3fErbo#7pc)aquP$MX+B6tm| zM)=^P#lLcA@AmhlP`u59`q2!xlLMHP z>ON}r{;a7zdQl*CK2y^gp)O$ZtMIaPkg*VWA`mE#e#bUriVcdSkA0+H!&0YTkv=x; zG2cX~dWsK$u#wH&G@3e-?3W=6yWC=ObnH`&OVx?9N=M=|eJ!8l5BQl=xv)J5^Sp?} z0=AfZCJs5=FD9ao`}zLMvMOL(3CjydO*ukS_jrz*P_Sz)>l`V=Ydqu3s>Ic(?8N@e zGs$8W<4i_va7%dBJIX~RbL4CX_v3Tfe#u(DC>X))rF@i79{ox>IZ8~}<|=y26aZDC z`ZF~@>g3Pn&LMZ?1AO1UG#I1S5fT1>Z(Z1Yn?ZF;N4bA64n4dF7?Ib<6?suDTBFa; zVI*n(CltZg<-z8|EUPF`r7`el0oZyiOA`T9ivm%DE<4)IiJu{DXRXeK$^515&wsVn z9rMME71#1;BS0T-=JrM4PD=}-DY6d!x|Yso9^Qs)k24=DDr$2Vw70UbI7vSkcu1$W zCE$QPwI$|ibH-YDw|Dv9wYqX7+Rsbl3 zJ8Jl4|5L_lL_qKS;k?b4e^=dB(vWkmpEEZx&e2qP-u{)9-}O$Rf?c11_Ds!TmNw&% zaTLgyyQr7^8Xsfy!zf`R@wV$2a4z{;+-2s2XZZ^Z zX3NVl_oUb-zh1Swfz-y4sVU*PxXFzEcOevn>IU2j$98MWN>jEeB_c4@mTLtU{~NE= zd-|#0IWxU2%ZKZDBk@^ASpLlazN~ExvLJ+?s=*tZLCoyA`AGUvBZI)b z*N>v0_CgLNo}Er(J5htB-e*jG$?o#V6jOdbieSfE4!=*Q!mHY{6WX%bVyG1EVsEeV zoU=rdPIgEX%%2CgT4dYR3fNQ*aV^Ws9pWvMkxoCIH_UY_0HKwcvWpB!A`ra@K>Owe z!I`fRtFHjSkc-bG0ZG9!Nv{x65vF!%>($UJBl|F~vOo5%SY4)HE-sM73x2L<_I}TN zDa`Ku&bDSs^+e^4(GvTjNVmfHL6r(2`45ujG6b}TV{;#>!mz_(Y3U1bTW8e9!?gK8 zd~K`+jtveR7Dw@D9|VbuEwusPyz9w=-s8V3)(SLE`&+}ZYZU{>=7m?YsXH$IMF=lx zgd79I;)SMSu)Osy?sKmz9@ZQe@g?p#MCJ*_??!y4p>NFRZl?Ac&{_<&wccxXf^CHZ z8<*})h|e5M$b9F`vEzcRs@R4Ah#L_qKat^2gONYI3FNN?^=B*#Gt+Bfl>cSdpxbK} zkDbEhB$)kM7DDGf9*d>-B}nE(Bxh0(8Dt`tD9RGpbDi2a8N&!pKgVerY-I?s`VQ_( zPknxIlT1(Ty9l=9gZCoXLl4;&!p&Xdn-t3qG)PT}zwNpB&$M@>X)rKBzxg zf_*xt;GV}#+rmO^{i<@0YUgXOdkIU zv9s7_yXUR(4$16WsnvH6=yw%6??t#nvODfXEnn2T!|xn0=|p79net1ukgCZm_+EEe z*^qww`voCfe6`o;A>EVBOEU5yW~5%(u;Dqb`sL)17xt~zpHQj7o6qeqq4f?8=bw2mtDr-|yZfe=y5yG*iJp zCjlz&qa5;r5H6bZjHI;kLN6#+^jC7#{q}Y`Q39=HA#z6=mU`_Y86E?TSCUo?0#2Q6 zJzy&P^O#T2H^48SproPK->iqMPd|Nr5j}9D2wRw|BR}}6AVDhIvoL7{ zH9EBLd0A%1@!SN8LRgeDS3M+U7MnTFzJeOLlpYlw+G8ohb)qq!=zYfTqW+7|hVVrfw7Bm7aK zLgCqD7gJHejEUiAmFH_Yn(MXj`Lc1fpHW#FOS2XKi|D?XD%8gMh|_Uf`W?^5kL)zf zIq+Y7RPWB0+;d*lA%e{wr(L%^=)XOEmq8`%duKf~*M_wKNc%?dAf}c_>7f7_u%L+ML1q@GsyI z2`w(Uvslr;8%B|PINXL&^i4jr;$8yjN^YlYh!CvO1jyiqRiZ=)u-x{DE7~PfDqxx= z`?j+ry%uUBI+5L9W4qJ9V|-p?-_!xAGszAbWe(-P(YN=EHs%H5mJYS>)a8ha4EW~f z3YBqv#KzUKI8KNZsK!-$V;J!=UJrAHPtp4Fxngp7*6PdIS@|mn>K8G3si^_GO*1$kj@-CRuEwI*_rHj8d0pIUbepj2oBbN;(D~*c7dD6C z%Gow=Zp!Vd^BtU3cp&sLs&GHIl{4b?M^?LjW1)U!Kx-|8V`1_AKygxIuYG^a81`Vb z^Y1RG$*pR*ZQBgekD#hjYSwb2pQ0zSh8RS3LhL65Fu!5X@j1c&LdY|4cm4rI0Ws5> z0hAyiM4gISo)hV9#>mqQ4$_BO7tqjYQZnkbesW~D`YO#dEnAYlR@If>-lhKA=#wtZ zKn1FYs>1Cu^=+;4;QDK7(@}j-kYlEt`Olg=PsVueb$yd2bhw2tF~~k(^!oe? zxJSo3A?HBu?0JE5uR;Ej-L}W)<-!xUDju2NCdMh9JugRfsK@$!ZbId7I1ze{up^?| zD{#v;yb0w|r|h;lXGg%qJy%|~`gj;v3=M$luVqPPp5$QHrXd#A9Z)fu(ZGvjq?^JD zt5Yi^#Nh{yPj}kC0c5wmnZ?GRok1lG?tbDGdSZ`JWNfT&*R#zo^;#}uT}w8{dkP2v z64==3X}ARq2l#LBEBH-(Hj2^>Ak=~|_3;pbb*P4r(IvyfK?$}UZKH2Z5UC5XzgGQ^ z4b+&(*{*ds1X^jiE1N2qJjtYVi-p_5iyF5AX?q(gB{K>^Xamk?7%ly&OK(oh|F{d0FVM_fRNMw z0mLBhGyRbI-}x-;rT}3t4l@4{EaNy%Y>a|gAy)m8KY8N*RG1&9)PEdovpj+n;T{Gy z8)ulrWk{uQsrohv4UU0TGL64oDP(*BaeM~*bYFxDkCypK^zWl|YRs0sjjXKQ3UECy zN|;*qJ0se!xdg*rT7iGm3MY`TWhnr3QJ zt_C*9-S+Yu)?iA#VIX~p0nJxY{Y0sTtEDO$U$?}m^Gw-lo;(g%af~Tn+hHI7q0*AP zTf#poqvX~!F29F{8llxvATc$Hv40(pCQ|NDII!~ zGB80U>8m?nbvWnONarDB{r>$==HY49RT`E|i=hsCik4?FNe4z=imv);9-=qx6V+Vt zGrMYO0ve@$A?>ge76P|z|7f2|-fMX=h3{kq$XaKzZIe4Kb6MDN&2t!KV`^8YpR2?4 zjc5otoX<2iQ#RSH59#;N+s@KZkyEYdhTeAzoT!B!zRBMS}}A{@486vxF*`)QEw zCR2^aG+cURuigROCQ{T%m$!&>6AV-w5t1vgH~L_o3y|1m%Zw8#rIiVdJUFz2Fk4zpyl_CB(HHd7-<_sp{dWT9e(60ojKqJZJUPJfSw5cG| zqK^I<>eDsD25qRFUz-nFu6e5Bh2?;(+OwXGbe=9}pEGkfRth)X?6T|!?`a2_JbrIR z^a^FM@gBbWIPkDcRvM(!+}a_!lM~%P>pj=C2STP~4_;qH*eFj}8+8?$`xWBZrns)c zY;B8jmRV}io+MAv7LA4DkrWGkU!l=9Kgm%D>k#vShH?qmRr_g`~kP~v!5vjU=07{jXKFSfVqgc%wRh#Ir zE36YofkdcBY1j7bEM=Yu2Y_vBK?E_3odF8_h|;N2@%;$pm4TCq*D3)D*H?;FXhes>dmZ_II>>-QGD8w zpX+Mj=-HO+2rTv6dD}W2buaAzP*|8j*t8Ee>_*xmsbrb?cgQEKJ%{87!*;)O==Bki zc7f8q`9#FK{5hXlj0F8V->F{^;;_ZW4e!_Ta@ql?*T|(})kCmvu)&WtwMq`)+WNfX zP}{nr*2Q3OFLdBhuKro}?&k6!Be2CA_pMTLg42Xe5%XGi4;pg))fQN4MPT0mK&^eY z%NHPU*OOTYQoR7dr=!;x@O6RW8-b#=&(5n#I0`u>m@IV{WbIddYYwC*fa)PsTZ-IK zPr=j;nhj^p=6Qzm>QKNOOSUfT9uE}Fqkp;%P+1wX#}GexFf=%wC=8K85R+2?m}CZU z>Vs+(gE+ik?-~UZ0x&`WfIy^R1oG+*Vv|q(mII6iKnRJi9U-DK2Vbi407>CW8g5E` zO+Y(Wht}x#@=@>Y%Y#&+-m5lu?b9JE6$3Rk`@)4+&MyH^zUgYB=YOAqEzg08fvV@| zU%pxDJs$1~9_x*>ycOLHvRdgiZ-XzG-;FMHqTGdtjlvx0$HXBl%%}UKbUO7Z6o(PA zmNg5z#w@NkPP2{H7dq@~&sutZ7dO^#u?BkHGF2!$4RC^(xKawYz_*`GS(Rz)wjB(h z9^BiT5$H&f6}xk+kISH{1K%)`Z_zQz%(L5UB!nc@&fB?9Ua0AE!?82%(e0bVUmeGw zPA&uIPgbLTenoHOwch%G4tLR}m5CE=`~%1uvxg{jhDfrUnBmk74io4?$oEMNU|R;Z zT=4YX44A=~7@PUv0RUOE_F99W#HdaydF697eDIcvYnV^ReJa(uq0h>+8}3YB z>1du`EFSCA>+jl!s#4>3Ov-7wrQNWd1Mi#ayY@YNVdS@^6U2O!9p@cmQS9)dvfQe7 z>bNRz+CqhJnPV_bqO>uG%$cLO;gX3Akpi*jvp>mpK;T}_nBN9xCURv8Zp#*bg-0Jx z(acdrvGjQ4@LDGFaBS3!vh`DwpNf^AP?fD!iUD3QIa21gre58|nco}N)sYQAWz*oX z;H8gu)}6XRn`~eJaiMPJ172XCOq82@{nv?;pnIZw@}yiC6BGb|{&)>e?AT3^uMY;O zlnX!zqM|if!zGR|3__0KmDSl;&a%2miQ!AU!gvSvIOzyz5jEVoCu}h}yccovojoI7 zdycJviKhhk;LIGbB|fNCr<9b6NxFZdpSRYugA#-(jblnD7ihFMwC)T3bvhF(Vxtp* zIX`CJyya>SGQu2{yhH>CzUEgGzHcVM~)$ih!i*up; z@D7FRKQ9(rn$&jM<2teGifRuVBZbX@)+X}ZjgfKG@^BL74H2_A(d5W%w0VtvN75mZ z4LAKXYH3GsCj$r;0byYj)MQJN!=7(nZzqdIHZ+qrSAO+6>Y*HTh;dI<#d{$TjEJje z63MO)1XV;`8W6DA1u`N6!-xyt1iv3INF=LfOzuE7z`vRkl?DXgJs9A%F)%k0>vaN# z0f3AEn0^Lud>P!(v3oNZvwBEQ7<+%HayPc?Yfn$_cLs<-RP+Se`Th|aGmrlT+TI~4 z#Q}|nyF{l??*uFm(G904+=5+u=TW%+-ui=-wN?B7PQ6t)ssw13j{_=%vGOd>>}+Ie zCxS3*067FxmJhTU?}*?1SKc>QxQCSYC5TdfB=rhYBlSjLIs1BYpM5|=K$v!I{mP5e zqtl+<$C}<28obn5VQRjaa;=t7JHk}=WxCyduBz059w(MgIAXY8J>jwCj$)mge(W`+ zn!Du7n+_Tj$^$3PgL`dSp)E3Lf2UU>*Sk&Pqze6ZREDe9@-0E#k9P#0>s?6#Au#|u z-MBw`z@rC*O48l2|J?y4Gv3k}8TasrxK48j(DeOpiFQ+)M1b8=Q^@WIOIiQ~LA)=r zMf$!CC-5DXI%JJiRTzUIUPQ&0ctikspDkX*VxX2;qLkf@&Q)F#LqK6N*)j@yd0d|K{<;I^g9zbK?n~50! zBx;;=k*Hlgs-m89`Nm0tY96IF*c<6gmbG1?nROd zBkuV0VMUXTAu#4E{kMV{JuY#%TFmZymB4ZUrho~jYhqrqEZ5A>co5|hSy*BmU&A=5 zrBz#$G8NlI~9@$Y9)mWu0BGxkDa*;2ezl46u+7xucDRcFAZ6NBvz}I;CzpFd=QjXna?W8mYsljrSvUS+ z)oKZ~1s0Yd-0nLUPW`p#+nq1*cFvSf`|=OHv{z~ps^gUz+RPF$y)|KBjB>C&hfF{i z0sk{5ErPFgx89#0BA!3DY1gsC^v@w|Dp*GMnm zlG?f3niSWyr)RXXkL*JjX3PJ$-xp$;c<_oew=W*tU6d|jZJx3K&!BXut(&pCK7T@C z5O>z(@XBIAuXo$cTm_3(JU)V$7s<6sQ&`UNB zRgwlKGA7XN?{F;94{SG-hwM0rmIBBk-O0j(w?yT?$_xs zhd5r(b|VO(m%Da56Bk3&%29<)Y9TXQr$Nob6g7gJ7rm~g#O4iJw}`ZDUeY=d0e*Qi zAn`!o!|snuQj;HUT~B(*OkTKk<^A^Ap__C04mBm-)?haV6SdaI0DyRTIiWU72HDrMrI1v})JJO;3(cW($o z^DHPP+6AZbC3N$hOeDfeLz|*Sy2TVZiRwa1RAtTObkK91;#0!PtgEgYQJjl*{G17C zJCo?S^3+*p9ci0cVd!`3S$=#Yc>&8&XG6I*SHB$HUiYMO`F=(EEP$P0Rwm@i8Y@5Q&OfkO@cqVv?F28Q z*liVk^P{~>;k2WQ`{ps;ZXFqaokbquau?^6xy3XY71uYO?pykAu`%2#Z|ra`Yb?Er z_8B`&xf5zR>iT|O#f!2-(JKDx-66kYEnkoEKDCSVuYan}OS(cWaWlH)3}pSa!r)Q;`%pggE4|n;En(8ZExKKV9bcv>R<@SkO5K=>$F8c5`=$8 z@{`a32CD)fPNLI6UIK8$VqPeZ7qkv~P03}U(U6YFKt!iU)Ewl&^IFN3{{31NeAwQ& zJej0^ik7Nui%D^%#yZbYWsGLIceq13L+CIPR)R>`6cYov;2}a}GbI(Z<1PShH&=+j zt_DY1TmY+$55w&@Kdb$%g&TuLvfYMfmA?upgEsh`)~wIu8^f=5PbTDE8|lm!3E!E^ zImjdqj=1@oL$nq!2>XDSCN*l@-C{nZUoqI~IU&n86_08(it#LRLE$5ID|;$~yxLw| zuWO)-GSX2Uh$fec@Tx_bfb$WOga5M3oDY?G{isr`s_lA6y`;P_@g-&W&B6cDUgsSH z!jDWMKeZ;AqwP?XfPr}@yj;0c@zk+j2v!~{=10MlslB1{1g z+z_lvXO`0;fDF8NEDt)=5ua&TdRIG%Uu74SknIWy2~VQGQq!pH4;6{oliB@; zDqP?L9$H-r;m@atB_8=<)lj9|(@>L=8Ofoar*ZYZUPg!|8FS@XEc4Na9q^sXhqi7a zh(eN7+Pi|mHM!;^XemwlXIroG2&K9;Z;b}{y>Mg6GQo4rV{Pzf#{y$_&%W?pi)ZJ& zQ-_y-JJO@;^{<>M9&Q1W-~F9^M>^YDC9B@74<^K;jEBjHe-9!7N`r_MOjN1zL28!_JQ09E3M`paOR%9ZR8FvWM_xf9Zr#v9>la`;y`83cofqv) zVnDP@IUt?uR88haVRkML1~?S0wb;nXD`~FQxyzT{*30OIw1}KtmZ>;y$TZioVQ1&w zcP{FiGMds5i6ri8dy`74en6E>lCaBrTsezE=#LN*y6kNm6&$Wg-;ctFhZLDh`;_O) z-Enwv`BPQ6vqWp7sPw~*7^PA-sFV4V9b{?mX|_T_Fs}QLZd4mDbNA*=*!kjmjE2wXp0}=}n9?O9hi9u~#f|SZs zMp=DpA7A8PnmC%dmVD>kFf|SJn;O5>I_$m7*awrVWI`K{FWs^9vFeUb0rU4hPGCc7D%iS{4eoL00V>)oRJy35BP2*uptrM(QW5| z=xTv9&x{T4{4Md7+vQw$grq?SNGPSYi1z6&Z8(NfBR5GmFK~Ns zPpp8NDd z=78I#j+f7xsF}?KS>OOjQO=4DVQJ%~_Sig-cvnG=$yO8qr4iR>zX4GKiI=3wH8>D~ z0HC>ywf)RBp2nC!8{-ZfzHr03US7!DNff+YjpUdhWv=rykJLm}+69FSMP2ZBDDThe;&j(Ec+L8@Nai({8y-h1?2*R}EG|ys0b?3*kB<8u zhw_ZaeH8Je7wPDa05l9J6F_TmlT^Ur0YCUmdV|e z#m!h-Faw&Hzm|2uwo!j zE*btzf)fDRASZJtULyUZE+$TvBmmQdSd>e5YSkLu0ZMgoO|5!AJEYyM^8wPUT{0*e zK~4509|W11dv;295`*%CC&oh~TD7DjUUjzvi>;hb{fhDEoHe$Jd|`DPC<2}?5Y1qw zX0d6*6 zlwX{|rZooVP~b&6cvqIn_Zhh=p4fG2_gRJ#>k5BN)D(BDNgNW160ayNAr$n|c)@89 zg6b{Z0y0GaaMC5qDkNk?2qffO>!MiGIw3fJE#1cqJ*^1EJkczcOxI57L~wZWvqJ|l zna2lsuV?SdPj;xZT~t)BEWl*lx7(liW7a)@r0jIa!>*53QhB;f)_9ww+%O~V4Nu1d z?=Qx9h;gfs;G{WTNdq0R&6L=lLG}Zrel1~#>F8me^vO#ee`V3=j-QAo>;hd9S!Ml} zcNI3fVD}eG3HA%SiI2Ra^>F`aXCM5oW9lt~)cQ&r2L-F9L)s#yipfkh2*)HAft2fg zbg7lU<|nE`jXEgsxg5m`;#L?vME#2atXpD*tteM1`79=`K`+i07AC-9773V69BQ&w zru(w;iA?*jh>J}TQukmv!fClwJ@vIvd8b&@1?*o8_&D2Sh-K zM5D{y6HO8Nu<6&HyPY=OCqD1wwG5?19YA5<+u3LsACED~VcS};ytq6X;G*1; zpy4D)Z@P#v3$o_S7RJdNz%8Z(HoqXxL{04kX zhG<6dIMDr3V}&SK{XrbxVv6-ZPAC6xr)%cXB(npWQ&kTY?qA_(B4zjfd8BMWdx z5d;5Cp#+9kLEL4OXMGU&AiQACt&fKja> z#?Z5~21%yMY7z2lCG(k@$j;MO!=`cM1J>ZP+FE#&6VglIlFw2sM)^Y{B`QS3u>2_9 zxIv8{3?waIszDDm7J?gH!TK^4mi-R?U03_L4s`xAUtwend2A^)X&I`Y8i?<_Gw<~)3)-rjNweD}?4Y_4B_z3vhcg zAatQ6%cWPL8VH>hI`g(g(z`4}aa25pfc=Kc;M@^?k}2ZeDy9y&Z`ZH3sE&MLh2gsY zC?d&AX#X&2CRkWCh-ibu@edx{sI^yuPNt`aNPnP< zaE4VLy)+>c#I;N@D8l0C0^F`os`TsRd`DIzH*o6<(7Zu%=WvT805IE&l?RFds_9}@ zvRqmqY-y@|OsW!{wL<{tYhWqSTB+mM(O&U?JRvIz^hGL6J9>997@njpRGH0pdM(Y zq;ON@!jrNOZ3LSDaYD!dWc6e?z^2xQNNjyZc%eI2I8|fIZ z6#y~*nWBH-(1iuuC>_Pa3-f~%B!cc?DS@-Amw-J0?t-|>e>LZ2{{l#h>_5OIx>V(d zsWrg}m`Q@k1B|(NdGg|qZ(B;{yGY&zmESq^t&?6y#K0OPmB}gF=mG#bb%)1`&83H24BrLyJ(Fp z&k%?xcd|c~b{xLM6dEvco3#ubZCgy>2+s|%yA9Klny}LS8QlSOHfzFlYKDRj|KzN{ zD@j)%!t6|2?9gQy(Jsm;S@A`@+vT11Mk#^+^56?0!0ndLS3&AR3WsQOX#F84hWDq4f=X!rc9kY zG^htqw}{IBE)_S%_Aco`O86Sn7WzQ<&RX$bBxQkEd?XUE+tPVr9&oIe6e+I&IiG$JDg?XY3wg4{bpbJS^a4L6kK}fQ6 z!xblhd~=odq-@xz#~?PIio2+8Jk=v>s-mpL9FiV8TzWNu73gEPiu)DbI0tMopO+j28hI)lv{gpBI}4EeUwG|04ZJQU6<6WKNPk zqp3Le;#~NHWGi9Zl~glLXsYRgY^!t(IZt#xaQz$@K_u;kcJ2J^6@Rmp*$>50i6j`F z0%T!c?zt1F{M^LtRjUN9<8`aJ6Uj>YGXw-m5OvCv|4+s#u3B#dhgBrML_V{ZvZGT2 z6KOmZ+hqK!lR$*XEBH~kAc=ll3b3=>hf@4^k|*$X9SxZmGN@j6Mc`D6pbz|~RG_=- zY^99b;>8$PxO~NH_tg9xD`5Kd4F{2 zF(2WUHG-qMrxrV_KlFqvI@_Z?73w<@@{CS>@ssrQVzJa_PJVfq?Bm^}IaL*Tu3*}7 z8KpcU7dPc#uObL7Vv5M6k@U#WlM9(}P{{hN1x1WYw?vJ}HwY3Xn$Ek6|CenA(~v(u zDx~sn`&Y)7BkM6Rm82pJl69)1Pe{rJlL?l#iOYmbQ{wa0%>sJ6Ma;VS>q0%kYSx&T z^zZOh46NB2gBa)jv$T>=bV)IoQ^Rp z9q~yxpmyL)`Z8l#-R+fpI^x=*lQf`+_s>$7h9S$d57^VeyJ;#cP6`1lx<(+#oa)z$ z%;Z}5TFcFo*afkW8?`zEab17 z`}GpVRg;U^3>;IEdEUY?3MH;7uuq1O~=-`!;@CJ z2@PPSH7bXynq?*Z=kQy@U$=z2vEZv8<=!Jt@cIya2#1s`R1zK{gC_{ZBW#p-T!{D{ zAS!g-T4}TwW>*9(JR?}Q#?R6q<`zI?CdEcsN(gSRK^75EnK=1Egq~^^in_*7KU>Jj z=p|s0?l#I2w@1Ch7xB+cV7UBUh0T1i%snnkJnc8+sM#X!GljXYme!`_>5h018ZMa* zV4t8bqbB7tMZ!rI5(_SHi`nqK3U21ZUD&*DQbn?k}>S6DEGU^FvnPO2MO2v>wRA4qHYd%X(fy| zzsJD^l|923n<|C051R&iu7|33uD<8=a30I{rifXrWQrDT*+_%d=@|$B0fYfWq1Hvg zjYjP;r$72VH>A&%&SJ0Nrv`-tc`I=I9ug)Hg)xK~SF0u{J6pr@uWY7u%}yoP71 zPOCupnFV*f*XF$RdJsr z71LT(MTSG-7loc7*;}k_IGXI)F|>(!x}$f>yt7l>vSFA=pSAbrad637GLKW5EO*DY%_pyh(Bw0 z=Z4IE%-}S45p}O#^1tpVd5>2f`&+jLHDYxXDCr~BMU)k@8k^DR_iQ7>Yp^@jsVW^*wmn8U zA9~~6?&*pAm%=z6nCDUXC|t6>Rb}sY%=NC*U~8#Qmu1F;vHDPys2- zrSj#JOJ*)xu;~GC)~Ri_@9^*J&-%Tr(0Nx41ex=>UmTvw5sU_^XSVgW4%FD}7Ss$P zUHiy!QD=mrLMBcLO)-orTR#DSA()gG_ujMU6X` zzf@!HQ;e+Fr)G*%im)SUL#mC(oML7mRpIIMeSdo| zOJ!T$s}`+`v-WGa4*-73Xb-fLjkZn91!`3k%CLd}5o}N$4X&RpE5e250N^5Vkp-Ec zTArqV9l!w(K7faCynSUb$eMSW!$31XE>z^Zj$qeV+s;Rtk+hrL<@Mu$5_m9OW$*d# z!Xp^S;Do}_Y!C{-#Ph(9v5J#~y|JBITMP^ZaAX|If-WlOa#} z8^xrvjr5I0SE(Xzc_MQs#q2lZS|V-y25e}Fp~|;x{jl*Qm{bJA_$zJq1N2j#Wc+va zcmN`a3$N&frSFLlUH}590D&;Az-Kx!@NFWr8+U37C*X!1{+T%Kr4TlRlK_CbxKQZ2 zMh;eSae6ngsijX7ezHKM{RCW}g63xdh6sQZ3MGy$b7CD`b3%UD4M1B!=#zx+yUC|s zgFWX*rTmg}pb2*ZpleI=v3wB#Sn>K2K%a|H0Kjy)C+0n1pZVEe#T3IhKwUax8>9rT zGsiDLo~)x|%T2E&p)%AgcD31qYrFPRzkd zm6i#HRRsqp+!WnJQ1N1`-lbDqqbVv{I@?BaBqY?z^DFHP{+H-rxzt;lt#UTzEir#N zLgePU=tUwT3Amq3uuEMR4V#2EDgU1yYexsX-U>7GsX|01`WJ>pyp6z9j~H~w^iAFUmrBg$MZXg=cbzI`)0gg_nx} zQ5#{vx_mPkXtj>6!Z64imVYJ{Ue?QZChAhQl)LysxQ^s?J*#!MeT#*VH9q7lO?W3> zELubP;WC0oCDMz5AKVCjTQZ{kFgBF{YM{zGT9q^u3OW=W7T{h&dl5m9OumR90a2XGeY&1DQ2pcB69cltBmef$x4k=e1?|VU+Q(9 zCbLLmZ>3?>B@|AE*1WH%XvzCdQZF$TecLP9hd@`y!7{QiaT^JU!b%nu32y#EJaD>Xa=HPoK;CjTO(+5(V*y?fT`%PkuO1U3@}86*1iY@^ zC*pSvUEQ%KLKdDx&7gDPESiuPRaO*}DK1_ecp}_zzfZU%Jp~y)TaJ^V!D}#(*ZM6; z4Ozbv^sqQoEEnidmc(TP+@xj0egN?JSfd5mO8;a4U&rfRWCjsU>pl52UcO$hRfJpP z0uagM%Nxwr_AJ*nL|ZW9<^NN~$5Kup^gu&HN`hZNMKcnV+yS9tOx5+ko$F|QjHv+~ zM2-V}1(}e8m=G({g(*M5_XCl$&>!of30si5JrF8Qc*v$iT@Um7J8W7#ZneuwJ?QFV z_^I8On+O{_sV;F$6D6Az^vBraV6xcf=pfL$D={9|5J0k1rKj1j&GaP^d?>mIcWnwz zwkZ{vUyqLtu?+~3q}8Ecmo<4$RtNxS&hClDyXXc(?7Pg4@{xOKm2@~foD+R%5hoX9 zoETL4AU#>*1g1G(ESc->+q$HgbHXWy2y#7^S>c#;`5Pc74&_AzzH;|{T@?BEZ;~7S z#P~Cft92Sp6Ma|WZ;sF*qjg#%ivR(c5H%8{7`lF9awGX(T|a&S{9^$z`=>wSn9}@) zk{yvya0*|;Z#e}w`L!uZrHj0MMl5*&-}XG{z(v#38zi z1H+?ozntulDdd3vV!l7331d5SABD*6W^)qLjmQSzLs#$Qp`eF5&=K)?XvKPTL!Qv7 zuXix;%N+J-xx~A2FV!>h`_CLH_6$oDQ$8j37?Tmd0XzCbG-Sn#Mcw~z*{%VpgXDHe zbGt5Gd(gVnSLiLDy=r;^tfRrZsPHfRyt|fUHIULFd)(^+|C{V+W~2-i@n!)gjxpsX zj)lF`RRMq~JYgu*43p+6z&P3qg3@AmfDa88F{&n$LEU2Y#Rp=PzD$faIGW0E5uX>7 zAAjoqLEbk!2K&|v6R6-nEFYZf8BhT{ttH@3ZsLnSK2_%nJ*Q*vSr{sxLgn%WRH&>U z^kHYB&Wrj1xo6oecLOJFjm%UuA?_+0Smnm6s1qYk;X$`=x%sM;t`dz+y2|gx3G2KT zW=HAg$&79|l6^f+U%lJ$^-+1af#i{BTerHVw+q6iHxn2|mC0_10-8_}A0D{wWQT|N zIhE9v*jv&dR4%l(&R!o6-o-c&7r?&>(+6mfFde8L04gmGtD+ws`$G!F9}T5r;`mgd z&oJ;axL4dE!RFL4;qy@c4$_|MLbD{{5L)yZX7r_>Q{5imz&O;xy67I{)Kk^zI@o!S z9%anZRLObB#nO_$D_|?Wc`^)tN+P;>+|Th75dI9QMQ39`_=1g^WiXw3?C=o=f^% z1Ko1Fpdm4#9(O?H)aOUW687<@vTS5Lhp}(ZINecq{&Pj@nXptJ@*X|*yFYsPrQ6J2 zGf!#XtKfpz!fXpjYd$^ z0aL*c@R4)};9ea-Iu4Y>pZ4N+!x%4g7r;c`?u98@*7QI;4PrqBr<}KUr&27r{9EU< z{1^N)7KCPb!eU_PW$0?uJ%3FE?8g-RG#=4weB?!wgZLUezYzgm6sl^Z6i&heu22MA zs7H){Cok}eQ8R92_mqWn~LKG!?T zp25!K8iWUJwhoKOV?VH;ZJ4+oH4zZs?^LauM>oAwDhk;EO3wYpe&2Y~u;8_yPZvhwzg^7WEf=a!i zan)yn2CfDH0(pqj0MJNk5)cny0|Dy0a(0|{6Hxj1F~FrD09a`w1qzs=Kvm;FcJ%Qq zJo*kDGBNR(aq*^lX1{vuzK#Jq%j$!edQkJQz2|Xmdnm*lZ4$Z4|MVnOU8#s@aWqm*?q~V1z2qR zg3xV4^c^DR4PPYk+)qKr=5weB|EK5{-xjBL^;+3ojh`OjLIwgivxM21+%NDTOxIoD zsUWFeb52*us~th#w6wn*4DNiudxBBe-Vdi`?* zACcahAXxXJy!LuKi?bcse-;400DuBXz;8Q{#`p*RR=}pp>D)z##y2``+8c=nH*lf( z-vCMB5iIZIVzK1c_0re{p(k{ahPYiu33Q@EXgq%3FZUCR8{5~jPVVH?GQw|RqE7tM z2mP8DZ!~y`x$ZoC!&!w6`V=ClF#yhs?u?|10Wz1~<@lvRPH{%(H-*1GFvoJD zdn@i=_DU_5fxLCzZ{VHW{r>_ln7r>-3zO8u)ta|VtO}0aPy4Ymbu4?M*1QHb^5dWS zCnf!${x(Tl?*fg>8BRtr+JBx_-irS~-?zb6X^l9xMe{X<%~ zRu)M+8;iy7lz8NdEqZTrFE0y(l+upFyvziGy~w{c)=9~(&{iO>5#KAMV=TJU5`Mgg zZUxYnw=%u5d{k)?SMbsJ{$G>o%c?G8>v<46bLsNe+ARtBAPX>hNi-)*uoF*4R)qR5*SesP%SuVTeeY^Z zrO1(W8h6%$3yfyB-j?I5Q%W}^(b1W;}N;F?fNKrxU%(nYm9so(|?F}WaSUC z;nsQ23H7U28nqQHpWn{Y8&=4!Q`nojpm?mwZ_9yDjLY5HUsM(yByyZ%g zLw^f`DK$3iJ6dB0SOj6#{@UaJZ5CTy%2WQMUU=dH36#(NavLsxsEMy^n>SU%L6k5vkcXTqpauJ*((?Wk!!4YK%J^2jp40SI7Mfxqt88T(C@Ta4VMf4G9X-w%?;)lR;iqRS0v@U5AK4mlxKGGinMySUN?hkc zIRM3EF90MIj~!_ypZF3vN(Sv}FCEq3-QXa6*WDRILc?AaE9T>3Qwu_mXg-E!E$KYP zEAq>%^6@P7(G?|y2~Os^IPr8U&!-LoM5OyEfmcUJW?S>J{oFc}P8BRHlPvvcftdYz z0!vxbCsA_@yJKwL&?5tEyqj)iuZF8`0B8sy=gZd&73e1xn74>vCJXx z6dP_8{t-kq;-0zJ_hjXoQIIM%Le45AwMwViMIdaQt}_^qj_)M^F7+dhYXD%jHrE06 zaS-{UN>A%8pa?sTsY1ojX~`hy9V|&~feY2=T8Zo`nlhCDIL>@G(PILWQzPD0AuS^8 zw}3L8>(kn^0GMU(Wz-)U(Sfo>*|`e|1ryg+h0FH|>MT-* zG}N?zAGr%zIyAX?sJ!1MGZb7}8)?NM!}kuz>|k?GaK9@}r5|_d<-O3VUQ~{t4aiqCF*Y7e$t-k z*qm?TBtfA_=>$QwwhkP*bFJc#k#fF-$tzLwAcVd)!n-5b`O9pFkY=Zr@L4L8U|#3h z^s^7!6*mY)5*ap0WcW2ONy=_f;c$Mxcvl?Tz_|+eUNu=ok!h?eGovWr+%pI&aqj?MtPQ}>S zywnwq>GEmNQL{5FdZ63<^YjRfBGcN-w&2!@#;#d6$c)2`xtV94wh6NE)uxd|)uoAC zicOUeS-*X9e>#))?-Poq<*s|I3vGP7|kooJf_bT z501*25~bz zNeN;~ul&r4Yk+_L{CjsqGLg3M@@dU+KbDbaT}71!MN(i)I2*;YR$SY{n7vrczx+5v zc2^nRzn>sFmDPtnaP7`?UtMyNJOc%*8``JJgb+SOFxuF;NtvMIybdDeHZAv=chFO^ zOsM2#YQEK}_)iDX&kl{*Ee3o_I7x^dVl0y>NyieKk*@G!15oh*5ZK-RKuk2_nS{|`Dadye-#ZcpOH_L z;9GWXxmjhzZHoEJ_94jK(vrQmHy=y?uzmm@G~KKc0Jr&~J6qXhCb*Z5kW>;w##h&N z4T9Qa*dUq3<{**$yf)CRkSB;r>DX-6Lz4WIhNR3Dx73uO>AYca@9%eUWE$ zjr_g2Ztrbn-fv-6QPuceWb&G@cQa#u+yLb_%7WLCVFFg4%~D60iSn(F%4Qzi9I!CR zqI?9YQXc$#Ik5lG1G*JZb&X-u!GIW&DCQ4DOdiN^LH@Ia`>ku!9Uh2NGykmJpxRhs zMzi8(v72tS53e8K@eZ&kS-QME>J*S)k^n?#9y%-mx^v8yV3Nb4faI@n7X5V>9c4;V zK>`niIshX-IdovaDj(3Bu`pEDQFuPS++Y-xQoakKHzeY35~;jJS=|^3JG0Sti%57l zB1=7mp=|<=hcJlc7w0P5b>qMh?m)SReTRv%4=}kyG=gHf*x^KRjcDat?=MAXE(BmB*mP$9R(CHWzcH&2a|-Ql*vOg^UfTIyGuz1|Vats*eE`k3%*sm587* zLu{1Em0VLhCg%~8!U5=kRO22WXaxKBW_xq6I6uU*01gX*QQf+jwmV^5&%`$an#qBgz-B27#AC2f@~-upLE2(a8u;d;V9GKWkZf%nM}!74^ffy1<2ZtIJNW=(JIK)0 zV}kmTA#?f}VCtz609-r^Dkc^B-*K0C56bnW+I?h@));MLYMKWb7QO5^{%&0dlXA!n ze)lQ7L_)ENZ`Z-F+Z8T*VUBKv`+W;{;;CkFR0k7S$U4L!4oo?aKe}x7K!b4Yw{x?J z?CBz1#U3O4Zj=vX-YX7e+sNYck!LHB+R^p7DVjZR{mFKvu&5d= zYP0vTf@#-gR^)NkE|tSCk`;uT3Bu0=l|%iBKFMLQL?fSY`b-dgMxhRxV#GeG0Sh6* z{5i0cFt$PvJA=#4L}zCmd~TpKn`OkwRG5vV`LdIJPZ!JvZ$Z;_*xi?BgL%HW+t85F znX^r-^e|WyjU5u_8`bRN&z}j|@^R{!c8z4YZZWClbFLb!N;Io_e6C{3ud>9i0?qQ~ z%vBYzyvu!@3}2*e&lDA~Lq^$`6y`FVXY#gL-5zt92VreGb1{6u%<1+Q-YJC7p5k2< zVt09YnoMOzt5Gx2SlLSBnp?g)vrbyy9H(68eN4F^KteldlG$byV6CCI>7w|-yb1pl z|Do$UB-iFWOg#sZPnW1!uP2W(>=52O4Tu9B5^lut>|kb4Snim>XG5H)tIVfHP?xQ2 zcj{|j3Urqe#|ZJagqK3(RPto1 zwy++_F83+*!|nSTFer)On_vIf*8}&xcXYY-p4F$)KeViS=27k~$*O&jmYql9PgIOO z??GEf$-Iu-YqoiJHI$FNH2w~;P6XV_-~^bhd-X;=d#uB>?0$85l<7ndWDubfLmbah zh)YVK3-$HX>DN!3p&mrm+@25y(dYFLCul1>f;h8i6k>QG(5P#6wEz~6_RTcnn2$ft zFkv5j0;}O2`Ec(@&Fu2QEA!UV%a=;nG5nBE?MFV{JYrrFdhqR$FE@{T8DDnlS}}hO zBU?N_j>ygctY&D;x~PS@hiAK6gnf2iWwKU0b_Cg*+t}@GIE+opr)MADo7+>ad%Ikh zP9`+C)r~&q)$)pz`;5B6P(6g8YlSyeHz)-X;*mSTc7DQ269LE#qAf3aEzb|lgyY!mGYEE^ z-mst_cAzxD>VJR}^*tYXhg0%z1`4KnrC>MsJa3OmsbH4LZO_ArMt3<#)j!Cr*8C4P zFTXlzD3}q9fSUK8AVVyWO~3KA80F#>-gJuATOl`}dmRF~SIe*5O zPhW3b7=>sb37jsNo|0UPFygpVBblXv**n?UrLSj?A2%`%$yZ~CZ)JzivLhp5w2p6k zuZGOSawCZ_o!WWbk!1t**rn+Z8?_bdX`$7NvHo7)KZhrNiH-F?p2(Pm8I5qx&$B5f zzLT%aTV}5`p7~Jkvico*DGqeQ=Ap`d&063j`aj&9@rJhB=^_)^{``d zk5J4-WaSPL>h5~OzvtF5z6s28ztJvZE91VHXDMG5OAv1C*1i7z($DCt%@x@{C4!Sv za`o1P_}us>#B=0G2!+dhie|-b!!x_DO*4LaqcrI)IzrwSy4=&NZXYn<( z>dPVGCHcuq2y&>zVF7;olxJSN!(Ar0W-D&_-cT59CbhULzcC>Bzl#B)O9= zeB1)5o;InyvMyUGSEF7R|D}r{`qQMu*8_o**E-p+L)uB@mb9>RNlE$@6Y;w z@BX`T<%k)a6Dfd*$qR6w7alN?aDCNTr-gndM#Z?XJFb2xUse!yxEw9l6{)0o{ebk7 zP;$&gvHr_5-cXOwE&=%&7O> zek|Gf!ZOJB?w-x*cDd+nxE+{-n+TAeCPnq0z! zetdMgDt_^gPwUSI{z4Js{=S!gwFM!w(JJ2gzT_2T)$x!kaaDs0a^l8S_p!_U;ZvFx z+Pg00ACWKjD%z}VD+{byv+Xwo(qb=-V}u3@%}2f*y*4ih_>wK;dW;+pWVGPyXDi|5 zeNeJw)}`MZI!X52BO&Q&6P|IJ@h&^_!= zZYwEjDn-7(csN$j`Asw~Sj}_Hatt#tX8Dvj;FLUWRmL|4t(uN$f2^5Nv|S1_oVR_A zW%5ajtY7Eqmr8y$r{@d&YMAOP?Qd2P~a0 zakiS8%yV^i7LbM~WFgDbRm(Frhx|M-O?uReW?S1pt<`Yozj z?W`%Qj&%@#-deVeUv7Hf{X)u3OH6&UDozNi9+>do{T3pTKj%PA99pw$w&mGF64LTP zHv$dfyw(2<%y0Xp*hM+Ag zk?fzz!nx)-G(GgZ+}7`?+wAD;THVN>BnteCdZ6wNBG?DG*9&4eG*Y$*4 zCU$UKU0(lC@OrQcO9JYX*eCO>tE>)qdu^}2?3hWnSh0^cT=89iloVhRIE+?WZvcF*Ob23xh)S~5^jF4M-zZiMoz;=jS zPo_szZe9P!u_p&Ehj{AEdXfup3~Hg7>q7ukpM!8au|`m^VTHg!FNU$~5|m6a@C z14SW$Z)UvrxHPWEgwG^ko}?R#$3N1ld3U3p?7Jx%ir;%$zCJH+&9rNyW=;2G%hXLl zb%No#SBS|m4Td4tlp|6Rasw|AK9~^CGP~k_jky=xw$h#N{^@Tpzk<6X)Y%#t9 z^yuwul=JvwxBuP-Ee>%JBMVwi642^T>?~RZxx>rGRG(UBnT=J*GMr!NMZ4&@j|J;H zHow5W-gk1g-=R;JgU<0ZdJPua>K-|Hq+sswq!>05W{z;gW-3EyiB>SLIyj?gTiERxV{~mMS|#p1(Jjq-r-xjL1%03oUx*=GlZWZd_E%Ow!7ch!S#6IU(*d zHoDIn>siYIXwOv@+y&(}+x z^%&Jwy;$q30mlvE?F?cMd$1%LWF82;O*rd#eNG2*+rTK$AjPb?&hh3$#piXk6AgOV zG#mUJ#=($yvn7r>FtRawZd}a9GRb(HM6vewyr7<6w+Xrb@^P^9@L}-)!PJU+x7hc& zw|>%L2gkLKc}fL-%NmZSeY72Wgprj_Agne~+A@CUT7FzYpI##D?ubybE|>##(N`&5 z(XcVldc`|xUtJNwhZ%{r6Tt!awz3Pog@JIC&J{I;^qGR z#el`Zs`h>_cW*-Oz9mQ7l@FPS!3_nPbKI0Rj8K5(%45mjTHOq zA~tWXX{SAJ{oa~zy*0^OL(|M^YH)4|-;Da45_$hd<{kIT)!j|6n$zA~t7kQ|+8(mW zl<5?JTt_y3i?0VyEzL?yqM7#R#ONHSSGu1lstWJ>g$d(!2AR(^+(I2MOio1ol=C;~BH3)1xU# zmZ9If*Ol-8dp`i0y30yBD4(e!+4kbo_&yiT{on%0cFceTi~c5JN&w<+hOS8W)wimD z`j{KsYW0*O8pi#5Z%+OeuKnOks7<7J-V1Z*jntNYc%LCVJjgCgld4FYEYe^` zB(a_2{+9A%Bxm9e(k(PVA9RXCK4@83;S7Ds0jBW@hpY znQNOX&$5!7&?yJu&F9bc0AKe!W*L!H9cYm$-YORY7S@EL1?_hBh5l<%MYfZ}<(*`m zHl`-;!t&txy-hrqhZ36eveiA4b~wqVe_4C)D1X@=5T54mTg4CCchUX_gHTKV`?t7%G*LkGmjwAD4&=kftM`O5BX7u0Y zn}3@_c!l8rTgWYL2>G|IXx}NfnA=cgoICrf|=D&RqFUnLAhIfpQAe-@$2-n@6~~?+NIfR zPsvbO{S@IB?NEKyA>jO&tRmhcZuB~i6M%#Hh~N}9yyTZy9)?5N9kvJc#aMpc-1R;# z%}yoKli=yDV%Q()iGOzx6J23PrYM({Pxrja`w!xDd6k{l_rmrIyWI^EF9DCIJkjP( zXiLd_gm{GBuMYz$oOyMD%zyYnJ8*55s`C?vn!+UPk@-M6OkCn8$rN|>sa}e6)WWmf z*O>dg9?yJ2O%7uJHP<*VJnD(BW%C^OPu85)JY*_=r1p2+jejp`4y%7rvot^Szt3fZ zn&-%M1>3)$C+ojRn(x;o_2a!~@8XxjBYk)0zakF{*n3FiBbCGF#~$x4Gg`~$H>kd;5`T6#k25fw;5xd4E$>V$acuE~|jzf`Ev`shipS$MB?}jSr zD!Ns_t}J0#VeL-qy!cfTc9V)8?>uUSF3SD{@m>;_MYHJR1R4+8_psI(0aot;DpKrL z!YYdb?85bw-=xE$2J<58ycZ}`O`5x-4y2qE`uOJgafOR3L{wF;Vgd?WMHS(rtwZNM zCw|ZGLu6|~0_#Nv>Mzu1Jy{qOm``!;Q$1E^qps%^)~W5w8QF+>c`UZVC{tYOH_9`K z;MxI|e_i1sk2nAAWZHyiEnjwy8!CRAJJt5=`M;_&HE4?s>5M6_qwVA7>vhiB=L6+0 zow+^wf;yOyittzmsiQ!O2nQ}nOjXiBCEz@CdUel12WYPEyJUxwwZ}*5?W&DK%PFCD zV)^5?5IthyaiC8!P0MBImA1%-biO}$HW>FxjR}Iqpg?Ewsdd=i$^jq#H{Bu%d znO)LMy3M)V^k@Iwd48pJ@xgXg0Z7xN6?*<+-4sTcLsIb=gX*8x+KXqHH8ME70-v9O zMelG{MV+3sChLH3u4XYa(5Sh~4!dO6F@f&><6K_m$A#^@m4@KRNu(FgAf;3gbI8>c0zJ{cU}v&Xu;64ic4sb!NjJ82chTI^K;xA z&Y&3qYOUQ$tTD@LmgN!?l5QzUgV;Sa5C&GgB#DVO%Ew5!iUWq8$GDhw^d1uFoILNQ z?QE3kiE}wY@Pq{dJUEVi)M6i64$u!GAmM>%0m=;*2wSD`F_D?f5LBMtbbsMK;?I6z zJ>qTELh1yUaXxjGTP9|^GXOdUbGTUPUgCUFF6{15_(g~}?%TQDYK`C_SYg=DJz+u= z<8bGI1i$)YXwpJGWN1pm3SMiqE?Nhhvx|hPdGdSX2W8Z&B67T6oKGBQ^#XN)CCt8NC#eV$!U9 zAvg&u2zp`NRm8gO$2qBz&LAk(k%(>((jNpNu=1@r8inpcAjUINF8o_XL)HV zRHh=VUQM5J|8pq`T3gEfP(!=!ed#}K2K%r_;@J`zNQK{ebFA2G%355uU0QR4mGrxM z{;j?kOykQv5q+@UC{n+r2Sb%C1an4Pr>MqtWS-cH-*|=-+M|-Eef_N7GfS0bz{F|1 ztrbUpJVABD(${H2v~569umxraOpfvGg8M$uIwfU0=|D513m(9=9S`~nEakArZ4ro3 z{c8R`D(NQ7`D{>jWAOp6LpBQSl0F+x_S)SOGU+AVXG&)J%E4qnOap=E(qGGN@o931 zo$MrW0AtywkD_z&XZri& z`0h7j7sK4=nrn>^<+8bsRPL9k<{Cn-Q7PLDbD#SqF_&_Q8cDj(tx(A=Bx$6QE?<>O zCHwjP2cOU9@i_1EIq&m+J)bWZqM|1qmx#e&E(-WOqEj0hnh6;VN1^P=8Nu7Ai4qqT znQkGss#y@FL!7?06<~euKrj1AHOt4p2X|>`4Y%9hmTcb9Di0DWo9Vd%;)tV{OC&R8 zBb+in{lLiC(II{$J7?s)>!a;AUOVNe!kf%*D9T)RcIWL@9ykFQPqcH2gRlyE_ctrU;o@$ktJ+dv-X0{DkHlkOxP#Y!^pU<0Z|7V zAmm`U=cHx;^j8aWanr^+t;ADX^utf6OFrY$PM9S;!j59_GtTy%@oLw&M zOSLwS?c6L5vH$1Bu({mWC2~4-Klf+f-Mu>Djy2~R-15uq zm4Pa7@jEM42*8~Wr*jJqYRiu3X;f=Ez;<))sY+l6V?034uybwRdW5!md0ipZ3JM? zoHn;#<&c7Kdy4713)^W0u-5Wy$mypt%N6989E|M+)FIMa#Aqo4k-BDoUl6~$_Jz4X zGU}xbTL**6_GRyQd){fE!iGoBBgHQtOk_i~B&nz~FInU_8HWyP?o@A?sdDYi*#G$R z(-VReit}>_=;%kZ1aakoRaDXW&fiZeSaX7wuO6efs;f@l`^j|lnI~OM#?XoaxK#8U zDUs6$(IVm05Pg!yB7^xiiPHR8KxkwMo z{>trL*e&5C*HQEaE4K9v0s#b|9XGD-g*SbR=cFijnZCcg^URfa+KGO9*A7KK1!BaC zf{}y(ZGnO4`PaVkJL>WueKPU3g+}ICUteAKc(nI=OAY^7oS*j2e)l*tUi9vYUQ%i~ z$Ziar@Cy@joUu8R6g2?S6(+qXOrnEz-khR{2V=Rv6dt=W)86~@CKN{N{5O9ZEA+U_ zHY@CV>qG+XkWJq4c<{)z1Qx1U&j!8WdC!MtNovcC|rO|c>}GFbpk8;ez@0y}@< z5*i4F({U9)<3JhV;rzGk6GdkuKeMKWFpG9W{u5{!+RIxTUh z7Dc_F<1$I4Iz{1SW>;_)o}+Uhh*8|bVIF5&gW^x|G%m!ZX2d41;lAF0Qc0e^YZ&&}v0+0mR~o(S^4XmZ>GytN zwmnq?GZE*S31>sFwDLUuc;4p(;K35r%p2zn@g90!LCi1|nsH4|{NSuKG~ady%JDBh=- zoBS*5WiLnav^J}J|K*{R7x0Brr!6}XX&CPu-(urhLt|B;35t0@av=6U17y1BCniw< zji7Ibtj_8PVKsal>y0y?2`$&8(@tBvZNxRMVQNn0y;VP#@FU*0CuLzM$}9oc4Z~*+ z0_1SOl?ssbcaYUX^TT6myBKQylWMkxz7fIZ1J})qpUeEkgYEb(?tuj&EI2splx#@w zbytg0RZuREP##kOqoR)AjE*S>ZI&0ndO^W3Jd?=5{LWgGW{!T$005Coz}^zcd>iim?cl zPi|lu1rRBakGaa;kv;NlnlPz?r;m&P@OilOms`fo zhX&t}t`sjM%oS-Pl=vu=_(>6;S}RHKAYOXw8zCyuAfGBvaf~u9tRi(BK0lk8OTie| zRVZUQ2Rfo#99s_5MIQiH;*y!X`XrtsQ@jEszT!v!UYH7(Pf`!9L=-lScn9;f482y4+8oa0U-RQoL*lq zp~ROlm<3O>cdcYzXczglR-&JH>BotYP>pVictTI&Uwol#BH-%aVYZneHXQZ{fL|xW zFH~@iT`S94vQCyK)u5OJRJLRoLqg;OQft*H=xKE-Ed1A4rAo0F(_hFv2q91kB;pz+%sq>|7tGRO)l@AcX-SX zFu6|B(*R`5!{fMSx!*5a-i%-Q0JMzJlLmryc=$t8rSqWxC__Kt_QB7#8Nb+o7jb~I z5oIAVre+K!8-aAWJTXSL1EYUOn5v?^-;DgWnD zmD&`oDVr25%e-8vsN8a0#0OpSm0axYq`N2S>`o&5iK>xJmL9%K{aZx1rM>U@736Us zQ09U{Mc0IGtcalqF2q`ju=+Y=9MYBg#Q?mUjth8ARFraXhyKO4Dk7i@g?i+2!I^+0Bd6HCIihB z6MKHDN}Yjqryv^Qh(fiYh^|)vaTalwo~zj13#=@rwZg{ zovP1I%2NSFO_lZQUb)zs^cq%q@1;@lbW#JdN;A<|SHNh=x|`F?g==M7zpSdIWhn?i zvntVv?~FCn4w>79cGGeQw;NvF*;6b?6OSbr3v!w$NDdtvL2-`2QeFAgfiV#O7>G9? z?$i@^R0w@7a(b|S;^HAkRE8exEL}_lkRVYdmgv|eEP81Tbr&ocZjKKe$vgEHZzv=n zKsfCHpv;&}NrMH5Y!;qv=VN;!>CB-!Cc*bLZlNy`Ous#@_LQ^(_2=mHlW?gC!Ijrl zDnve-RkMU#vVDe=cqvbFY7OJHHgs=tUli8j0)hkm?O8qJe~9HNeM=prBZfzF<<9`v=e23r8i-6kGCy4y^w?|9T!zxt-5 z<)v$}8?b&ZNtve^1i{b8JpD;Qjk+Lz@-g#dx%n|Wt%&CYROMm{<;a0H?t8yr$FfVZ zg~Eqbbgw$!PotWZT8d*%yAzGy))gHM%JBf-lWasmW1)U0uq8aynuZsM39lie*krwo zNUtn~@X(h-j-#~OCe$zH4))TTqHNMu-mSmWA|*%*v9$)9&Y7Qbz2K~-HI8P#A7ZX14Neh*-p zv-lCCgpoo2+@$6?<6M2pRkopL5Ln{oEYj9klhGj0MaxVb7x9oJT;qLGSRp`HsR?<8 zU){jh)A;)0_OD~Qd!W$ELtl0CW*t$WdAN5B#1nhpehp`g(~FClBdJIT$>Nr42hK`0DfX|Iwl(0-)0`Zoano!g+v9kQy1QSCLC1-r3c?B5mkWPbftPy|;q(e>w5pZ{vfvnsD zKD>c3At#P+0N{Mg0zl*%lSP1J0N$|jnkjqo%>u!BA*C*D{zfi9-TIwH9so-3i!$OH z3HBXb#}LmxWkq00`4S?5_?CpZPgSHHD~RuUIA$2A3)Nh{Cs%Yc_?|p7s&Utd-eB@M zW1a66CB&pv*y)(2?Q~2b=&JB-{5OJ@#-;2+Z_yK4p~GEy*<^|B83Bz$Zjz^)dWt;L zp7E?ftvB~H@!-iZr~={sVLJ6%Q}G$@qYGH;#~|H@67nNT^tG3S>EEb8?0r$ph~2n} znh8kK1$-8ZuGqwUTf=;I85XUHAL6DHGfHJiKP49d&UKezF4ru)=S`yo72QLUbb?GmXao1iUWgHZL5Jw9w2vn&fbd^}H=TIQ;wG$+IK>dD}JpAf`v=2=+iXTG@gUbqZxrPvlQMygn1s|pY z&)r$A!AS*ljB2cm1tEj?SF;qG1ULLe`>~z$3mzfmXQP z6J6%JsXu%`G}pL~sQjTku5h5`osdiV$m9gyFyYSfn6q7B8red|PcZzp$Zb>iE4AP1 z_PKv?V*-_>%v9xf-}FmwTV8cdINsohsYz`r>hN1XG_7qEwq+nX z{wj~>S_84$R8oALoaHl7@oZHiPi87}eRjjUAtPII{e$TH+Q|VnM@+hTEvSSeVD3;o zWkOw6rT|*rppEM(svKDt0FaH#<%WU4kc~$VQc)WY4IBWL-k3*oqcs9RL`MKl(e#5d zOreJks0g+O00EKND^QYZRjW=`7*D6^%27e9LmR6aWxOMGE`raQjWblgx#dV=>W<^Z z+Iv*vK8~cyaRKcp)xFn%%yfK$^^Zw{2#bDolpSpB{=@q@} zH|_W3iMpmyK_wmidh*U`dqf?AZXGiObKBGULIAz|_vK^=4>Y9SbT*y-a&^_v=uORvx$HsnZLKt6UmXcI(!)`y6wk zN+VKXZCix_3*J$EY|8*D9cQ+vsqj<R}Dw&t`{YQAyFzaTbf1If zr>YJ~9i>Z(ktr=iK0I|saA%~my^Kb`9+q`{3SEaVpW1fe=4fF)Uo zjU+*mu>)#bbz&C&z3@plf7)`qu4L7k@*z$irXHZ&KK2>aCz`v^i73yA+6uyX_beh* zW0>+=L5DRN9F(eqSqV{3Y^?%j&WO#9s{~~)tIFt&4BDRidELLs1*+$PD`Zyful>ay zU~P=?QcYE?k3o8^VAjAamoB=57K1o|Wu_aW^LGs~2TIf%&G5r#aKpeK^)n4RzadT( zP@@-Z+9J!vvB=oZ9U4j5j>f5oU5IGBAnY3A)n7|d*cYPZFkHn|N9{^sK0dneNV9w~ zeYdgAW1SPHMu4LaLDzd#E@?d!M~CVHv>SEK9d+9A@X-#tfdT{02cNJrlU392oHevuaJtYP6(L!n*|y3N-NBA? z{z&rOLIhkZs2Q)r~*P|AI6j(6-* z=}eocyK3uR&_{f^b6-9M$_a|xgJC{xi_B^0V{&FpVu0<9v3?4hcG3p3FGjOXRNrPp zcWo8EIcslQyTOL8&@Pm|cxWQk(GJyd6ZK{#w(H{2a{aQSLgYZkKu>HV71N9>G666N zrD16*TXj&~usU&^YhR_vmF;9C6SU*$NFr2Cnw#i+R#_}-_*=@c`yK%JCc@Dyl9ZY1 z6L8DRg(bzHyYcwKQoliBlye3xzfwC4-ywI5AxZh`=_*9!HPehuyqb&qeXAvss9qquD|`r3C@YuPYJM< zu4@fFWP5C?wHJ3)05QN?qrGEjJ=e+6amRZl`#1Yps(lIvNK34Dd!f5vt0+%lAWy_i z((~xe@!_!~o5yT#hw9=)FGtW)cZeH>0rIG1PA?D;DgY>iHr7S@sy1q|2Gu%4{AS-M zf85C2w}pF`%Iz!oV5j{(+lf|H6;gDZLX&^LoD~7&OMv-xm;c^gDN#K; zd;Hu3zaqeizK18wPPdl^DeYFZ-}e3GQ~K`2IYD1!BVH+80Y$`L_c=3mqI>5Bt{67} z(};LzF+j~f+xA9oiIQo$Eun~uQBwRklw!O3RsTq{DuJ^BR>A@Y&rbd&{3-jDAA%D* zw%qd?M}?{z0k``iA`t!x?{jagRm#j3xV!7Eo%Sry+zEOu8KVX|)J4hGFYZIx;^H+1 zHZh;~rTwAqSs!BZCe~|=V8`p-RK|}_UbeIc2XltZw-zzpi1*5RG@#f~0caE43N=^( zTHUIZ*3toiYTNKi`HRh-amwBFoXhf(H(b;aSS1ik;1yH}&f!QPG{hK`IR*g96ST%l z0*b{E8#-IJ#l?Kc?WW?+OnFSai4+$jOTWCq04fhrm4!U5**nTTSALHbJeVxFT_ zK{7=07a;D*7FooM7rSK+&ffFfxM#iz7k)RG*X>EAK`GniH>Tau=RQQ(yvS@IZs6Vy z1Hb=8)rWywICxcxwp}s8v{;r`QS)Cf5nf$nP*>C(FSk*QNF4)9g^|};*Ge39b^vc& zI$Yb@(8Wa|oSOn?MM~bZJ0_lUXE#XE*+gm$uzi2Y?O})k?nbI~Y|uN(xJ+F# zF>G5Gh{GDh#&pl27>M1h!#QWjA!52~MMgN;CYB2~AoS~$WO5?mF*I0^OS&jWJ)>=< zgCp$sB=#&`(0@6>X4>>n+4V;%*fF!s92eVax^4SfmMX5OSQT%9M^!UwN+xWlgaZz_ zK&Qt=+A3nSBFW!@gc%z1jwvWWMo4KOge2vjo|@D&du6S7}~X$$>8O3 zg4^u6c$7vQbwBGdNR{k0=_J{c2fR>aTc;u%dwf6)IKOsj!RG*`8Pl7C2+roHaS;YF zyLFol`!<0`>nF_u zxfq4p!wmFV9kPPC7r<6)g@*9%`6Op>Xc=K+P|-XjGo#Jgpd|AY0v=QWRi8^A?=%?4 zTYC7U(>NUSC(tZ9)N2FoH4F9PK}yXH$^{48Xn2*DY+6Xs7s8>E9Q>5&v#Py-UxXaz zm#vqW*Y5Cuean_vE5LmL%qx9BHx0Z3wgk`c99=8E(;+uM+J|6&*hFI#_Ue# zOs-t}dMnSoSLO1!&etqC*r1Wz-m##~U0jM6Q^B?0Ou@IlRow5 zsXeau{sQGGGe>X9HxsO-!EVOc4rVTH(R_FuuaASxOi@g?+5lcNW2vl7r_vlEd=;`N zz?obQ8o;D-pdl+6yZ^z%5XfU2a8WiCM(4mHd!D9phI9rp&vG-95t*T!bRT;AjGU?E zbM^w*(gWd z`kGzxXZQ=suGv>onv~ntGp97nR0-Qjz7EoyMX3*GORWH_Ip~Rs$JZlTB?gplp*3K7 z#XlWgw$2#c)NwHlztXLmhbIAhSNv`^@NQ1}^$PY&ZquGN7_S7t>9LnV>`TD3UA}>Q z!gH@#p8c@lj)z@9BF8CWFUwsK#Mgh4N@S^2ObCpc{(O~_Z7x9255R|<-Bz5l0A^W3 zcU@f|vguB;|Co|1+xlT%^WZjOgRXE}o1^pxj^#j(jdgIivz*e|;|y3>az;qA&oL70 z7$75xlkqY#^VHaZl#R?Y40t4CmP7KGf1F;aXc#~oSlXTb_R46%_=`7GyklvNbIO3j zJnADbyI2f)jgI0$=Qzo82k+vq&+fDXfut$>HzusAXXiN0koFa(dlXQf^u#@+=u6N&x|+w` z&TZ`{i1RL|GBDSuGur>=2hgul-yTu#2Fj9ww_gA}&6&CxdGTE`QlxY)-}{+u=F`8K zu0tS91wbT7D^Mdd`7c#AX$#BGIr;~Vz9VKvF*2gaaPx%>&gY=_&tXRq!6|Q`A2l+< zhB9I{GEVUvPGet(RY3EX`ziYOxh@$Tmkj4G89re>ZQDJF`UWoGtvc}S=JSEZSFc+b zhi~$BH}+jO3kS&2nO7Q~YpOWwlG!%RmRVs}m53~5BB&RbzRQHYd!L_xQ+yBWBC(>Z zaJ$IRwe@GVnZB=>cQ*qNxV^cCZdZRn;$54%WD-tBS)(Ho znBu104?2O$rrip5Gb#2n@&A}g8_YqwK*z!2>hTbSDF@Pa!tXM667 zI4*3Y5G!5ERHK7Tly=AsqcC;NP+1s1%L5iyh< z6_ydof0_z-&A}cErFEtf9cOMYSLAb|S2DO`83AFYKTa^Dct8c8qP1M$8v0pQ)|86l zoO3Szl-V9d-vYh4A`R`AUVc(QPKD#fOn5L z{&+O|_CS?F9!8#9@IZccWc;?zZXhN-$aJ*8*1RLMK?XyDwD-UghVF{OT_9>>9E2(G zE^tiwGOb*sYkssmMs) zfPYmyc8UWFA-X$%fkh>s%v#GxrDX>A+&4eFI`|p6=3n%lxl>I$Bo}z1ZA+tkwv0l2QZe68zWf!73R04jydMO4ObHlK*5%