From 8f952dcb163c8eb54e05af723300a8edccf4ae7f Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:39:25 +0800 Subject: [PATCH 1/9] Add core agent functionality with client, listener, registry, and remote connections --- python/pyproject.toml | 1 + python/uv.lock | 265 +++++++++++++++++- python/valuecell/core/agent/__init__.py | 0 python/valuecell/core/agent/client.py | 90 ++++++ python/valuecell/core/agent/connect.py | 194 +++++++++++++ python/valuecell/core/agent/decorator.py | 197 +++++++++++++ python/valuecell/core/agent/listener.py | 66 +++++ python/valuecell/core/agent/registry.py | 195 +++++++++++++ python/valuecell/core/agent/tests/__init__.py | 0 python/valuecell/utils/__init__.py | 2 + python/valuecell/utils/port.py | 13 + python/valuecell/utils/uuid.py | 7 + 12 files changed, 1029 insertions(+), 1 deletion(-) create mode 100644 python/valuecell/core/agent/__init__.py create mode 100644 python/valuecell/core/agent/client.py create mode 100644 python/valuecell/core/agent/connect.py create mode 100644 python/valuecell/core/agent/decorator.py create mode 100644 python/valuecell/core/agent/listener.py create mode 100644 python/valuecell/core/agent/registry.py create mode 100644 python/valuecell/core/agent/tests/__init__.py create mode 100644 python/valuecell/utils/port.py create mode 100644 python/valuecell/utils/uuid.py diff --git a/python/pyproject.toml b/python/pyproject.toml index b4c86b916..d68c0040e 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "fastapi>=0.104.0", "pydantic>=2.0.0", "uvicorn>=0.24.0", + "a2a-sdk[http-server]>=0.3.4", ] [project.optional-dependencies] diff --git a/python/uv.lock b/python/uv.lock index 357c99bd7..c8b5a160e 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,6 +1,33 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] + +[[package]] +name = "a2a-sdk" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "protobuf" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/ce/f50c904664a0fcafa0909be7a987ef6dcf1d75595532305bcefadb0532af/a2a_sdk-0.3.4.tar.gz", hash = "sha256:79db4c287cab1235a0b0c5af9a3a58eedd8a037b51a87bed2e89f5e5e8977f65", size = 220158, upload-time = "2025-09-02T16:53:48.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/7a/c7a13e692aba332fb55480c843acb7f3cf43668623f75d127792e8fa9b30/a2a_sdk-0.3.4-py3-none-any.whl", hash = "sha256:423f72334b4a4b34cb4da07d5db3b786975de2fbc527236e3ca713b0d08c27e1", size = 135297, upload-time = "2025-09-02T16:53:46.473Z" }, +] + +[package.optional-dependencies] +http-server = [ + { name = "fastapi" }, + { name = "sse-starlette" }, + { name = "starlette" }, +] [[package]] name = "annotated-types" @@ -25,6 +52,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] +[[package]] +name = "cachetools" +version = "5.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + [[package]] name = "click" version = "8.2.1" @@ -124,6 +211,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, ] +[[package]] +name = "google-api-core" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/21/e9d043e88222317afdbdb567165fdbc3b0aad90064c7e0c9eb0ad9955ad8/google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8", size = 165443, upload-time = "2025-06-12T20:52:20.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/4b/ead00905132820b623732b175d66354e9d3e69fcf2a5dcdab780664e7896/google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7", size = 160807, upload-time = "2025-06-12T20:52:19.334Z" }, +] + +[[package]] +name = "google-auth" +version = "2.40.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/9b/e92ef23b84fa10a64ce4831390b7a4c2e53c0132568d99d4ae61d04c8855/google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77", size = 281029, upload-time = "2025-06-04T18:04:57.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137, upload-time = "2025-06-04T18:04:55.573Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -133,6 +262,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -169,6 +335,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "proto-plus" +version = "1.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142, upload-time = "2025-03-10T15:54:38.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163, upload-time = "2025-03-10T15:54:37.335Z" }, +] + +[[package]] +name = "protobuf" +version = "6.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/df/fb4a8eeea482eca989b51cffd274aac2ee24e825f0bf3cbce5281fa1567b/protobuf-6.32.0.tar.gz", hash = "sha256:a81439049127067fc49ec1d36e25c6ee1d1a2b7be930675f919258d03c04e7d2", size = 440614, upload-time = "2025-08-14T21:21:25.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/18/df8c87da2e47f4f1dcc5153a81cd6bca4e429803f4069a299e236e4dd510/protobuf-6.32.0-cp310-abi3-win32.whl", hash = "sha256:84f9e3c1ff6fb0308dbacb0950d8aa90694b0d0ee68e75719cb044b7078fe741", size = 424409, upload-time = "2025-08-14T21:21:12.366Z" }, + { url = "https://files.pythonhosted.org/packages/e1/59/0a820b7310f8139bd8d5a9388e6a38e1786d179d6f33998448609296c229/protobuf-6.32.0-cp310-abi3-win_amd64.whl", hash = "sha256:a8bdbb2f009cfc22a36d031f22a625a38b615b5e19e558a7b756b3279723e68e", size = 435735, upload-time = "2025-08-14T21:21:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5b/0d421533c59c789e9c9894683efac582c06246bf24bb26b753b149bd88e4/protobuf-6.32.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d52691e5bee6c860fff9a1c86ad26a13afbeb4b168cd4445c922b7e2cf85aaf0", size = 426449, upload-time = "2025-08-14T21:21:16.687Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/607764ebe6c7a23dcee06e054fd1de3d5841b7648a90fd6def9a3bb58c5e/protobuf-6.32.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:501fe6372fd1c8ea2a30b4d9be8f87955a64d6be9c88a973996cef5ef6f0abf1", size = 322869, upload-time = "2025-08-14T21:21:18.282Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/2e730bd1c25392fc32e3268e02446f0d77cb51a2c3a8486b1798e34d5805/protobuf-6.32.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:75a2aab2bd1aeb1f5dc7c5f33bcb11d82ea8c055c9becbb41c26a8c43fd7092c", size = 322009, upload-time = "2025-08-14T21:21:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f2/80ffc4677aac1bc3519b26bc7f7f5de7fce0ee2f7e36e59e27d8beb32dd1/protobuf-6.32.0-py3-none-any.whl", hash = "sha256:ba377e5b67b908c8f3072a57b63e2c6a4cbd18aea4ed98d2584350dbf46f2783", size = 169287, upload-time = "2025-08-14T21:21:23.515Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -298,6 +511,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.12.11" @@ -342,6 +582,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, +] + [[package]] name = "starlette" version = "0.47.3" @@ -376,6 +628,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + [[package]] name = "uvicorn" version = "0.35.0" @@ -394,6 +655,7 @@ name = "valuecell" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "a2a-sdk", extra = ["http-server"] }, { name = "fastapi" }, { name = "pydantic" }, { name = "python-dateutil" }, @@ -411,6 +673,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "a2a-sdk", extras = ["http-server"], specifier = ">=0.3.4" }, { name = "fastapi", specifier = ">=0.104.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, diff --git a/python/valuecell/core/agent/__init__.py b/python/valuecell/core/agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/core/agent/client.py b/python/valuecell/core/agent/client.py new file mode 100644 index 000000000..9c8840d5f --- /dev/null +++ b/python/valuecell/core/agent/client.py @@ -0,0 +1,90 @@ +from typing import AsyncIterator, Optional + +import httpx +from a2a.client import A2ACardResolver, ClientConfig, ClientFactory +from a2a.types import ( + Message, + Part, + PushNotificationConfig, + Role, + Task, + TaskArtifactUpdateEvent, + TaskStatusUpdateEvent, + TextPart, +) +from valuecell.utils import generate_uuid + +MessageResponse = tuple[Task, Optional[TaskStatusUpdateEvent | TaskArtifactUpdateEvent]] + + +class AgentClient: + def __init__(self, agent_url: str, push_notification_url: str = None): + self.agent_url = agent_url + self.push_notification_url = push_notification_url + self._client = None + self._httpx_client = None + self._initialized = False + + async def _ensure_initialized(self): + if not self._initialized: + await self._setup_client() + self._initialized = True + + async def _setup_client(self): + self._httpx_client = httpx.AsyncClient(timeout=30) + + config = ClientConfig( + httpx_client=self._httpx_client, + accepted_output_modes=["text"], + ) + + push_notification_configs = [] + if self.push_notification_url: + push_notification_configs.append( + PushNotificationConfig( + id=generate_uuid("pushcfg"), + token="token", + url=self.push_notification_url, + ) + ) + config.push_notification_configs = push_notification_configs + config.streaming = False + config.polling = True + + client_factory = ClientFactory(config) + card_resolver = A2ACardResolver(self._httpx_client, self.agent_url) + card = await card_resolver.get_agent_card() + self._client = client_factory.create(card) + + async def send_message( + self, text: str, context_id: str = None, exhaustive: bool = False + ) -> MessageResponse | AsyncIterator[MessageResponse]: + """Send message to Agent""" + await self._ensure_initialized() + + message = Message( + role=Role.user, + parts=[Part(root=TextPart(text=text))], + message_id=generate_uuid("msg"), + context_id=context_id or generate_uuid("ctx"), + ) + + generator = self._client.send_message(message) + if exhaustive: + return generator + + task, event = await generator.__anext__() + await generator.aclose() + return task, event + + async def get_agent_card(self): + await self._ensure_initialized() + card_resolver = A2ACardResolver(self._httpx_client, self.agent_url) + return await card_resolver.get_agent_card() + + async def close(self): + if self._httpx_client: + await self._httpx_client.aclose() + self._httpx_client = None + self._client = None + self._initialized = False diff --git a/python/valuecell/core/agent/connect.py b/python/valuecell/core/agent/connect.py new file mode 100644 index 000000000..0276b779c --- /dev/null +++ b/python/valuecell/core/agent/connect.py @@ -0,0 +1,194 @@ +import asyncio +import logging +from typing import Dict, List + +from valuecell.core.agent.client import AgentClient +from valuecell.core.agent.registry import AgentRegistry +from valuecell.core.agent.listener import NotificationListener +from valuecell.utils import get_next_available_port + +logger = logging.getLogger(__name__) + + +class RemoteConnections: + """Manager for remote Agent connections""" + + def __init__(self): + self._connections: Dict[str, AgentClient] = {} + self._running_agents: Dict[str, asyncio.Task] = {} + self._agent_instances: Dict[str, object] = {} + self._listeners: Dict[str, asyncio.Task] = {} + self._listener_urls: Dict[str, str] = {} + + async def start_agent( + self, + agent_name: str, + with_listener: bool = True, + listener_port: int = None, + listener_host: str = "localhost", + notification_callback: callable = None, + ) -> str: + """Start an agent, optionally with a notification listener.""" + agent_class = AgentRegistry.get_agent(agent_name) + if not agent_class: + raise ValueError(f"Agent '{agent_name}' not found in registry") + + # Create Agent instance + agent_instance = agent_class() + self._agent_instances[agent_name] = agent_instance + + listener_url = None + + # Start listener if requested and agent supports push notifications + if with_listener and agent_instance.agent_card.capabilities.push_notifications: + try: + listener_url = await self._start_listener_for_agent( + agent_name, + listener_host=listener_host, + listener_port=listener_port, + notification_callback=notification_callback, + ) + except Exception as e: + logger.error(f"Failed to start listener for '{agent_name}': {e}") + await self._cleanup_agent(agent_name) + raise RuntimeError( + f"Failed to start listener for '{agent_name}'" + ) from e + + # Start agent service + try: + await self._start_agent_service(agent_name, agent_instance) + except Exception as e: + logger.error(f"Failed to start agent '{agent_name}': {e}") + await self._cleanup_agent(agent_name) + raise RuntimeError(f"Failed to start agent '{agent_name}'") from e + + # Create client connection with listener URL + agent_url = agent_instance.agent_card.url + self._create_client_for_agent(agent_name, agent_instance, listener_url) + + return agent_url + + async def _start_listener_for_agent( + self, + agent_name: str, + listener_host: str, + listener_port: int = None, + notification_callback: int = None, + ) -> str: + """Start a NotificationListener for the agent and return its URL.""" + # Auto-assign port if not specified + if listener_port is None: + listener_port = get_next_available_port(5000) + + # Create and start listener + listener = NotificationListener( + host=listener_host, + port=listener_port, + notification_callback=notification_callback, + ) + + listener_task = asyncio.create_task(listener.start_async()) + self._listeners[agent_name] = listener_task + + listener_url = f"http://{listener_host}:{listener_port}/notify" + self._listener_urls[agent_name] = listener_url + + # Wait a moment for listener to start + await asyncio.sleep(0.3) + logger.info(f"Started listener for '{agent_name}' at {listener_url}") + + return listener_url + + async def _start_agent_service(self, agent_name: str, agent_instance: object): + """Start the agent service (serve) and track the running task.""" + server_task = asyncio.create_task(agent_instance.serve()) + self._running_agents[agent_name] = server_task + + # Wait for agent to start + await asyncio.sleep(0.5) + + def _create_client_for_agent( + self, agent_name: str, agent_instance: object, listener_url: str = None + ): + """Create an AgentClient for the agent and record the connection.""" + agent_url = agent_instance.agent_card.url + self._connections[agent_name] = AgentClient( + agent_url, push_notification_url=listener_url + ) + + logger.info(f"Started agent '{agent_name}' at {agent_url}") + if listener_url: + logger.info(f" โ””โ”€ with listener at {listener_url}") + + async def _cleanup_agent(self, agent_name: str): + """Clean up all resources for an agent""" + # Close client connection + if agent_name in self._connections: + await self._connections[agent_name].close() + + # Stop listener + if agent_name in self._listeners: + self._listeners[agent_name].cancel() + try: + await self._listeners[agent_name] + except asyncio.CancelledError: + pass + del self._listeners[agent_name] + + # Stop agent + if agent_name in self._running_agents: + self._running_agents[agent_name].cancel() + try: + await self._running_agents[agent_name] + except asyncio.CancelledError: + pass + del self._running_agents[agent_name] + + # Clean up references + if agent_name in self._connections: + del self._connections[agent_name] + if agent_name in self._agent_instances: + del self._agent_instances[agent_name] + if agent_name in self._listener_urls: + del self._listener_urls[agent_name] + + async def get_client(self, agent_name: str) -> AgentClient: + """Get Agent client connection""" + if agent_name not in self._connections: + await self.start_agent(agent_name) + + return self._connections[agent_name] + + async def stop_agent(self, agent_name: str): + """Stop Agent service and associated listener""" + await self._cleanup_agent(agent_name) + logger.info(f"Stopped agent '{agent_name}' and its listener") + + def list_running_agents(self) -> List[str]: + """List running agents""" + return list(self._running_agents.keys()) + + def list_available_agents(self) -> List[str]: + """List all available agents from registry""" + return AgentRegistry.list_agents() + + async def stop_all(self): + """Stop all running agents""" + for agent_name in list(self._running_agents.keys()): + await self.stop_agent(agent_name) + + def get_agent_info(self, agent_name: str) -> dict: + """Get agent information including listener info""" + if agent_name not in self._agent_instances: + return None + + agent_instance = self._agent_instances[agent_name] + return { + "name": agent_name, + "url": agent_instance.agent_card.url, + "listener_url": self._listener_urls.get(agent_name), + "card": agent_instance.agent_card.model_dump(exclude_none=True), + "running": agent_name in self._running_agents, + "has_listener": agent_name in self._listeners, + } diff --git a/python/valuecell/core/agent/decorator.py b/python/valuecell/core/agent/decorator.py new file mode 100644 index 000000000..c9b71fb03 --- /dev/null +++ b/python/valuecell/core/agent/decorator.py @@ -0,0 +1,197 @@ +import logging +from typing import Type + +import httpx +import uvicorn +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.apps import A2AStarletteApplication +from a2a.server.events import EventQueue +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import ( + BasePushNotificationSender, + InMemoryPushNotificationConfigStore, + InMemoryTaskStore, + TaskUpdater, +) +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentSkill, + Part, + TaskState, + TextPart, + UnsupportedOperationError, +) +from a2a.utils import new_task +from a2a.utils.errors import ServerError +from valuecell.core.agent.registry import AgentRegistry +from valuecell.utils import get_next_available_port + +logger = logging.getLogger(__name__) + + +def serve( + name: str = None, + host: str = "localhost", + port: int = None, + streaming: bool = True, + push_notifications: bool = True, + description: str = None, + version: str = "1.0.0", + skills: list = None, +): + def decorator(cls: Type) -> Type: + # Build agent card (port will be assigned when server starts) + agent_skills = [] + if skills: + for skill in skills: + if isinstance(skill, dict): + agent_skills.append(AgentSkill(**skill)) + elif isinstance(skill, AgentSkill): + agent_skills.append(skill) + + # Determine the agent name consistently + agent_name = name or cls.__name__ + + # Create decorated class + class DecoratedAgent(cls): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Assign port when instance is created + actual_port = port or get_next_available_port() + + # Create agent card with actual port + self.agent_card = AgentCard( + name=agent_name, + description=description + or f"No description available for {agent_name}", + url=f"http://{host}:{actual_port}/", + version=version, + default_input_modes=["text"], + default_output_modes=["text"], + capabilities=AgentCapabilities( + streaming=streaming, push_notifications=push_notifications + ), + skills=agent_skills, + supports_authenticated_extended_card=False, + ) + + self._host = host + self._port = actual_port + self._executor = None + self._server_task = None + + async def serve(self): + # Create AgentExecutor wrapper + self._executor = _create_agent_executor(self) + + # Setup server components + client = httpx.AsyncClient() + push_notification_config_store = InMemoryPushNotificationConfigStore() + push_notification_sender = BasePushNotificationSender( + client, config_store=push_notification_config_store + ) + request_handler = DefaultRequestHandler( + agent_executor=self._executor, + task_store=InMemoryTaskStore(), + push_config_store=push_notification_config_store, + push_sender=push_notification_sender, + ) + + server_app = A2AStarletteApplication( + agent_card=self.agent_card, + http_handler=request_handler, + ) + + # Start server + config = uvicorn.Config( + server_app.build(), + host=self._host, + port=self._port, + log_level="info", + ) + server = uvicorn.Server(config) + logger.info(f"Starting {agent_name} server at {self.agent_card.url}") + await server.serve() + + # Preserve original class metadata + DecoratedAgent.__name__ = cls.__name__ + DecoratedAgent.__qualname__ = cls.__qualname__ + + # Store agent name as class attribute for registry management + DecoratedAgent.__agent_name__ = agent_name + + # Register to registry + try: + AgentRegistry.register(DecoratedAgent, agent_name) + except ImportError: + # Registry not available, skip registration + logger.warning( + f"Agent registry not available, skipping registration for {DecoratedAgent.__name__}" + ) + + return DecoratedAgent + + return decorator + + +class GenericAgentExecutor(AgentExecutor): + def __init__(self, agent): + self.agent = agent + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + # Ensure agent implements streaming interface + if not hasattr(self.agent, "stream"): + raise NotImplementedError( + f"Agent {self.agent.__class__.__name__} must implement 'stream' method" + ) + + # Prepare query and ensure a task exists in the system + query = context.get_user_input() + task = context.current_task + if not task: + task = new_task(context.message) + await event_queue.enqueue_event(task) + + # Helper state + updater = TaskUpdater(event_queue, task.id, task.context_id) + artifact_id = f"{self.agent.__class__.__name__}-artifact" + chunk_idx = 0 + + # Local helper to add a chunk + async def _add_chunk(content: str, last: bool = False): + nonlocal chunk_idx + parts = [Part(root=TextPart(text=content))] + await updater.add_artifact( + parts=parts, + artifact_id=artifact_id, + append=chunk_idx > 0, + last_chunk=last, + ) + if not last: + chunk_idx += 1 + + # Stream from the user agent and update task incrementally + try: + async for item in self.agent.stream(query, task.context_id, task.id): + content = item.get("content", "") + is_complete = item.get("is_task_complete", True) + + await updater.update_status(TaskState.working) + await _add_chunk(content, last=is_complete) + + if is_complete: + await updater.complete() + break + except Exception as e: + # Convert unexpected errors into server errors so callers can handle them uniformly + raise ServerError(error=e) from e + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + # Default cancel operation + raise ServerError(error=UnsupportedOperationError()) + + +def _create_agent_executor(agent_instance): + return GenericAgentExecutor(agent_instance) diff --git a/python/valuecell/core/agent/listener.py b/python/valuecell/core/agent/listener.py new file mode 100644 index 000000000..749d34608 --- /dev/null +++ b/python/valuecell/core/agent/listener.py @@ -0,0 +1,66 @@ +import asyncio +import logging +from typing import Callable, Optional + +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class NotificationListener: + def __init__( + self, + host: str = "localhost", + port: int = 5000, + notification_callback: Optional[Callable] = None, + ): + self.host = host + self.port = port + self.notification_callback = notification_callback + self.app = self._create_app() + + def _create_app(self): + app = Starlette() + app.add_route("/notify", self.handle_notification, methods=["POST"]) + return app + + async def handle_notification(self, request: Request): + try: + data = await request.json() + logger.info(f"๐Ÿ“จ Notification received on {self.host}:{self.port}: {data}") + + if self.notification_callback: + if asyncio.iscoroutinefunction(self.notification_callback): + await self.notification_callback(data) + else: + self.notification_callback(data) + + return JSONResponse({"status": "ok"}) + except Exception as e: + logger.error(f"Error handling notification: {e}") + return JSONResponse({"error": str(e)}, status_code=500) + + def start(self): + logger.info(f"Starting listener on {self.host}:{self.port}") + uvicorn.run(self.app, host=self.host, port=self.port) + + async def start_async(self): + logger.info(f"Starting async listener on {self.host}:{self.port}") + config = uvicorn.Config( + self.app, host=self.host, port=self.port, log_level="info" + ) + server = uvicorn.Server(config) + await server.serve() + + +def main(): + listener = NotificationListener() + listener.start() + + +if __name__ == "__main__": + main() diff --git a/python/valuecell/core/agent/registry.py b/python/valuecell/core/agent/registry.py new file mode 100644 index 000000000..bf1365bac --- /dev/null +++ b/python/valuecell/core/agent/registry.py @@ -0,0 +1,195 @@ +from typing import Dict, Type, List + + +class AgentRegistry: + """Simple Agent registry for managing decorated agents""" + + _agents: Dict[str, Type] = {} + + @classmethod + def register(cls, agent_class: Type, agent_name: str) -> None: + """Register an Agent class + + Args: + agent_class: The decorated agent class + agent_name: The agent name (from decorator parameter or class name) + """ + class_name = agent_class.__name__ + + # Primary registration: use agent_name (this is what users will lookup) + cls._agents[agent_name] = agent_class + + # Secondary registration: use class_name if different from agent_name + # This helps with debugging and class-based lookups + if class_name != agent_name: + cls._agents[class_name] = agent_class + print(f"Registered agent: '{agent_name}' (class: {class_name})") + else: + print(f"Registered agent: '{agent_name}'") + + @classmethod + def get_agent(cls, name: str) -> Type: + """Get a registered Agent class by name""" + return cls._agents.get(name) + + @classmethod + def get_agent_name(cls, agent_class: Type) -> str: + """Get the agent name for a given class""" + if hasattr(agent_class, "__agent_name__"): + return agent_class.__agent_name__ + return agent_class.__name__ + + @classmethod + def list_agents(cls) -> List[str]: + """List all registered agent names (primary names only)""" + # Filter out duplicates by checking if the agent_name matches the stored __agent_name__ + unique_names = [] + for name, agent_class in cls._agents.items(): + if ( + hasattr(agent_class, "__agent_name__") + and agent_class.__agent_name__ == name + ): + unique_names.append(name) + elif ( + not hasattr(agent_class, "__agent_name__") + and agent_class.__name__ == name + ): + unique_names.append(name) + return unique_names + + @classmethod + def get_all_agents(cls) -> Dict[str, Type]: + """Get all registered Agents (includes both primary and secondary keys)""" + return cls._agents.copy() + + @classmethod + def get_registry_info(cls) -> Dict[str, dict]: + """Get detailed registry information for debugging""" + info = {} + processed_classes = set() + + for _, agent_class in cls._agents.items(): + class_id = id(agent_class) + if class_id in processed_classes: + continue + + processed_classes.add(class_id) + agent_name = cls.get_agent_name(agent_class) + + info[agent_name] = { + "class_name": agent_class.__name__, + "agent_name": agent_name, + "registered_keys": [ + k for k, v in cls._agents.items() if v is agent_class + ], + "class_qualname": getattr(agent_class, "__qualname__", "N/A"), + } + + return info + + @classmethod + def unregister(cls, name: str) -> bool: + """Unregister an agent by name (agent_name or class_name) + + Args: + name: The agent name or class name to unregister + + Returns: + bool: True if agent was found and unregistered, False otherwise + """ + agent_class = cls._agents.get(name) + if not agent_class: + return False + + # Find all keys that point to this agent class + keys_to_remove = [k for k, v in cls._agents.items() if v is agent_class] + + # Remove all keys for this agent + for key in keys_to_remove: + del cls._agents[key] + + agent_name = cls.get_agent_name(agent_class) + print(f"Unregistered agent: '{agent_name}' (removed keys: {keys_to_remove})") + return True + + @classmethod + def unregister_by_class(cls, agent_class: Type) -> bool: + """Unregister an agent by class reference + + Args: + agent_class: The agent class to unregister + + Returns: + bool: True if agent was found and unregistered, False otherwise + """ + # Find all keys that point to this agent class + keys_to_remove = [k for k, v in cls._agents.items() if v is agent_class] + + if not keys_to_remove: + return False + + # Remove all keys for this agent + for key in keys_to_remove: + del cls._agents[key] + + agent_name = cls.get_agent_name(agent_class) + print(f"Unregistered agent: '{agent_name}' (removed keys: {keys_to_remove})") + return True + + @classmethod + def is_registered(cls, name: str) -> bool: + """Check if an agent is registered by name + + Args: + name: The agent name or class name to check + + Returns: + bool: True if agent is registered, False otherwise + """ + return name in cls._agents + + @classmethod + def unregister_all(cls, pattern: str = None) -> List[str]: + """Unregister multiple agents, optionally by pattern + + Args: + pattern: Optional string pattern to match agent names (substring match) + If None, unregisters all agents + + Returns: + List[str]: List of unregistered agent names + """ + if pattern is None: + # Unregister all + agent_names = cls.list_agents() + cls.clear() + print(f"Unregistered all agents: {agent_names}") + return agent_names + + # Find agents matching pattern + matching_agents = [] + for name in cls.list_agents(): + if pattern in name: + matching_agents.append(name) + + # Unregister matching agents + unregistered = [] + for name in matching_agents: + if cls.unregister(name): + unregistered.append(name) + + return unregistered + + @classmethod + def count(cls) -> int: + """Get the number of unique registered agents + + Returns: + int: Number of unique agents (not counting duplicate keys) + """ + return len(cls.list_agents()) + + @classmethod + def clear(cls) -> None: + """Clear all registered agents (useful for testing)""" + cls._agents.clear() diff --git a/python/valuecell/core/agent/tests/__init__.py b/python/valuecell/core/agent/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/utils/__init__.py b/python/valuecell/utils/__init__.py index e69de29bb..e759662c8 100644 --- a/python/valuecell/utils/__init__.py +++ b/python/valuecell/utils/__init__.py @@ -0,0 +1,2 @@ +from .port import get_next_available_port +from .uuid import generate_uuid diff --git a/python/valuecell/utils/port.py b/python/valuecell/utils/port.py new file mode 100644 index 000000000..b812aa242 --- /dev/null +++ b/python/valuecell/utils/port.py @@ -0,0 +1,13 @@ +import socket + + +def get_next_available_port(start: int = 9000, num: int = 1000) -> int: + for port in range(start, start + num): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("localhost", port)) + return port + except OSError: + continue + + raise RuntimeError("No available ports found") diff --git a/python/valuecell/utils/uuid.py b/python/valuecell/utils/uuid.py new file mode 100644 index 000000000..cdf789065 --- /dev/null +++ b/python/valuecell/utils/uuid.py @@ -0,0 +1,7 @@ +from uuid import uuid4 + +def generate_uuid(prefix: str = None) -> str: + if not prefix: + return str(uuid4().hex) + + return f"{prefix}-{uuid4().hex}" From 873fe62fa32c10bb98818706bc9208041b9d7793 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Fri, 5 Sep 2025 15:09:27 +0800 Subject: [PATCH 2/9] feat: Add demo agents with logging and interaction for Calculator, Weather, and Simple agents --- .../core/agent/tests/test_e2e_demo.py | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 python/valuecell/core/agent/tests/test_e2e_demo.py diff --git a/python/valuecell/core/agent/tests/test_e2e_demo.py b/python/valuecell/core/agent/tests/test_e2e_demo.py new file mode 100644 index 000000000..3153ac620 --- /dev/null +++ b/python/valuecell/core/agent/tests/test_e2e_demo.py @@ -0,0 +1,171 @@ +import asyncio +import logging +from valuecell.core.agent.decorator import serve +from valuecell.core.agent.connect import RemoteConnections + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +# Demo agents using the @serve decorator +@serve(name="Calculator Agent") +class CalculatorAgent: + """A calculator agent that can do basic math""" + + def __init__(self): + logger.info("Initializing CalculatorAgent") + self.agent_name = "CalculatorAgent" + + async def stream(self, query, session_id, task_id): + """Process math queries""" + logger.info(f"Calculator processing: {query}") + + yield {"is_task_complete": False, "content": f"๐Ÿงฎ Calculating: {query}"} + await asyncio.sleep(0.5) + + try: + # Simple math evaluation (in real world, use safe parsing) + if any(op in query for op in ["+", "-", "*", "/", "(", ")"]): + # For demo, just respond with a mock calculation + result = "42" # Mock result + yield {"is_task_complete": False, "content": "๐Ÿ’ญ Computing result..."} + await asyncio.sleep(0.5) + yield {"is_task_complete": True, "content": f"โœ… Result: {result}"} + else: + yield { + "is_task_complete": True, + "content": "โ“ I can help with math calculations. Try something like '2 + 3'", + } + except Exception as e: + yield { + "is_task_complete": True, + "content": f"โŒ Error in calculation: {str(e)}", + } + + +@serve(name="Weather Agent", port=9101, description="Provides weather information") +class WeatherAgent: + """A weather information agent""" + + def __init__(self): + logger.info("Initializing WeatherAgent") + self.agent_name = "WeatherAgent" + + async def stream(self, query, session_id, task_id): + """Process weather queries""" + logger.info(f"Weather processing: {query}") + + yield {"is_task_complete": False, "content": f"๐ŸŒค๏ธ Checking weather for: {query}"} + await asyncio.sleep(0.8) + + if "weather" in query.lower(): + yield { + "is_task_complete": False, + "content": "๐ŸŒก๏ธ Fetching current conditions...", + } + await asyncio.sleep(0.5) + yield { + "is_task_complete": False, + "content": "๐Ÿ“Š Analyzing forecast data...", + } + await asyncio.sleep(0.5) + yield { + "is_task_complete": True, + "content": f"โ˜€๏ธ Weather report: Sunny, 22ยฐC. Perfect day! (for query: {query})", + } + else: + yield { + "is_task_complete": True, + "content": "๐ŸŒ I provide weather information. Ask me about the weather in any location!", + } + + +@serve(name="Simple Agent", streaming=False, push_notifications=False) +class SimpleAgent: + """A simple non-streaming agent""" + + async def stream(self, query, session_id, task_id): + """Simple response""" + yield {"is_task_complete": True, "content": f"Simple response to: {query}"} + + +async def demo_complete_system(): + """Complete demonstration of the decorator system""" + logger.info("๐Ÿš€ Starting Complete A2A Decorator System Demo") + + # Create connections manager + connections = RemoteConnections() + + try: + # Show available agents from registry + available = connections.list_available_agents() + logger.info(f"๐Ÿ“‹ Available agents from registry: {available}") + + # Start multiple agents + logger.info("โ–ถ๏ธ Starting multiple agents...") + + calc_url = await connections.start_agent("CalculatorAgent") + weather_url = await connections.start_agent("WeatherAgent") + simple_url = await connections.start_agent("SimpleAgent") + + logger.info(f"๐Ÿงฎ Calculator Agent: {calc_url}") + logger.info(f"๐ŸŒค๏ธ Weather Agent: {weather_url}") + logger.info(f"๐Ÿ“ Simple Agent: {simple_url}") + + # Wait for all agents to fully start + await asyncio.sleep(3) + + # Show running agents + running = connections.list_running_agents() + logger.info(f"๐Ÿƒ Running agents: {running}") + + # Test Calculator Agent + logger.info("๐Ÿงช Testing Calculator Agent...") + client = await connections.get_client("CalculatorAgent") + task, event = await client.send_message("What is 15 + 27?") + logger.info(f"Calculator result: {task.status}") + + # # Test Weather Agent + logger.info("๐Ÿงช Testing Weather Agent...") + client = await connections.get_client("WeatherAgent") + task, event = await client.send_message( + "What's the weather like in San Francisco?" + ) + logger.info(f"Weather result: {task.status}") + + # Test Simple Agent + logger.info("๐Ÿงช Testing Simple Agent...") + client = await connections.get_client("SimpleAgent") + task, event = await client.send_message("Hello simple agent") + logger.info(f"Simple agent result: {task.status}") + + await asyncio.sleep(10) + # Show agent information + for agent_name in running: + info = connections.get_agent_info(agent_name) + if info: + logger.info( + f"โ„น๏ธ {agent_name}: {info['url']} (running: {info['running']})" + ) + + logger.info("โœ… All tests completed successfully!") + + except Exception as e: + logger.error(f"โŒ Error in demo: {e}") + import traceback + + traceback.print_exc() + raise + + finally: + # Clean up + logger.info("๐Ÿงน Stopping all agents...") + await connections.stop_all() + logger.info("โœ… Demo completed and cleaned up") + + +if __name__ == "__main__": + asyncio.run(demo_complete_system()) From 021d01799d46640b298513b02a2f7b3e2c8340ac Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Fri, 5 Sep 2025 15:12:20 +0800 Subject: [PATCH 3/9] fix lint --- python/valuecell/utils/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/python/valuecell/utils/__init__.py b/python/valuecell/utils/__init__.py index e759662c8..29fd4e226 100644 --- a/python/valuecell/utils/__init__.py +++ b/python/valuecell/utils/__init__.py @@ -1,2 +1,7 @@ from .port import get_next_available_port from .uuid import generate_uuid + +__all__ = [ + "get_next_available_port", + "generate_uuid", +] From 38b0a3230ca0ada3e12d4513d49fb38918914c8c Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Fri, 5 Sep 2025 15:16:13 +0800 Subject: [PATCH 4/9] fix format --- python/valuecell/utils/uuid.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/valuecell/utils/uuid.py b/python/valuecell/utils/uuid.py index cdf789065..a904c06b2 100644 --- a/python/valuecell/utils/uuid.py +++ b/python/valuecell/utils/uuid.py @@ -1,5 +1,6 @@ from uuid import uuid4 + def generate_uuid(prefix: str = None) -> str: if not prefix: return str(uuid4().hex) From 05ec5846a53e82f8683a37f18eaa64510abac42c Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Fri, 5 Sep 2025 15:37:40 +0800 Subject: [PATCH 5/9] fix: Add type hint for agent parameter in GenericAgentExecutor --- python/valuecell/core/agent/base.py | 31 ++++++++++++++++++++++++ python/valuecell/core/agent/decorator.py | 3 ++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 python/valuecell/core/agent/base.py diff --git a/python/valuecell/core/agent/base.py b/python/valuecell/core/agent/base.py new file mode 100644 index 000000000..683b23277 --- /dev/null +++ b/python/valuecell/core/agent/base.py @@ -0,0 +1,31 @@ +from abc import ABC, abstractmethod +from pydantic import BaseModel, Field + + +class StreamResponse(BaseModel): + is_task_complete: bool = Field( + default=False, + description="Indicates whether the task associated with this stream response is complete.", + ) + content: str = Field( + ..., + description="The content of the stream response, typically a chunk of data or message.", + ) + + +class BaseAgent(ABC, BaseModel): + """ + Abstract base class for all agents. + """ + + agent_name: str = Field(..., description="Unique name of the agent") + description: str = Field( + ..., description="Description of the agent's purpose and functionality" + ) + + @abstractmethod + async def stream(self, query, session_id, task_id) -> StreamResponse: + """ + Abstract method to stream the agent with the provided input data. + Must be implemented by all subclasses. + """ diff --git a/python/valuecell/core/agent/decorator.py b/python/valuecell/core/agent/decorator.py index c9b71fb03..0d9e0c793 100644 --- a/python/valuecell/core/agent/decorator.py +++ b/python/valuecell/core/agent/decorator.py @@ -24,6 +24,7 @@ ) from a2a.utils import new_task from a2a.utils.errors import ServerError +from valuecell.core.agent.base import BaseAgent from valuecell.core.agent.registry import AgentRegistry from valuecell.utils import get_next_available_port @@ -137,7 +138,7 @@ async def serve(self): class GenericAgentExecutor(AgentExecutor): - def __init__(self, agent): + def __init__(self, agent: BaseAgent): self.agent = agent async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: From e15fcbf7968aafb8b9c3e0a2be38c9d544c25833 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Fri, 5 Sep 2025 15:52:53 +0800 Subject: [PATCH 6/9] feat: refactor agent types --- python/valuecell/core/agent/client.py | 15 +++------------ python/valuecell/core/agent/decorator.py | 2 +- python/valuecell/core/agent/{base.py => types.py} | 6 ++++++ 3 files changed, 10 insertions(+), 13 deletions(-) rename python/valuecell/core/agent/{base.py => types.py} (82%) diff --git a/python/valuecell/core/agent/client.py b/python/valuecell/core/agent/client.py index 9c8840d5f..f9a3ce403 100644 --- a/python/valuecell/core/agent/client.py +++ b/python/valuecell/core/agent/client.py @@ -1,20 +1,11 @@ -from typing import AsyncIterator, Optional +from typing import AsyncIterator import httpx from a2a.client import A2ACardResolver, ClientConfig, ClientFactory -from a2a.types import ( - Message, - Part, - PushNotificationConfig, - Role, - Task, - TaskArtifactUpdateEvent, - TaskStatusUpdateEvent, - TextPart, -) +from a2a.types import Message, Part, PushNotificationConfig, Role, TextPart from valuecell.utils import generate_uuid -MessageResponse = tuple[Task, Optional[TaskStatusUpdateEvent | TaskArtifactUpdateEvent]] +from .types import MessageResponse class AgentClient: diff --git a/python/valuecell/core/agent/decorator.py b/python/valuecell/core/agent/decorator.py index 0d9e0c793..c1de4d84e 100644 --- a/python/valuecell/core/agent/decorator.py +++ b/python/valuecell/core/agent/decorator.py @@ -24,8 +24,8 @@ ) from a2a.utils import new_task from a2a.utils.errors import ServerError -from valuecell.core.agent.base import BaseAgent from valuecell.core.agent.registry import AgentRegistry +from valuecell.core.agent.types import BaseAgent from valuecell.utils import get_next_available_port logger = logging.getLogger(__name__) diff --git a/python/valuecell/core/agent/base.py b/python/valuecell/core/agent/types.py similarity index 82% rename from python/valuecell/core/agent/base.py rename to python/valuecell/core/agent/types.py index 683b23277..bc5198ae2 100644 --- a/python/valuecell/core/agent/base.py +++ b/python/valuecell/core/agent/types.py @@ -1,4 +1,7 @@ from abc import ABC, abstractmethod +from typing import Optional + +from a2a.types import Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent from pydantic import BaseModel, Field @@ -29,3 +32,6 @@ async def stream(self, query, session_id, task_id) -> StreamResponse: Abstract method to stream the agent with the provided input data. Must be implemented by all subclasses. """ + + +MessageResponse = tuple[Task, Optional[TaskStatusUpdateEvent | TaskArtifactUpdateEvent]] From 5e9d6fb69eba906909163e6ffbe044262f8b12ef Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:36:21 +0800 Subject: [PATCH 7/9] feat: replace print statements with logging in AgentRegistry --- python/valuecell/core/agent/registry.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/python/valuecell/core/agent/registry.py b/python/valuecell/core/agent/registry.py index bf1365bac..883f99ea0 100644 --- a/python/valuecell/core/agent/registry.py +++ b/python/valuecell/core/agent/registry.py @@ -1,4 +1,7 @@ from typing import Dict, Type, List +import logging + +logger = logging.getLogger(__name__) class AgentRegistry: @@ -23,9 +26,9 @@ def register(cls, agent_class: Type, agent_name: str) -> None: # This helps with debugging and class-based lookups if class_name != agent_name: cls._agents[class_name] = agent_class - print(f"Registered agent: '{agent_name}' (class: {class_name})") + logger.info(f"Registered agent: '{agent_name}' (class: {class_name})") else: - print(f"Registered agent: '{agent_name}'") + logger.info(f"Registered agent: '{agent_name}'") @classmethod def get_agent(cls, name: str) -> Type: @@ -109,7 +112,7 @@ def unregister(cls, name: str) -> bool: del cls._agents[key] agent_name = cls.get_agent_name(agent_class) - print(f"Unregistered agent: '{agent_name}' (removed keys: {keys_to_remove})") + logger.info(f"Unregistered agent: '{agent_name}' (removed keys: {keys_to_remove})") return True @classmethod @@ -133,7 +136,7 @@ def unregister_by_class(cls, agent_class: Type) -> bool: del cls._agents[key] agent_name = cls.get_agent_name(agent_class) - print(f"Unregistered agent: '{agent_name}' (removed keys: {keys_to_remove})") + logger.info(f"Unregistered agent: '{agent_name}' (removed keys: {keys_to_remove})") return True @classmethod @@ -163,7 +166,7 @@ def unregister_all(cls, pattern: str = None) -> List[str]: # Unregister all agent_names = cls.list_agents() cls.clear() - print(f"Unregistered all agents: {agent_names}") + logger.info(f"Unregistered all agents: {agent_names}") return agent_names # Find agents matching pattern From c0262a4d304afe4142230c80291b115b4fe9b643 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:36:54 +0800 Subject: [PATCH 8/9] fix: update type hint for notification_callback and skills parameters --- python/valuecell/core/agent/connect.py | 2 +- python/valuecell/core/agent/decorator.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/valuecell/core/agent/connect.py b/python/valuecell/core/agent/connect.py index 0276b779c..0c2a05f6d 100644 --- a/python/valuecell/core/agent/connect.py +++ b/python/valuecell/core/agent/connect.py @@ -74,7 +74,7 @@ async def _start_listener_for_agent( agent_name: str, listener_host: str, listener_port: int = None, - notification_callback: int = None, + notification_callback: callable = None, ) -> str: """Start a NotificationListener for the agent and return its URL.""" # Auto-assign port if not specified diff --git a/python/valuecell/core/agent/decorator.py b/python/valuecell/core/agent/decorator.py index c1de4d84e..ba1baff86 100644 --- a/python/valuecell/core/agent/decorator.py +++ b/python/valuecell/core/agent/decorator.py @@ -39,7 +39,7 @@ def serve( push_notifications: bool = True, description: str = None, version: str = "1.0.0", - skills: list = None, + skills: list[AgentSkill | dict] = None, ): def decorator(cls: Type) -> Type: # Build agent card (port will be assigned when server starts) From d276aacd93f3e6ba5f7254413ed520f98bb5e027 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:38:36 +0800 Subject: [PATCH 9/9] fix format --- python/valuecell/core/agent/registry.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/python/valuecell/core/agent/registry.py b/python/valuecell/core/agent/registry.py index 883f99ea0..641994c8c 100644 --- a/python/valuecell/core/agent/registry.py +++ b/python/valuecell/core/agent/registry.py @@ -112,7 +112,9 @@ def unregister(cls, name: str) -> bool: del cls._agents[key] agent_name = cls.get_agent_name(agent_class) - logger.info(f"Unregistered agent: '{agent_name}' (removed keys: {keys_to_remove})") + logger.info( + f"Unregistered agent: '{agent_name}' (removed keys: {keys_to_remove})" + ) return True @classmethod @@ -136,7 +138,9 @@ def unregister_by_class(cls, agent_class: Type) -> bool: del cls._agents[key] agent_name = cls.get_agent_name(agent_class) - logger.info(f"Unregistered agent: '{agent_name}' (removed keys: {keys_to_remove})") + logger.info( + f"Unregistered agent: '{agent_name}' (removed keys: {keys_to_remove})" + ) return True @classmethod