From c8c1adb310c1ef4b8615a765ff3e035061456003 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:07:05 +0800 Subject: [PATCH 01/50] feat(react_agent): implement react agent functionality with LangGraph integration --- python/pyproject.toml | 2 + python/uv.lock | 258 +++++++++++++++++- .../valuecell/agents/react_agent/__init__.py | 0 python/valuecell/agents/react_agent/graph.py | 134 +++++++++ python/valuecell/agents/react_agent/models.py | 37 +++ .../agents/react_agent/nodes/critic.py | 90 ++++++ .../agents/react_agent/nodes/executor.py | 199 ++++++++++++++ .../agents/react_agent/nodes/inquirer.py | 121 ++++++++ .../agents/react_agent/nodes/planner.py | 136 +++++++++ .../agents/react_agent/nodes/scheduler.py | 63 +++++ python/valuecell/agents/react_agent/state.py | 33 +++ .../agents/react_agent/tool_registry.py | 240 ++++++++++++++++ 12 files changed, 1309 insertions(+), 4 deletions(-) create mode 100644 python/valuecell/agents/react_agent/__init__.py create mode 100644 python/valuecell/agents/react_agent/graph.py create mode 100644 python/valuecell/agents/react_agent/models.py create mode 100644 python/valuecell/agents/react_agent/nodes/critic.py create mode 100644 python/valuecell/agents/react_agent/nodes/executor.py create mode 100644 python/valuecell/agents/react_agent/nodes/inquirer.py create mode 100644 python/valuecell/agents/react_agent/nodes/planner.py create mode 100644 python/valuecell/agents/react_agent/nodes/scheduler.py create mode 100644 python/valuecell/agents/react_agent/state.py create mode 100644 python/valuecell/agents/react_agent/tool_registry.py diff --git a/python/pyproject.toml b/python/pyproject.toml index 23d3cb0d0..b7af3d252 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -31,6 +31,8 @@ dependencies = [ "ccxt>=4.5.15", "baostock>=0.8.9", "func-timeout>=4.3.5", + "langchain-openai>=1.1.1", + "langgraph>=1.0.4", ] [project.optional-dependencies] diff --git a/python/uv.lock b/python/uv.lock index 8f5fe746e..24fca98bc 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", @@ -1456,12 +1456,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/e8/685f47e0d754320684db4425a0967f7d3fa70126bffd76110b7009a0090f/joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241", size = 308396, upload-time = "2025-08-27T12:15:45.188Z" }, ] +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + [[package]] name = "jsonpath" version = "0.82.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cf/a1/693351acd0a9edca4de9153372a65e75398898ea7f8a5c722ab00f464929/jsonpath-0.82.2.tar.gz", hash = "sha256:d87ef2bcbcded68ee96bc34c1809b69457ecec9b0c4dd471658a12bd391002d1", size = 10353, upload-time = "2023-08-24T18:57:55.459Z" } +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -1559,6 +1580,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/0a/36d753b01198b0590eb45e283b07d54feaaab89d528cf7bb048eeeaf2dce/lancedb-0.25.2-cp39-abi3-win_amd64.whl", hash = "sha256:9bd990f27667d37cec0f41686e9c83e8051bb45cb4b6d48355fcc9f8e2c6b0f7", size = 41081428, upload-time = "2025-10-08T18:59:54.832Z" }, ] +[[package]] +name = "langchain-core" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/8d/99652acb7feaa4e16c9162429bc7a446f04749ef438aa02fce74b4319a00/langchain_core-1.1.2.tar.gz", hash = "sha256:75456c5cc10c3b53b80488bf5c6a4bcc3447b53e011533a8744bb0638b85dd78", size = 802560, upload-time = "2025-12-08T15:28:17.689Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/68/2caf612e4b5e25d7938c96809b7ccbafb5906958bcad8c18d9211f092679/langchain_core-1.1.2-py3-none-any.whl", hash = "sha256:74dfd4dcc10a290e3701a64e35e0bea3f68420f5b7527820ced9414f5b2dc281", size = 475847, upload-time = "2025-12-08T15:28:16.467Z" }, +] + +[[package]] +name = "langchain-openai" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/38/c6517187ea5f0db6d682083116a020409b01eef0547b4330542c117cd25d/langchain_openai-1.1.1.tar.gz", hash = "sha256:72aa7262854104e0b2794522a90c49353c79d0132caa1be27ef253852685d5e7", size = 1037309, upload-time = "2025-12-08T16:17:29.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/95/d65d3e187cd717baeb62988ae90a995f93564657f67518fb775af4090880/langchain_openai-1.1.1-py3-none-any.whl", hash = "sha256:69b9be37e6ae3372b4d937cb9365cf55c0c59b5f7870e7507cb7d802a8b98b30", size = 84291, upload-time = "2025-12-08T16:17:24.418Z" }, +] + [[package]] name = "langdetect" version = "1.0.9" @@ -1568,6 +1622,81 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2569554f7c70f4a3c27712f40e3284d483e88094cc0e/langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0", size = 981474, upload-time = "2021-05-07T07:54:13.562Z" } +[[package]] +name = "langgraph" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/3c/af87902d300c1f467165558c8966d8b1e1f896dace271d3f35a410a5c26a/langgraph-1.0.4.tar.gz", hash = "sha256:86d08e25d7244340f59c5200fa69fdd11066aa999b3164b531e2a20036fac156", size = 484397, upload-time = "2025-11-25T20:31:48.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/52/4eb25a3f60399da34ba34adff1b3e324cf0d87eb7a08cebf1882a9b5e0d5/langgraph-1.0.4-py3-none-any.whl", hash = "sha256:b1a835ceb0a8d69b9db48075e1939e28b1ad70ee23fa3fa8f90149904778bacf", size = 157271, upload-time = "2025-11-25T20:31:47.518Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/07/2b1c042fa87d40cf2db5ca27dc4e8dd86f9a0436a10aa4361a8982718ae7/langgraph_checkpoint-3.0.1.tar.gz", hash = "sha256:59222f875f85186a22c494aedc65c4e985a3df27e696e5016ba0b98a5ed2cee0", size = 137785, upload-time = "2025-11-04T21:55:47.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/e3/616e3a7ff737d98c1bbb5700dd62278914e2a9ded09a79a1fa93cf24ce12/langgraph_checkpoint-3.0.1-py3-none-any.whl", hash = "sha256:9b04a8d0edc0474ce4eaf30c5d731cee38f11ddff50a6177eead95b5c4e4220b", size = 46249, upload-time = "2025-11-04T21:55:46.472Z" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/f9/54f8891b32159e4542236817aea2ee83de0de18bce28e9bdba08c7f93001/langgraph_prebuilt-1.0.5.tar.gz", hash = "sha256:85802675ad778cc7240fd02d47db1e0b59c0c86d8369447d77ce47623845db2d", size = 144453, upload-time = "2025-11-20T16:47:39.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/5e/aeba4a5b39fe6e874e0dd003a82da71c7153e671312671a8dacc5cb7c1af/langgraph_prebuilt-1.0.5-py3-none-any.whl", hash = "sha256:22369563e1848862ace53fbc11b027c28dd04a9ac39314633bb95f2a7e258496", size = 35072, upload-time = "2025-11-20T16:47:38.187Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/c4/b37b892a408f0b4753b8ad49529c7b5994abab47005940300ab1af9d8a5c/langgraph_sdk-0.2.14.tar.gz", hash = "sha256:fab3dd713a9c7a9cc46dc4b2eb5e555bd0c07b185cfaf813d61b5356ee40886e", size = 130335, upload-time = "2025-12-06T00:23:31.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/ff/c4d91a2d28a141a58dc8fea408041aff299f59563d43d0e0f458469e10cb/langgraph_sdk-0.2.14-py3-none-any.whl", hash = "sha256:e01ab9867d3b22d3b4ddd46fc0bab67b7684b25ab784a276684f331ca07efabf", size = 66464, upload-time = "2025-12-06T00:23:30.638Z" }, +] + +[[package]] +name = "langsmith" +version = "0.4.56" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/e0/6d8a07b25a3ac308156707edaeffebbc30b2737bba8a75e65c40908beb94/langsmith-0.4.56.tar.gz", hash = "sha256:c3dc53509972689dbbc24f9ac92a095dcce00f76bb0db03ae385815945572540", size = 991755, upload-time = "2025-12-06T00:15:52.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/6f/d5f9c4f1e03c91045d3675dc99df0682bc657952ad158c92c1f423de04f4/langsmith-0.4.56-py3-none-any.whl", hash = "sha256:f2c61d3f10210e78f16f77e3115f407d40f562ab00ac8c76927c7dd55b5c17b2", size = 411849, upload-time = "2025-12-06T00:15:50.828Z" }, +] + [[package]] name = "lark" version = "1.3.0" @@ -1940,7 +2069,7 @@ wheels = [ [[package]] name = "openai" -version = "1.107.0" +version = "2.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1952,9 +2081,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/67/d6498de300f83ff57a79cb7aa96ef3bef8d6f070c3ded0f1b5b45442a6bc/openai-1.107.0.tar.gz", hash = "sha256:43e04927584e57d0e9e640ee0077c78baf8150098be96ebd5c512539b6c4e9a4", size = 566056, upload-time = "2025-09-08T19:25:47.604Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/48/516290f38745cc1e72856f50e8afed4a7f9ac396a5a18f39e892ab89dfc2/openai-2.9.0.tar.gz", hash = "sha256:b52ec65727fc8f1eed2fbc86c8eac0998900c7ef63aa2eb5c24b69717c56fa5f", size = 608202, upload-time = "2025-12-04T18:15:09.01Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/ed/e8a4fd20390f2858b95227c288df8fe0c835f7c77625f7583609161684ba/openai-1.107.0-py3-none-any.whl", hash = "sha256:3dcfa3cbb116bd6924b27913b8da28c4a787379ff60049588547a1013e6d6438", size = 950968, upload-time = "2025-09-08T19:25:45.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/fd/ae2da789cd923dd033c99b8d544071a827c92046b150db01cfa5cea5b3fd/openai-2.9.0-py3-none-any.whl", hash = "sha256:0d168a490fbb45630ad508a6f3022013c155a68fd708069b6a1a01a5e8f0ffad", size = 1030836, upload-time = "2025-12-04T18:15:07.063Z" }, ] [[package]] @@ -2018,6 +2147,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/01/d6b274a0635be0468d4dbd9cafe80c47105937a0d42434e805e67cd2ed8b/orjson-3.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:e8f6a7a27d7b7bec81bd5924163e9af03d49bbb63013f107b48eb5d16db711bc", size = 125985, upload-time = "2025-08-26T17:46:16.67Z" }, ] +[[package]] +name = "ormsgpack" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/67/d5ef41c3b4a94400be801984ef7c7fc9623e1a82b643e74eeec367e7462b/ormsgpack-1.12.0.tar.gz", hash = "sha256:94be818fdbb0285945839b88763b269987787cb2f7ef280cad5d6ec815b7e608", size = 49959, upload-time = "2025-11-04T18:30:10.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/f2/c1036b2775fcc0cfa5fd618c53bcd3b862ee07298fb627f03af4c7982f84/ormsgpack-1.12.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e0c1e08b64d99076fee155276097489b82cc56e8d5951c03c721a65a32f44494", size = 369538, upload-time = "2025-11-04T18:29:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ca/526c4ae02f3cb34621af91bf8282a10d666757c2e0c6ff391ff5d403d607/ormsgpack-1.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fd43bcb299131690b8e0677af172020b2ada8e625169034b42ac0c13adf84aa", size = 195872, upload-time = "2025-11-04T18:29:38.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0f/83bb7968e9715f6a85be53d041b1e6324a05428f56b8b980dac866886871/ormsgpack-1.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0149d595341e22ead340bf281b2995c4cc7dc8d522a6b5f575fe17aa407604", size = 206469, upload-time = "2025-11-04T18:29:39.749Z" }, + { url = "https://files.pythonhosted.org/packages/02/e3/9e93ca1065f2d4af035804a842b1ff3025bab580c7918239bb225cd1fee2/ormsgpack-1.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f19a1b27d169deb553c80fd10b589fc2be1fc14cee779fae79fcaf40db04de2b", size = 208273, upload-time = "2025-11-04T18:29:40.769Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d8/6d6ef901b3a8b8f3ab8836b135a56eb7f66c559003e251d9530bedb12627/ormsgpack-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6f28896942d655064940dfe06118b7ce1e3468d051483148bf02c99ec157483a", size = 377839, upload-time = "2025-11-04T18:29:42.092Z" }, + { url = "https://files.pythonhosted.org/packages/4c/72/fcb704bfa4c2c3a37b647d597cc45a13cffc9d50baac635a9ad620731d29/ormsgpack-1.12.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9396efcfa48b4abbc06e44c5dbc3c4574a8381a80cb4cd01eea15d28b38c554e", size = 471446, upload-time = "2025-11-04T18:29:43.133Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/402e4e3eb997c2ee534c99bec4b5bb359c2a1f9edadf043e254a71e11378/ormsgpack-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96586ed537a5fb386a162c4f9f7d8e6f76e07b38a990d50c73f11131e00ff040", size = 381783, upload-time = "2025-11-04T18:29:44.466Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8d/5897b700360bc00911b70ae5ef1134ee7abf5baa81a92a4be005917d3dfd/ormsgpack-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e70387112fb3870e4844de090014212cdcf1342f5022047aecca01ec7de05d7a", size = 112943, upload-time = "2025-11-04T18:29:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/5b/44/1e73649f79bb96d6cf9e5bcbac68b6216d238bba80af351c4c0cbcf7ee15/ormsgpack-1.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:d71290a23de5d4829610c42665d816c661ecad8979883f3f06b2e3ab9639962e", size = 106688, upload-time = "2025-11-04T18:29:46.411Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e8/35f11ce9313111488b26b3035e4cbe55caa27909c0b6c8b5b5cd59f9661e/ormsgpack-1.12.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:766f2f3b512d85cd375b26a8b1329b99843560b50b93d3880718e634ad4a5de5", size = 369574, upload-time = "2025-11-04T18:29:47.431Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/77461587f412d4e598d3687bafe23455ed0f26269f44be20252eddaa624e/ormsgpack-1.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84b285b1f3f185aad7da45641b873b30acfd13084cf829cf668c4c6480a81583", size = 195893, upload-time = "2025-11-04T18:29:48.735Z" }, + { url = "https://files.pythonhosted.org/packages/c6/67/e197ceb04c3b550589e5407fc9fdae10f4e2e2eba5fdac921a269e02e974/ormsgpack-1.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e23604fc79fe110292cb365f4c8232e64e63a34f470538be320feae3921f271b", size = 206503, upload-time = "2025-11-04T18:29:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b1/7fa8ba82a25cef678983c7976f85edeef5014f5c26495f338258e6a3cf1c/ormsgpack-1.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc32b156c113a0fae2975051417d8d9a7a5247c34b2d7239410c46b75ce9348a", size = 208257, upload-time = "2025-11-04T18:29:51.007Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b1/759e999390000d2589e6d0797f7265e6ec28378547075d28d3736248ab63/ormsgpack-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94ac500dd10c20fa8b8a23bc55606250bfe711bf9716828d9f3d44dfd1f25668", size = 377852, upload-time = "2025-11-04T18:29:52.103Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/0af737c94272494d9d84a3c29cc42c973ef7fd2342917020906596db863c/ormsgpack-1.12.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c5201ff7ec24f721f813a182885a17064cffdbe46b2412685a52e6374a872c8f", size = 471456, upload-time = "2025-11-04T18:29:53.336Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ba/c81f0aa4f19fbf457213395945b672e6fde3ce777e3587456e7f0fca2147/ormsgpack-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a9740bb3839c9368aacae1cbcfc474ee6976458f41cc135372b7255d5206c953", size = 381813, upload-time = "2025-11-04T18:29:54.394Z" }, + { url = "https://files.pythonhosted.org/packages/ce/15/429c72d64323503fd42cc4ca8398930ded8aa8b3470df8a86b3bbae7a35c/ormsgpack-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ed37f29772432048b58174e920a1d4c4cde0404a5d448d3d8bbcc95d86a6918", size = 112949, upload-time = "2025-11-04T18:29:55.371Z" }, + { url = "https://files.pythonhosted.org/packages/55/b9/e72c451a40f8c57bfc229e0b8e536ecea7203c8f0a839676df2ffb605c62/ormsgpack-1.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:b03994bbec5d6d42e03d6604e327863f885bde67aa61e06107ce1fa5bdd3e71d", size = 106689, upload-time = "2025-11-04T18:29:56.262Z" }, + { url = "https://files.pythonhosted.org/packages/13/16/13eab1a75da531b359105fdee90dda0b6bd1ca0a09880250cf91d8bdfdea/ormsgpack-1.12.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0f3981ba3cba80656012090337e548e597799e14b41e3d0b595ab5ab05a23d7f", size = 369620, upload-time = "2025-11-04T18:29:57.255Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c1/cbcc38b7af4ce58d8893e56d3595c0c8dcd117093bf048f889cf351bdba0/ormsgpack-1.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:901f6f55184d6776dbd5183cbce14caf05bf7f467eef52faf9b094686980bf71", size = 195925, upload-time = "2025-11-04T18:29:58.34Z" }, + { url = "https://files.pythonhosted.org/packages/5c/59/4fa4dc0681490e12b75333440a1c0fd9741b0ebff272b1db4a29d35c2021/ormsgpack-1.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e13b15412571422b711b40f45e3fe6d993ea3314b5e97d1a853fe99226c5effc", size = 206594, upload-time = "2025-11-04T18:29:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/249770896bc32bb91b22c30256961f935d0915cbcf6e289a7fc961d9b14c/ormsgpack-1.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91fa8a452553a62e5fb3fbab471e7faf7b3bec3c87a2f355ebf3d7aab290fe4f", size = 208307, upload-time = "2025-11-04T18:30:00.377Z" }, + { url = "https://files.pythonhosted.org/packages/07/0a/e041a248cd72f2f4c07e155913e0a3ede4c86cf21a40ae6cd79f135f2847/ormsgpack-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74ec101f69624695eec4ce7c953192d97748254abe78fb01b591f06d529e1952", size = 377844, upload-time = "2025-11-04T18:30:01.389Z" }, + { url = "https://files.pythonhosted.org/packages/d8/71/6f7773e4ffda73a358ce4bba69b3e8bee9d40a7a06315e4c1cd7a3ea9d02/ormsgpack-1.12.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bbf7896580848326c1f9bd7531f264e561f98db7e08e15aa75963d83832c717", size = 471572, upload-time = "2025-11-04T18:30:02.486Z" }, + { url = "https://files.pythonhosted.org/packages/65/29/af6769a4289c07acc71e7bda1d64fb31800563147d73142686e185e82348/ormsgpack-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7567917da613b8f8d591c1674e411fd3404bea41ef2b9a0e0a1e049c0f9406d7", size = 381842, upload-time = "2025-11-04T18:30:03.799Z" }, + { url = "https://files.pythonhosted.org/packages/0b/dd/0a86195ee7a1a96c088aefc8504385e881cf56f4563ed81bafe21cbf1fb0/ormsgpack-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e418256c5d8622b8bc92861936f7c6a0131355e7bcad88a42102ae8227f8a1c", size = 113008, upload-time = "2025-11-04T18:30:04.777Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/fafc79e32f3087f6f26f509d80b8167516326bfea38d30502627c01617e0/ormsgpack-1.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:433ace29aa02713554f714c62a4e4dcad0c9e32674ba4f66742c91a4c3b1b969", size = 106648, upload-time = "2025-11-04T18:30:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cf/5d58d9b132128d2fe5d586355dde76af386554abef00d608f66b913bff1f/ormsgpack-1.12.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e57164be4ca34b64e210ec515059193280ac84df4d6f31a6fcbfb2fc8436de55", size = 369803, upload-time = "2025-11-04T18:30:06.728Z" }, + { url = "https://files.pythonhosted.org/packages/67/42/968a2da361eaff2e4cbb17c82c7599787babf16684110ad70409646cc1e4/ormsgpack-1.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:904f96289deaa92fc6440b122edc27c5bdc28234edd63717f6d853d88c823a83", size = 195991, upload-time = "2025-11-04T18:30:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/03/f0/9696c6c6cf8ad35170f0be8d0ef3523cc258083535f6c8071cb8235ebb8b/ormsgpack-1.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b291d086e524a1062d57d1b7b5a8bcaaf29caebf0212fec12fd86240bd33633", size = 208316, upload-time = "2025-11-04T18:30:08.663Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -3623,6 +3790,28 @@ 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 = "uuid-utils" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/0e/512fb221e4970c2f75ca9dae412d320b7d9ddc9f2b15e04ea8e44710396c/uuid_utils-0.12.0.tar.gz", hash = "sha256:252bd3d311b5d6b7f5dfce7a5857e27bb4458f222586bb439463231e5a9cbd64", size = 20889, upload-time = "2025-12-01T17:29:55.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/43/de5cd49a57b6293b911b6a9a62fc03e55db9f964da7d5882d9edbee1e9d2/uuid_utils-0.12.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3b9b30707659292f207b98f294b0e081f6d77e1fbc760ba5b41331a39045f514", size = 603197, upload-time = "2025-12-01T17:29:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/02/fa/5fd1d8c9234e44f0c223910808cde0de43bb69f7df1349e49b1afa7f2baa/uuid_utils-0.12.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:add3d820c7ec14ed37317375bea30249699c5d08ff4ae4dbee9fc9bce3bfbf65", size = 305168, upload-time = "2025-12-01T17:29:31.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c6/8633ac9942bf9dc97a897b5154e5dcffa58816ec4dd780b3b12b559ff05c/uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b8fce83ecb3b16af29c7809669056c4b6e7cc912cab8c6d07361645de12dd79", size = 340580, upload-time = "2025-12-01T17:29:32.362Z" }, + { url = "https://files.pythonhosted.org/packages/f3/88/8a61307b04b4da1c576373003e6d857a04dade52ab035151d62cb84d5cb5/uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec921769afcb905035d785582b0791d02304a7850fbd6ce924c1a8976380dfc6", size = 346771, upload-time = "2025-12-01T17:29:33.708Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fb/aab2dcf94b991e62aa167457c7825b9b01055b884b888af926562864398c/uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f3b060330f5899a92d5c723547dc6a95adef42433e9748f14c66859a7396664", size = 474781, upload-time = "2025-12-01T17:29:35.237Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7a/dbd5e49c91d6c86dba57158bbfa0e559e1ddf377bb46dcfd58aea4f0d567/uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:908dfef7f0bfcf98d406e5dc570c25d2f2473e49b376de41792b6e96c1d5d291", size = 343685, upload-time = "2025-12-01T17:29:36.677Z" }, + { url = "https://files.pythonhosted.org/packages/1a/19/8c4b1d9f450159733b8be421a4e1fb03533709b80ed3546800102d085572/uuid_utils-0.12.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c6a24148926bd0ca63e8a2dabf4cc9dc329a62325b3ad6578ecd60fbf926506", size = 366482, upload-time = "2025-12-01T17:29:37.979Z" }, + { url = "https://files.pythonhosted.org/packages/82/43/c79a6e45687647f80a159c8ba34346f287b065452cc419d07d2212d38420/uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:64a91e632669f059ef605f1771d28490b1d310c26198e46f754e8846dddf12f4", size = 523132, upload-time = "2025-12-01T17:29:39.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a2/b2d75a621260a40c438aa88593827dfea596d18316520a99e839f7a5fb9d/uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:93c082212470bb4603ca3975916c205a9d7ef1443c0acde8fbd1e0f5b36673c7", size = 614218, upload-time = "2025-12-01T17:29:40.315Z" }, + { url = "https://files.pythonhosted.org/packages/13/6b/ba071101626edd5a6dabf8525c9a1537ff3d885dbc210540574a03901fef/uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:431b1fb7283ba974811b22abd365f2726f8f821ab33f0f715be389640e18d039", size = 546241, upload-time = "2025-12-01T17:29:41.656Z" }, + { url = "https://files.pythonhosted.org/packages/01/12/9a942b81c0923268e6d85bf98d8f0a61fcbcd5e432fef94fdf4ce2ef8748/uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd7838c40149100299fa37cbd8bab5ee382372e8e65a148002a37d380df7c8", size = 511842, upload-time = "2025-12-01T17:29:43.107Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a7/c326f5163dd48b79368b87d8a05f5da4668dd228a3f5ca9d79d5fee2fc40/uuid_utils-0.12.0-cp39-abi3-win32.whl", hash = "sha256:487f17c0fee6cbc1d8b90fe811874174a9b1b5683bf2251549e302906a50fed3", size = 179088, upload-time = "2025-12-01T17:29:44.492Z" }, + { url = "https://files.pythonhosted.org/packages/38/92/41c8734dd97213ee1d5ae435cf4499705dc4f2751e3b957fd12376f61784/uuid_utils-0.12.0-cp39-abi3-win_amd64.whl", hash = "sha256:9598e7c9da40357ae8fffc5d6938b1a7017f09a1acbcc95e14af8c65d48c655a", size = 183003, upload-time = "2025-12-01T17:29:45.47Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/52ab0359618987331a1f739af837d26168a4b16281c9c3ab46519940c628/uuid_utils-0.12.0-cp39-abi3-win_arm64.whl", hash = "sha256:c9bea7c5b2aa6f57937ebebeee4d4ef2baad10f86f1b97b58a3f6f34c14b4e84", size = 182975, upload-time = "2025-12-01T17:29:46.444Z" }, +] + [[package]] name = "uvicorn" version = "0.35.0" @@ -3652,6 +3841,8 @@ dependencies = [ { name = "edgartools" }, { name = "fastapi" }, { name = "func-timeout" }, + { name = "langchain-openai" }, + { name = "langgraph" }, { name = "loguru" }, { name = "markdown" }, { name = "pydantic" }, @@ -3711,6 +3902,8 @@ requires-dist = [ { name = "edgartools", specifier = ">=4.12.2" }, { name = "fastapi", specifier = ">=0.104.0" }, { name = "func-timeout", specifier = ">=4.3.5" }, + { name = "langchain-openai", specifier = ">=1.1.1" }, + { name = "langgraph", specifier = ">=1.0.4" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "markdown", specifier = ">=3.9" }, { name = "pydantic", specifier = ">=2.0.0" }, @@ -4056,3 +4249,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/9a/62a9ba3a919594605a07c34eee3068659bbd648e2fa0c4a86d876810b674/zope_interface-8.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:87e6b089002c43231fb9afec89268391bcc7a3b66e76e269ffde19a8112fb8d5", size = 264201, upload-time = "2025-09-25T06:26:27.797Z" }, { url = "https://files.pythonhosted.org/packages/da/06/8fe88bd7edef60566d21ef5caca1034e10f6b87441ea85de4bbf9ea74768/zope_interface-8.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:64a43f5280aa770cbafd0307cb3d1ff430e2a1001774e8ceb40787abe4bb6658", size = 212273, upload-time = "2025-09-25T06:00:25.398Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/python/valuecell/agents/react_agent/__init__.py b/python/valuecell/agents/react_agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/react_agent/graph.py b/python/valuecell/agents/react_agent/graph.py new file mode 100644 index 000000000..eee185b57 --- /dev/null +++ b/python/valuecell/agents/react_agent/graph.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from typing import Any +import uuid + +from .nodes.critic import critic_node +from .nodes.executor import executor_node +from .nodes.inquirer import inquirer_node +from .nodes.planner import planner_node +from .nodes.scheduler import scheduler_node +from .state import AgentState + + +def _route_after_scheduler(state: dict[str, Any]): + """Route after scheduler node based on _schedule_status. + + - Returns list[Send("executor", {"task": t})] when runnable tasks exist. + - Returns "critic" when plan is complete or deadlocked. + - Returns END when waiting for dispatched tasks. + """ + try: + from langgraph.types import Send # type: ignore + except Exception as exc: # pragma: no cover + raise RuntimeError( + "LangGraph is required for the orchestrator. Install 'langgraph'." + ) from exc + + status = state.get("_schedule_status") + if status == "runnable": + runnable = state.get("_runnable") or [] + if runnable: + return [Send("executor", {"task": t}) for t in runnable] + if status == "waiting": + # Tasks are dispatched but not completed; return empty to no-op + return [] + if status in {"complete", "deadlock"}: + return "critic" + return "critic" + + +async def _executor_entry(state: dict[str, Any]) -> dict[str, Any]: + """Entry adapter for executor: expects a `task` injected via Send().""" + task = state.get("task") or {} + return await executor_node(state, task) + + +def build_app() -> Any: + """Build and compile the LangGraph StateGraph with memory checkpointer.""" + # Local imports to keep module import safe if langgraph isn't installed yet + try: + from langgraph.checkpoint.memory import MemorySaver # type: ignore + from langgraph.graph import END, START, StateGraph # type: ignore + except Exception as exc: # pragma: no cover - import-time guard + raise RuntimeError( + "LangGraph is required for the orchestrator. Install 'langgraph'." + ) from exc + + graph = StateGraph(AgentState) + + graph.add_node("inquirer", inquirer_node) + graph.add_node("planner", planner_node) + graph.add_node("scheduler", scheduler_node) + graph.add_node("executor", _executor_entry) + graph.add_node("critic", critic_node) + + graph.add_edge(START, "inquirer") + + def _route_after_inquirer(st: dict[str, Any]) -> str: + return "plan" if st.get("user_profile") else "wait" + + graph.add_conditional_edges( + "inquirer", _route_after_inquirer, {"plan": "planner", "wait": END} + ) + + # After planning, go to scheduler + graph.add_edge("planner", "scheduler") + + # After executor completion, go back to scheduler to check for next wave + graph.add_edge("executor", "scheduler") + + # After scheduler node, route based on status + graph.add_conditional_edges("scheduler", _route_after_scheduler, {"critic": "critic"}) + + def _route_after_critic(st: dict[str, Any]) -> str: + na = st.get("next_action") + val = getattr(na, "value", na) + v = str(val).lower() if val is not None else "exit" + if v == "replan": + # Clear plan/schedule to allow fresh planning cycle + st.pop("plan", None) + st.pop("_schedule_status", None) + st.pop("_runnable", None) + st.pop("_dispatched", None) # Clear dispatch tracking for fresh plan + return "replan" + return "end" + + graph.add_conditional_edges( + "critic", _route_after_critic, {"replan": "planner", "end": END} + ) + + memory = MemorySaver() + app = graph.compile(checkpointer=memory) + return app + + +# Lazy singleton accessor to avoid import-time dependency failures +_APP_SINGLETON: Any | None = None + + +def get_app() -> Any: + global _APP_SINGLETON + if _APP_SINGLETON is None: + _APP_SINGLETON = build_app() + return _APP_SINGLETON + + +# Backwards-compat: expose `app` when available, else None until build +app: Any | None = None + + +async def astream_events(initial_state: dict[str, Any], config: dict | None = None): + """Stream LangGraph events (v2) from the compiled app. + + Usage: async for ev in astream_events(state): ... + """ + application = get_app() + # Ensure checkpointer receives required configurable keys. + cfg: dict = dict(config or {}) + cfg.setdefault("thread_id", "main") + cfg.setdefault("checkpoint_ns", "react_agent") + cfg.setdefault("checkpoint_id", str(uuid.uuid4())) + + async for ev in application.astream_events(initial_state, config=cfg, version="v2"): + yield ev diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py new file mode 100644 index 000000000..29ec4b92f --- /dev/null +++ b/python/valuecell/agents/react_agent/models.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + + +class Task(BaseModel): + id: str + tool_name: Literal["market_data", "screen", "backtest", "summary"] + tool_args: dict[str, Any] = Field(default_factory=dict) + dependencies: list[str] = Field(default_factory=list) + + +class FinancialIntent(BaseModel): + asset_symbols: Optional[list[str]] = None + risk: Optional[Literal["Low", "Medium", "High"]] = None + + +class ExecutorResult(BaseModel): + task_id: str + ok: bool = True + result: Any | None = None + error: Optional[str] = None + error_code: Optional[str] = None # e.g., ERR_NETWORK, ERR_INPUT + + +class InquirerDecision(BaseModel): + """The decision output from the LLM-driven Inquirer Agent.""" + intent: FinancialIntent = Field(description="Extracted financial intent from conversation") + status: Literal["COMPLETE", "INCOMPLETE"] = Field( + description="Set to COMPLETE if essential info (risk) is present OR if max turns reached." + ) + reasoning: str = Field(description="Brief thought process explaining the decision") + response_to_user: str = Field( + description="If INCOMPLETE: A follow-up question. If COMPLETE: A confirmation message." + ) diff --git a/python/valuecell/agents/react_agent/nodes/critic.py b/python/valuecell/agents/react_agent/nodes/critic.py new file mode 100644 index 000000000..e2d4f4b4b --- /dev/null +++ b/python/valuecell/agents/react_agent/nodes/critic.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Any +import json + +from agno.agent import Agent +from agno.models.openrouter import OpenRouter +from loguru import logger +from pydantic import BaseModel, Field +from enum import Enum + + +class NextAction(str, Enum): + EXIT = "exit" + REPLAN = "replan" + + +class CriticDecision(BaseModel): + next_action: NextAction = Field(description="Either 'exit' or 'replan'") + reason: str = Field(description="Short rationale for the decision") + + +async def critic_node(state: dict[str, Any]) -> dict[str, Any]: + """Use an Agno agent to decide whether to exit or trigger replanning.""" + completed = state.get("completed_tasks") or {} + + # Prepare concise context for the agent + ok = {tid: res.get("result") for tid, res in completed.items() if res.get("ok")} + errors = { + tid: {"error": res.get("error"), "code": res.get("error_code")} + for tid, res in completed.items() + if not res.get("ok") + } + + context = { + "plan_logic": state.get("plan_logic"), + "plan": state.get("plan"), + "ok_results": ok, + "errors": errors, + } + + # If nothing ran, default to replan to let planner try again + if not completed: + logger.warning("Critic: no completed tasks; defaulting to replan") + state["_critic_summary"] = {"status": "empty"} + state["next_action"] = "replan" + state["next_action_reason"] = ( + "No tasks executed; require planner to produce a plan." + ) + return state + + system_prompt = ( + "You are a critical reviewer for an execution graph in a financial agent. " + "Review the completed tasks and errors. Decide whether the current results " + "are sufficient to exit, or whether the planner should continue with additional " + "planning to achieve the user's goal. Respond strictly in JSON per the schema." + ) + user_msg = json.dumps(context, ensure_ascii=False) + + try: + agent = Agent( + model=OpenRouter(id="google/gemini-2.5-flash"), + instructions=[system_prompt], + markdown=False, + output_schema=CriticDecision, + debug_mode=True, + ) + response = await agent.arun(user_msg) + decision: CriticDecision = response.content + state["_critic_summary"] = { + "ok_count": len(ok), + "error_count": len(errors), + "errors": errors, + } + action = decision.next_action + state["next_action"] = action + state["next_action_reason"] = decision.reason + logger.info("Critic decided: {a} - {r}", a=action.value, r=decision.reason) + return state + except Exception as exc: + logger.warning("Critic agent error: {err}", err=str(exc)) + # On error, default to replan to allow recovery + state["_critic_summary"] = { + "ok_count": len(ok), + "error_count": len(errors), + "errors": errors, + } + state["next_action"] = NextAction.REPLAN + state["next_action_reason"] = "Critic error; safe default is to replan." + return state diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py new file mode 100644 index 000000000..5d56581cc --- /dev/null +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from typing import Any, Callable + +from langchain_core.callbacks import adispatch_custom_event +from loguru import logger +from pydantic import BaseModel, Field + +from ..models import ExecutorResult +from ..tool_registry import registry + + +class MarketDataArgs(BaseModel): + symbols: list[str] = Field( + default_factory=list, description="Ticker symbols to fetch" + ) + + +class ScreenArgs(BaseModel): + risk: str | None = Field( + default=None, description="Risk preference: Low, Medium, or High" + ) + + +class BacktestArgs(BaseModel): + symbols: list[str] = Field( + default_factory=list, description="Ticker symbols to backtest" + ) + horizon_days: int = Field(default=90, description="Lookback window in days") + + +class SummaryArgs(BaseModel): + class Config: + extra = "forbid" + + +_TOOLS_REGISTERED = False + + +def ensure_default_tools_registered() -> None: + global _TOOLS_REGISTERED + if _TOOLS_REGISTERED: + return + + _register_tool( + "market_data", + _tool_market_data, + "Fetch market statistics for a list of symbols.", + args_schema=MarketDataArgs, + ) + _register_tool( + "screen", + _tool_screen, + "Screen candidate symbols based on risk preference.", + args_schema=ScreenArgs, + ) + _register_tool( + "backtest", + _tool_backtest, + "Run a simple backtest for provided symbols.", + args_schema=BacktestArgs, + ) + _register_tool( + "summary", + _tool_summary, + "Summarize completed task outcomes.", + args_schema=SummaryArgs, + ) + + _TOOLS_REGISTERED = True + + +def _register_tool( + tool_id: str, + func: Callable[..., Any], + description: str, + *, + args_schema: type[BaseModel], +) -> None: + try: + registry.register( + tool_id, + func, + description, + args_schema=args_schema, + ) + except ValueError: + # Already registered; ignore to keep idempotent + pass + + +async def executor_node(state: dict[str, Any], task: dict[str, Any]) -> dict[str, Any]: + """Execute a single task in a stateless manner. + + Selects the tool by `task["tool_name"]` and returns updated state with + `completed_tasks[task_id]`. Artifacts are not persisted in state at this layer. + """ + task_id = task.get("id") or "" + tool = task.get("tool_name") or "" + args = task.get("tool_args") or {} + + logger.info("Executor start: task_id={tid} tool={tool}", tid=task_id, tool=tool) + + # Idempotency guard: if this task is already completed, no-op + completed_snapshot = (state.get("completed_tasks") or {}).keys() + if task_id and task_id in completed_snapshot: + logger.info("Executor skip (already completed): task_id={tid}", tid=task_id) + return {} + await _emit_progress(5, "Starting") + + try: + runtime_args = {"state": state} + result = await registry.execute(tool, args, runtime_args=runtime_args) + exec_res = ExecutorResult(task_id=task_id, ok=True, result=result) + except Exception as exc: + logger.warning("Executor error: {err}", err=str(exc)) + exec_res = ExecutorResult( + task_id=task_id, ok=False, error=str(exc), error_code="ERR_EXEC" + ) + + await _emit_progress(95, "Finishing") + + # Return only the delta for completed_tasks to enable safe parallel merging + completed_delta = {task_id: exec_res.model_dump()} + + # Emit a task-done event so the LangGraph event stream clearly shows completion + try: + await adispatch_custom_event( + "agno_event", + {"type": "task_done", "task_id": task_id, "ok": exec_res.ok}, + ) + except Exception: + pass + + await _emit_progress(100, "Done") + return {"completed_tasks": completed_delta} + + +async def _emit_progress(percent: int, msg: str) -> None: + try: + payload = { + "type": "progress", + "payload": {"percent": percent, "msg": msg}, + "node": "executor", + } + await adispatch_custom_event("agno_event", payload) + except Exception: + # progress emission is non-critical + pass + + +async def _tool_market_data(symbols: list[str] | None = None) -> dict[str, Any]: + await _emit_progress(15, "Fetching market data") + symbols = symbols or ["AAPL", "MSFT", "GOOGL"] + # Placeholder: return mock stats; real integration later + data = {"symbols": symbols, "stats": {"count": len(symbols)}} + await _emit_progress(40, "Market data fetched") + return data + + +async def _tool_screen(risk: str | None = None) -> dict[str, Any]: + await _emit_progress(50, "Screening candidates") + risk = risk or "Medium" + table = ( + [{"symbol": "AAPL", "score": 0.8}, {"symbol": "MSFT", "score": 0.78}] + if risk != "High" + else [{"symbol": "TSLA", "score": 0.82}] + ) + await _emit_progress(70, "Screening done") + return {"risk": risk, "table": table} + + +async def _tool_backtest( + symbols: list[str] | None = None, horizon_days: int = 90 +) -> dict[str, Any]: + await _emit_progress(75, "Backtesting") + horizon = int(horizon_days or 90) + # Placeholder: simple buy-hold mock result + result = { + "symbols": symbols or [], + "horizon_days": horizon, + "return_pct": 5.2, + } + await _emit_progress(85, "Backtest done") + return result + + +async def _tool_summary(*, state: dict[str, Any]) -> dict[str, Any]: + await _emit_progress(88, "Summarizing") + completed = state.get("completed_tasks") or {} + summary = { + "tasks": list(completed.keys()), + "ok_count": sum(1 for v in completed.values() if v.get("ok")), + } + await _emit_progress(92, "Summary done") + return summary + + +ensure_default_tools_registered() diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py new file mode 100644 index 000000000..a48e9e2c4 --- /dev/null +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import Any + +from agno.agent import Agent +from agno.models.openrouter import OpenRouter +from loguru import logger + +from ..models import FinancialIntent, InquirerDecision + + +async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: + """Use LLM-driven structured output to extract financial intent from conversation. + + Inputs: state["messages"], state["inquirer_turns"]. + Outputs: updated state with user_profile (if COMPLETE) or follow-up question (if INCOMPLETE). + """ + messages = state.get("messages") or [] + turns = int(state.get("inquirer_turns") or 0) + + logger.info("Inquirer node start: turns={turns}", turns=turns) + + is_final_turn = turns >= 2 + + system_prompt = ( + "You are a professional Financial Advisor Assistant. " + "Your goal is to extract structured investment requirements from the conversation.\n\n" + "# REQUIRED INFORMATION:\n" + "1. **Asset/Target**: What does the user want to buy? (e.g., AAPL, Tech Stocks, Gold)\n" + "2. **Risk Tolerance**: Low, Medium, or High.\n\n" + "# INFERENCE RULES:\n" + "- If user says 'safe', 'stable', 'conservative': infer Risk='Low'.\n" + "- If user says 'growth', 'aggressive', 'dynamic': infer Risk='High'.\n" + "- If user says 'balanced': infer Risk='Medium'.\n" + "- Analyze conversation history to infer intent.\n\n" + f"# CURRENT STATUS:\n" + f"- Interaction Turn: {turns}\n" + f"- Max Turns Allowed: 2\n\n" + ) + + if not is_final_turn: + system_prompt += ( + "# INSTRUCTION:\n" + "If essential information (risk preference) is MISSING, set status='INCOMPLETE' " + "and ask a natural, concise follow-up question. " + "Do NOT assume or infer defaults yet." + ) + else: + system_prompt += ( + "# CRITICAL INSTRUCTION (MAX TURNS REACHED):\n" + "You have reached the maximum interaction limit. " + "Do NOT ask more questions. Instead: " + "Set status='COMPLETE' and infer reasonable defaults for any missing fields. " + "(e.g., Risk='Medium' if unspecified). " + "The response_to_user should confirm the inferred values." + ) + + # Build user message from conversation history + message_strs = [] + for m in messages: + if hasattr(m, "content"): + message_strs.append(m.content) + else: + message_strs.append(str(m)) + user_msg = "Conversation history:\n" + "\n".join(message_strs) + + try: + agent = Agent( + model=OpenRouter(id="google/gemini-2.5-flash"), + instructions=[system_prompt], + markdown=False, + output_schema=InquirerDecision, + debug_mode=True, + ) + response = await agent.arun(user_msg) + decision: InquirerDecision = response.content + + logger.info( + "Inquirer decision: status={s} reason={r}", + s=decision.status, + r=decision.reasoning, + ) + + if decision.status == "INCOMPLETE": + # Increment turns and ask the follow-up question + new_turns = turns + 1 + state["inquirer_turns"] = new_turns + state["user_profile"] = None + state["_inquirer_question"] = decision.response_to_user + state.setdefault("completed_tasks", {}) + return state + else: # COMPLETE + # Store profile and reset turns for next session + state["user_profile"] = decision.intent.model_dump() + state["inquirer_turns"] = 0 + logger.info( + "Inquirer completed: profile={p} reason={r}", + p=state["user_profile"], + r=decision.reasoning, + ) + return state + + except Exception as exc: + logger.warning("Inquirer LLM error: {err}", err=str(exc)) + # Graceful fallback: if error on final turn, default to Medium risk + if is_final_turn: + state["user_profile"] = FinancialIntent( + asset_symbols=None, risk="Medium" + ).model_dump() + state["inquirer_turns"] = 0 + logger.info("Inquirer fallback: defaulting to Medium risk due to LLM error") + return state + else: + # On error and not final turn, ask the user to repeat + state["inquirer_turns"] = turns + 1 + state["user_profile"] = None + state["_inquirer_question"] = ( + "I didn't quite understand. Could you tell me your risk preference (Low, Medium, or High)?" + ) + state.setdefault("completed_tasks", {}) + return state diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py new file mode 100644 index 000000000..14c6d4280 --- /dev/null +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, List + +from agno.agent import Agent +from agno.models.openrouter import OpenRouter +from loguru import logger +from pydantic import BaseModel, Field + +from ..models import FinancialIntent, Task +from ..tool_registry import registry + +ARG_VAL_TYPES = str | float | int + + +class PlannedTask(BaseModel): + id: str = Field(description="Unique task identifier, e.g., 't1'") + tool_id: str = Field(description="The EXACT tool_id from the available tool list") + tool_args: Dict[str, ARG_VAL_TYPES | list[ARG_VAL_TYPES]] = Field( + default_factory=dict, + description="The arguments to pass to the tool. " + "MUST strictly match the 'Arguments' list in the tool definition. " + "DO NOT leave empty if the tool requires parameters. " + "Example: {'symbol': 'AAPL', 'period': '1y'}", + ) + dependencies: List[str] = Field( + default_factory=list, + description="Task IDs that must complete BEFORE this task starts", + ) + description: str = Field(description="Short description for logs") + + +class ExecutionPlan(BaseModel): + logic: str = Field(description="Reasoning behind the plan") + tasks: List[PlannedTask] = Field(description="Directed acyclic graph of tasks") + + +async def planner_node(state: dict[str, Any]) -> dict[str, Any]: + profile_dict = state.get("user_profile") or {} + profile = ( + FinancialIntent.model_validate(profile_dict) + if profile_dict + else FinancialIntent() + ) + + logger.info("Planner start: profile={p}", p=profile.model_dump()) + + # Build prompt for Agno Agent planning + tool_context = registry.get_prompt_context() + system_prompt_text = ( + "You are a Financial Systems Architect. Break down the user's financial request into a specific execution plan.\n\n" + "Use ONLY these available tools:\n" + tool_context + "\n\n" + "Planning Rules:\n" + "1. Dependency Management:\n" + " - If a task needs data from a previous task, add the previous task's ID to dependencies.\n" + " - If tasks are independent, keep dependencies empty for parallel execution.\n" + "2. Argument Precision:\n" + " - Ensure tool_args strictly match in the tool list.\n" + " - Do not invent arguments.\n" + "3. Logical Flow:\n" + " - Typically: Fetch Data -> Analyze -> Summarize.\n" + "4. Output Constraint:\n" + " - tool_args must contain only literal values or values from the user profile.\n" + " - Do not reference other task outputs (e.g., 't2.output...') inside tool_args.\n" + " - Use dependencies to express ordering; do not encode dataflow by string placeholders.\n" + ) + + user_profile_json = json.dumps(profile.model_dump(), ensure_ascii=False) + user_msg = f"User Request Context: {user_profile_json}" + + planned_tasks = [] + plan_logic = "" + + # TODO: add retry with backoff + try: + agent = Agent( + model=OpenRouter(id="google/gemini-2.5-flash"), + instructions=[system_prompt_text], + markdown=False, + output_schema=ExecutionPlan, + debug_mode=True, + ) + response = await agent.arun(user_msg) + plan_obj: ExecutionPlan = response.content + + planned_tasks = plan_obj.tasks + plan_logic = plan_obj.logic + logger.info( + "Planner Agno produced {} tasks, reason: `{}`, all: {}", + len(planned_tasks), + plan_logic, + planned_tasks, + ) + except Exception as exc: + logger.warning("Planner Agno error: {err}", err=str(exc)) + # Do not fall back to a deterministic plan here; return an empty plan + # so higher-level orchestration can decide how to proceed. + planned_tasks = [] + plan_logic = "No plan produced due to Agno/LLM error." + + # Validate tool registration and convert to internal Task models + tasks: list[Task] = [] + available = {tool.tool_id for tool in registry.list_tools()} + for pt in planned_tasks: + if pt.tool_id not in available: + raise ValueError(f"Planner produced unknown tool_id: {pt.tool_id}") + + tasks.append( + Task( + id=pt.id, + tool_name=pt.tool_id, # our Task's tool_name equals registry tool_id + tool_args=pt.tool_args, + dependencies=pt.dependencies, + ) + ) + + _validate_plan(tasks) + + state["plan"] = [t.model_dump() for t in tasks] + state["plan_logic"] = plan_logic + logger.info("Planner completed: {n} tasks", n=len(tasks)) + return state + + +def _validate_plan(tasks: list[Task]) -> None: + ids = set() + for t in tasks: + if t.id in ids: + raise ValueError(f"Duplicate task id: {t.id}") + ids.add(t.id) + valid_ids = ids.copy() + for t in tasks: + for dep in t.dependencies: + if dep not in valid_ids: + raise ValueError(f"Missing dependency '{dep}' for task '{t.id}'") diff --git a/python/valuecell/agents/react_agent/nodes/scheduler.py b/python/valuecell/agents/react_agent/nodes/scheduler.py new file mode 100644 index 000000000..19d0d2692 --- /dev/null +++ b/python/valuecell/agents/react_agent/nodes/scheduler.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Any + +from loguru import logger + + +def scheduler_node(state: dict[str, Any]) -> dict[str, Any]: + """Compute runnable tasks and detect completion/deadlock. + + Returns state with `_runnable` list of task dicts, `_schedule_status`, and + marks newly runnable tasks as `_dispatched` to prevent duplicate scheduling. + """ + plan: list[dict] = state.get("plan") or [] + completed: dict = state.get("completed_tasks") or {} + dispatched: dict = state.get("_dispatched") or {} + + logger.info( + "Scheduler start: plan={p_len} completed={c_len} dispatched={d_len}", + p_len=len(plan), + c_len=len(completed), + d_len=len(dispatched), + ) + + remaining = [t for t in plan if t.get("id") not in completed] + if not remaining: + state["_schedule_status"] = "complete" + logger.info("Scheduler: plan complete") + return state + + runnable: list[dict] = [] + completed_ids = set(completed.keys()) + dispatched_ids = set(dispatched.keys()) + + for t in remaining: + task_id = t.get("id") + # Skip if already dispatched (prevents duplicate scheduling) + if task_id in dispatched_ids: + continue + deps = set(t.get("dependencies") or []) + if deps.issubset(completed_ids): + runnable.append(t) + + if runnable: + # Mark newly runnable tasks as dispatched + new_dispatched = {t.get("id"): True for t in runnable if t.get("id")} + state["_runnable"] = runnable + state["_schedule_status"] = "runnable" + state["_dispatched"] = {**dispatched, **new_dispatched} + logger.info("Scheduler: {n} tasks runnable (newly dispatched)", n=len(runnable)) + return state + + # Deadlock or waiting: remaining tasks but none runnable (all may be dispatched) + if any(t.get("id") in dispatched_ids for t in remaining): + # Some tasks are dispatched but not yet completed - wait + state["_schedule_status"] = "waiting" + logger.info("Scheduler: waiting for dispatched tasks to complete") + else: + # True deadlock: tasks exist but can't be dispatched + state["_schedule_status"] = "deadlock" + state["_deadlock_reason"] = "No tasks runnable; unmet dependencies" + logger.warning("Scheduler: deadlock detected") + return state diff --git a/python/valuecell/agents/react_agent/state.py b/python/valuecell/agents/react_agent/state.py new file mode 100644 index 000000000..89243c94c --- /dev/null +++ b/python/valuecell/agents/react_agent/state.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from operator import ior +from typing import Any, Annotated, TypedDict + + +class AgentState(TypedDict, total=False): + # Conversation and intent + messages: list[Any] + user_profile: dict[str, Any] | None + inquirer_turns: int + + # Planning + plan: list[dict[str, Any]] | None + plan_logic: str | None + + # Execution results (merged across parallel executors) + completed_tasks: Annotated[dict[str, Any], ior] + + # Track dispatched tasks to prevent duplicate scheduling (merged across routing passes) + _dispatched: Annotated[dict[str, bool], ior] + + # Scheduler internal fields + _schedule_status: str | None + _runnable: list[dict[str, Any]] | None + + # Critic decision + next_action: Any | None + _critic_summary: Any | None + + # Misc + missing_info_field: str | None + user_supplement_info: Any | None diff --git a/python/valuecell/agents/react_agent/tool_registry.py b/python/valuecell/agents/react_agent/tool_registry.py new file mode 100644 index 000000000..354ad1f58 --- /dev/null +++ b/python/valuecell/agents/react_agent/tool_registry.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import inspect +from collections.abc import Callable +from typing import Any, Optional, Type + +from loguru import logger +from pydantic import BaseModel, create_model + + +CallableType = Callable[..., Any] + + +class ToolDefinition(BaseModel): + """Describe a callable tool with metadata for planning and execution.""" + + tool_id: str + name: str + description: str + args_schema: Type[BaseModel] | None + func: CallableType + is_agent: bool = False + + class Config: + arbitrary_types_allowed = True + + +class ToolRegistry: + """Registry that keeps tool metadata and offers unified execution.""" + + def __init__(self) -> None: + self._registry: dict[str, ToolDefinition] = {} + + def register( + self, + tool_id: str, + func: CallableType, + description: str, + *, + args_schema: Type[BaseModel] | None = None, + name: Optional[str] = None, + is_agent: bool = False, + ) -> None: + """Register a callable tool with optional schema reflection.""" + if tool_id in self._registry: + raise ValueError(f"Tool '{tool_id}' already registered") + + schema = args_schema or self._infer_schema(func) + tool_name = name or tool_id.replace("_", " ").title() + definition = ToolDefinition( + tool_id=tool_id, + name=tool_name, + description=description, + args_schema=schema, + func=func, + is_agent=is_agent, + ) + self._registry[tool_id] = definition + logger.info("Tool registered: {tool_id}", tool_id=tool_id) + + def get_tool(self, tool_id: str) -> ToolDefinition: + """Return the tool definition or raise if missing.""" + try: + return self._registry[tool_id] + except KeyError as exc: + raise ValueError(f"Tool '{tool_id}' not found") from exc + + async def execute( + self, + tool_id: str, + tool_args: dict[str, Any] | None = None, + *, + runtime_args: dict[str, Any] | None = None, + ) -> Any: + """Execute a registered tool with validated arguments.""" + tool_def = self.get_tool(tool_id) + args = tool_args or {} + params = self._validate_args(tool_def.args_schema, args) + if runtime_args: + params.update(self._filter_runtime_args(tool_def.func, runtime_args)) + + result = await self._call(tool_def.func, params) + return result + + def list_tools(self) -> list[ToolDefinition]: + """Return registered tools sorted by identifier.""" + return [self._registry[k] for k in sorted(self._registry.keys())] + + def get_prompt_context(self) -> str: + """Generate a planner-friendly summary of available tools.""" + lines: list[str] = ["Available Tools:"] + + def _json_type_to_py(t: Any, prop: dict) -> str: + # Map JSON schema types to concise Python-like types + if isinstance(t, list): + # e.g., ["string","null"] -> Optional[str] + non_null = [x for x in t if x != "null"] + if len(non_null) == 1: + return f"Optional[{_json_type_to_py(non_null[0], prop)}]" + return "Any" + if t == "string": + return "str" + if t == "integer": + return "int" + if t == "number": + return "float" + if t == "boolean": + return "bool" + if t == "object": + return "dict" + if t == "array": + items = prop.get("items") or {} + item_type = items.get("type") + if not item_type and items.get("anyOf"): + # try first anyOf type + ao = items.get("anyOf")[0] + item_type = ao.get("type") + py_item = _json_type_to_py(item_type or "any", items) + return f"List[{py_item}]" + if t == "null": + return "None" + return "Any" + + for tool in self.list_tools(): + # Tool header + lines.append(f"- {tool.tool_id}: {tool.description}") + + # Build concise signature from args_schema when available + if tool.args_schema: + try: + schema = tool.args_schema.model_json_schema() + props = schema.get("properties", {}) + except Exception: + props = {} + + if not props: + lines.append(" Arguments: ()") + continue + + parts: list[str] = [] + for name, prop in props.items(): + ptype = prop.get("type") + # handle 'anyOf' at property level (e.g., [string, null]) + if not ptype and prop.get("anyOf"): + # pick first non-null + types = [p.get("type") for p in prop.get("anyOf")] + ptype = types if types else None + + py_type = _json_type_to_py(ptype or "any", prop) + default = prop.get("default") + if default is not None: + # represent strings with quotes, others as-is + if isinstance(default, str): + parts.append(f"{name}: {py_type} = '{default}'") + else: + parts.append(f"{name}: {py_type} = {default}") + else: + parts.append(f"{name}: {py_type}") + + sig = ", ".join(parts) + lines.append(f" Arguments: ({sig})") + else: + lines.append(" Arguments: ()") + + return "\n".join(lines) + + @staticmethod + def _validate_args( + schema: Type[BaseModel] | None, args: dict[str, Any] + ) -> dict[str, Any]: + if schema is None: + return dict(args) + validated = schema(**args) + return validated.model_dump() + + @staticmethod + def _filter_runtime_args(func: CallableType, runtime_args: dict[str, Any]) -> dict[str, Any]: + try: + signature = inspect.signature(func) + except (TypeError, ValueError): + return dict(runtime_args) + + accepted: dict[str, Any] = {} + for key, value in runtime_args.items(): + if key in signature.parameters: + accepted[key] = value + return accepted + + @staticmethod + async def _call(func: CallableType, params: dict[str, Any]) -> Any: + try: + result = func(**params) + except TypeError: + if params: + result = func(params) + else: + result = func() + return await ToolRegistry._maybe_await(result) + + @staticmethod + async def _maybe_await(value: Any) -> Any: + if inspect.isawaitable(value): + return await value + return value + + @staticmethod + def _infer_schema(func: CallableType) -> Type[BaseModel] | None: + try: + signature = inspect.signature(func) + except (TypeError, ValueError): + return None + + fields: dict[str, tuple[type[Any], Any]] = {} + for name, param in signature.parameters.items(): + if name in {"self", "cls"}: + continue + if param.kind in {inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD}: + return None + annotation = ( + param.annotation + if param.annotation is not inspect.Signature.empty + else Any + ) + default = ( + param.default + if param.default is not inspect.Signature.empty + else ... + ) + fields[name] = (annotation, default) + + if not fields: + return None + + model_name = f"{func.__name__.capitalize()}Args" + return create_model(model_name, **fields) + + +registry = ToolRegistry() + +__all__ = ["ToolDefinition", "ToolRegistry", "registry"] From ae3f8735bbed0398c3d529472ec4e1544d6f3c54 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:16:23 +0800 Subject: [PATCH 02/50] refactor: simplify tool registration by removing redundant argument schemas --- .../agents/react_agent/nodes/executor.py | 58 +++---------------- .../agents/react_agent/tool_registry.py | 8 ++- 2 files changed, 14 insertions(+), 52 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index 5d56581cc..195e2ae38 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -4,36 +4,12 @@ from langchain_core.callbacks import adispatch_custom_event from loguru import logger -from pydantic import BaseModel, Field +from pydantic import BaseModel from ..models import ExecutorResult from ..tool_registry import registry -class MarketDataArgs(BaseModel): - symbols: list[str] = Field( - default_factory=list, description="Ticker symbols to fetch" - ) - - -class ScreenArgs(BaseModel): - risk: str | None = Field( - default=None, description="Risk preference: Low, Medium, or High" - ) - - -class BacktestArgs(BaseModel): - symbols: list[str] = Field( - default_factory=list, description="Ticker symbols to backtest" - ) - horizon_days: int = Field(default=90, description="Lookback window in days") - - -class SummaryArgs(BaseModel): - class Config: - extra = "forbid" - - _TOOLS_REGISTERED = False @@ -42,30 +18,10 @@ def ensure_default_tools_registered() -> None: if _TOOLS_REGISTERED: return - _register_tool( - "market_data", - _tool_market_data, - "Fetch market statistics for a list of symbols.", - args_schema=MarketDataArgs, - ) - _register_tool( - "screen", - _tool_screen, - "Screen candidate symbols based on risk preference.", - args_schema=ScreenArgs, - ) - _register_tool( - "backtest", - _tool_backtest, - "Run a simple backtest for provided symbols.", - args_schema=BacktestArgs, - ) - _register_tool( - "summary", - _tool_summary, - "Summarize completed task outcomes.", - args_schema=SummaryArgs, - ) + _register_tool("market_data", _tool_market_data) + _register_tool("screen", _tool_screen) + _register_tool("backtest", _tool_backtest) + _register_tool("summary", _tool_summary) _TOOLS_REGISTERED = True @@ -73,9 +29,9 @@ def ensure_default_tools_registered() -> None: def _register_tool( tool_id: str, func: Callable[..., Any], - description: str, + description: str | None = None, *, - args_schema: type[BaseModel], + args_schema: type[BaseModel] | None = None, ) -> None: try: registry.register( diff --git a/python/valuecell/agents/react_agent/tool_registry.py b/python/valuecell/agents/react_agent/tool_registry.py index 354ad1f58..0fec3497b 100644 --- a/python/valuecell/agents/react_agent/tool_registry.py +++ b/python/valuecell/agents/react_agent/tool_registry.py @@ -35,7 +35,7 @@ def register( self, tool_id: str, func: CallableType, - description: str, + description: str | None = None, *, args_schema: Type[BaseModel] | None = None, name: Optional[str] = None, @@ -44,6 +44,12 @@ def register( """Register a callable tool with optional schema reflection.""" if tool_id in self._registry: raise ValueError(f"Tool '{tool_id}' already registered") + # Infer description from function docstring if missing + if description is None: + if getattr(func, "__doc__", None): + description = func.__doc__.strip().split("\n")[0] + else: + description = f"Execute tool {tool_id}." schema = args_schema or self._infer_schema(func) tool_name = name or tool_id.replace("_", " ").title() From 5c1bfad4eecad3b0b3a1782ebde51182a17b7f52 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:05:40 +0800 Subject: [PATCH 03/50] refactor(executor): remove unused summary tool registration and update executor_node signature --- .../agents/react_agent/nodes/executor.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index 195e2ae38..3f118c16d 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any, Callable +from ..state import AgentState from langchain_core.callbacks import adispatch_custom_event from loguru import logger @@ -21,7 +22,6 @@ def ensure_default_tools_registered() -> None: _register_tool("market_data", _tool_market_data) _register_tool("screen", _tool_screen) _register_tool("backtest", _tool_backtest) - _register_tool("summary", _tool_summary) _TOOLS_REGISTERED = True @@ -45,7 +45,7 @@ def _register_tool( pass -async def executor_node(state: dict[str, Any], task: dict[str, Any]) -> dict[str, Any]: +async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, Any]: """Execute a single task in a stateless manner. Selects the tool by `task["tool_name"]` and returns updated state with @@ -141,15 +141,4 @@ async def _tool_backtest( return result -async def _tool_summary(*, state: dict[str, Any]) -> dict[str, Any]: - await _emit_progress(88, "Summarizing") - completed = state.get("completed_tasks") or {} - summary = { - "tasks": list(completed.keys()), - "ok_count": sum(1 for v in completed.values() if v.get("ok")), - } - await _emit_progress(92, "Summary done") - return summary - - ensure_default_tools_registered() From b21bdb9e3f9a15e250b033025babcc172170e3a6 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:05:47 +0800 Subject: [PATCH 04/50] refactor(graph): update type hints for state parameters to use AgentState --- python/valuecell/agents/react_agent/graph.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/valuecell/agents/react_agent/graph.py b/python/valuecell/agents/react_agent/graph.py index eee185b57..c1367139a 100644 --- a/python/valuecell/agents/react_agent/graph.py +++ b/python/valuecell/agents/react_agent/graph.py @@ -11,7 +11,7 @@ from .state import AgentState -def _route_after_scheduler(state: dict[str, Any]): +def _route_after_scheduler(state: AgentState): """Route after scheduler node based on _schedule_status. - Returns list[Send("executor", {"task": t})] when runnable tasks exist. @@ -38,7 +38,7 @@ def _route_after_scheduler(state: dict[str, Any]): return "critic" -async def _executor_entry(state: dict[str, Any]) -> dict[str, Any]: +async def _executor_entry(state: AgentState) -> dict[str, Any]: """Entry adapter for executor: expects a `task` injected via Send().""" task = state.get("task") or {} return await executor_node(state, task) @@ -65,7 +65,7 @@ def build_app() -> Any: graph.add_edge(START, "inquirer") - def _route_after_inquirer(st: dict[str, Any]) -> str: + def _route_after_inquirer(st: AgentState) -> str: return "plan" if st.get("user_profile") else "wait" graph.add_conditional_edges( @@ -81,7 +81,7 @@ def _route_after_inquirer(st: dict[str, Any]) -> str: # After scheduler node, route based on status graph.add_conditional_edges("scheduler", _route_after_scheduler, {"critic": "critic"}) - def _route_after_critic(st: dict[str, Any]) -> str: + def _route_after_critic(st: AgentState) -> str: na = st.get("next_action") val = getattr(na, "value", na) v = str(val).lower() if val is not None else "exit" From 4c393ba60360c78cd08d05c6de94866aaa370792 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:05:59 +0800 Subject: [PATCH 05/50] refactor(react_agent): update tool_name type to str and add asset_symbols validator --- python/valuecell/agents/react_agent/models.py | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py index 29ec4b92f..88deb9ada 100644 --- a/python/valuecell/agents/react_agent/models.py +++ b/python/valuecell/agents/react_agent/models.py @@ -2,12 +2,12 @@ from typing import Any, Literal, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class Task(BaseModel): id: str - tool_name: Literal["market_data", "screen", "backtest", "summary"] + tool_name: str tool_args: dict[str, Any] = Field(default_factory=dict) dependencies: list[str] = Field(default_factory=list) @@ -16,6 +16,26 @@ class FinancialIntent(BaseModel): asset_symbols: Optional[list[str]] = None risk: Optional[Literal["Low", "Medium", "High"]] = None + @field_validator("asset_symbols", mode="before") + def _coerce_asset_symbols(cls, v): + """Allow a single string symbol to be provided and coerce to list[str]. + + Examples: + - "AAPL" -> ["AAPL"] + - ["AAPL", "MSFT"] -> ["AAPL", "MSFT"] + - None -> None + """ + if v is None: + return None + # If a single string provided, wrap it + if isinstance(v, str): + return [v] + # If tuple, convert to list + if isinstance(v, tuple): + return list(v) + # Otherwise assume it's already an iterable/list-like + return v + class ExecutorResult(BaseModel): task_id: str @@ -27,7 +47,10 @@ class ExecutorResult(BaseModel): class InquirerDecision(BaseModel): """The decision output from the LLM-driven Inquirer Agent.""" - intent: FinancialIntent = Field(description="Extracted financial intent from conversation") + + intent: FinancialIntent = Field( + description="Extracted financial intent from conversation" + ) status: Literal["COMPLETE", "INCOMPLETE"] = Field( description="Set to COMPLETE if essential info (risk) is present OR if max turns reached." ) From 9cd7f36f7ffb6e597a5ad0094beb2a9251c75ba1 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:25:44 +0800 Subject: [PATCH 06/50] refactor(react_agent): enhance planning and execution flow with iterative feedback integration --- python/valuecell/agents/react_agent/graph.py | 56 ++++---- python/valuecell/agents/react_agent/models.py | 34 ++++- .../agents/react_agent/nodes/critic.py | 111 ++++++++-------- .../agents/react_agent/nodes/executor.py | 43 ++++++- .../agents/react_agent/nodes/planner.py | 120 ++++++++---------- python/valuecell/agents/react_agent/state.py | 22 ++-- .../agents/react_agent/tool_registry.py | 14 +- 7 files changed, 229 insertions(+), 171 deletions(-) diff --git a/python/valuecell/agents/react_agent/graph.py b/python/valuecell/agents/react_agent/graph.py index c1367139a..26430e81f 100644 --- a/python/valuecell/agents/react_agent/graph.py +++ b/python/valuecell/agents/react_agent/graph.py @@ -1,22 +1,21 @@ from __future__ import annotations -from typing import Any import uuid +from typing import Any from .nodes.critic import critic_node from .nodes.executor import executor_node from .nodes.inquirer import inquirer_node from .nodes.planner import planner_node -from .nodes.scheduler import scheduler_node from .state import AgentState -def _route_after_scheduler(state: AgentState): - """Route after scheduler node based on _schedule_status. +def _route_after_planner(state: AgentState): + """Route after planner based on is_final flag. - - Returns list[Send("executor", {"task": t})] when runnable tasks exist. - - Returns "critic" when plan is complete or deadlocked. - - Returns END when waiting for dispatched tasks. + - If is_final=True: Route to critic for verification. + - If plan has tasks: Route to executor via Send. + - Otherwise: Route to critic as safety fallback. """ try: from langgraph.types import Send # type: ignore @@ -25,16 +24,18 @@ def _route_after_scheduler(state: AgentState): "LangGraph is required for the orchestrator. Install 'langgraph'." ) from exc - status = state.get("_schedule_status") - if status == "runnable": - runnable = state.get("_runnable") or [] - if runnable: - return [Send("executor", {"task": t}) for t in runnable] - if status == "waiting": - # Tasks are dispatched but not completed; return empty to no-op - return [] - if status in {"complete", "deadlock"}: + is_final = state.get("is_final", False) + plan = state.get("plan") or [] + + # If planner claims done, verify with critic + if is_final: return "critic" + + # If planner produced tasks, execute them in parallel + if plan: + return [Send("executor", {"task": t}) for t in plan] + + # Safety fallback: no tasks and not final -> go to critic return "critic" @@ -59,7 +60,6 @@ def build_app() -> Any: graph.add_node("inquirer", inquirer_node) graph.add_node("planner", planner_node) - graph.add_node("scheduler", scheduler_node) graph.add_node("executor", _executor_entry) graph.add_node("critic", critic_node) @@ -71,26 +71,20 @@ def _route_after_inquirer(st: AgentState) -> str: graph.add_conditional_edges( "inquirer", _route_after_inquirer, {"plan": "planner", "wait": END} ) - - # After planning, go to scheduler - graph.add_edge("planner", "scheduler") - - # After executor completion, go back to scheduler to check for next wave - graph.add_edge("executor", "scheduler") - - # After scheduler node, route based on status - graph.add_conditional_edges("scheduler", _route_after_scheduler, {"critic": "critic"}) + + # After planning, route based on is_final and plan content + graph.add_conditional_edges("planner", _route_after_planner, {"critic": "critic"}) + + # After executor completion, go back to planner for next iteration + graph.add_edge("executor", "planner") def _route_after_critic(st: AgentState) -> str: na = st.get("next_action") val = getattr(na, "value", na) v = str(val).lower() if val is not None else "exit" if v == "replan": - # Clear plan/schedule to allow fresh planning cycle - st.pop("plan", None) - st.pop("_schedule_status", None) - st.pop("_runnable", None) - st.pop("_dispatched", None) # Clear dispatch tracking for fresh plan + # Clear is_final flag to allow fresh planning cycle + st["is_final"] = False return "replan" return "end" diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py index 88deb9ada..207d8c9dc 100644 --- a/python/valuecell/agents/react_agent/models.py +++ b/python/valuecell/agents/react_agent/models.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Literal, Optional +from typing import Any, Dict, Literal, Optional from pydantic import BaseModel, Field, field_validator @@ -9,7 +9,6 @@ class Task(BaseModel): id: str tool_name: str tool_args: dict[str, Any] = Field(default_factory=dict) - dependencies: list[str] = Field(default_factory=list) class FinancialIntent(BaseModel): @@ -45,6 +44,37 @@ class ExecutorResult(BaseModel): error_code: Optional[str] = None # e.g., ERR_NETWORK, ERR_INPUT +ARG_VAL_TYPES = str | int | float | bool + + +class PlannedTask(BaseModel): + id: str = Field(description="Unique task identifier, e.g., 't1'") + tool_id: str = Field(description="The EXACT tool_id from the available tool list") + tool_args: Dict[str, ARG_VAL_TYPES | list[ARG_VAL_TYPES]] = Field( + default_factory=dict, + description="The arguments to pass to the tool. " + "MUST strictly match the 'Arguments' list in the tool definition. " + "DO NOT leave empty if the tool requires parameters. " + "Example: {'symbol': 'AAPL', 'period': '1y'}", + ) + description: str = Field(description="Short description for logs") + + +class ExecutionPlan(BaseModel): + """Output from the Planner for iterative batch planning.""" + + tasks: list[PlannedTask] = Field( + description="List of tasks to execute concurrently in this batch." + ) + strategy_update: str = Field( + description="Brief reasoning about what has been done and what is left." + ) + is_final: bool = Field( + default=False, + description="Set to True ONLY if the user's goal is fully satisfied.", + ) + + class InquirerDecision(BaseModel): """The decision output from the LLM-driven Inquirer Agent.""" diff --git a/python/valuecell/agents/react_agent/nodes/critic.py b/python/valuecell/agents/react_agent/nodes/critic.py index e2d4f4b4b..13366a01b 100644 --- a/python/valuecell/agents/react_agent/nodes/critic.py +++ b/python/valuecell/agents/react_agent/nodes/critic.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import Any import json +from enum import Enum +from typing import Any from agno.agent import Agent from agno.models.openrouter import OpenRouter from loguru import logger from pydantic import BaseModel, Field -from enum import Enum class NextAction(str, Enum): @@ -16,45 +16,54 @@ class NextAction(str, Enum): class CriticDecision(BaseModel): - next_action: NextAction = Field(description="Either 'exit' or 'replan'") + """Gatekeeper decision for iterative batch planning.""" + + approved: bool = Field( + description="True if goal is fully satisfied, False otherwise" + ) reason: str = Field(description="Short rationale for the decision") + feedback: str | None = Field( + default=None, + description="Specific feedback for Planner if rejected (what is missing)", + ) async def critic_node(state: dict[str, Any]) -> dict[str, Any]: - """Use an Agno agent to decide whether to exit or trigger replanning.""" - completed = state.get("completed_tasks") or {} - - # Prepare concise context for the agent - ok = {tid: res.get("result") for tid, res in completed.items() if res.get("ok")} - errors = { - tid: {"error": res.get("error"), "code": res.get("error_code")} - for tid, res in completed.items() - if not res.get("ok") - } + """Gatekeeper: Verify if user's goal is fully satisfied. - context = { - "plan_logic": state.get("plan_logic"), - "plan": state.get("plan"), - "ok_results": ok, - "errors": errors, - } + Only runs when Planner sets is_final=True. + - If approved: Return next_action="exit" + - If rejected: Return critique_feedback and next_action="replan" + """ + user_profile = state.get("user_profile") or {} + execution_history = state.get("execution_history") or [] + is_final = state.get("is_final", False) - # If nothing ran, default to replan to let planner try again - if not completed: - logger.warning("Critic: no completed tasks; defaulting to replan") - state["_critic_summary"] = {"status": "empty"} - state["next_action"] = "replan" - state["next_action_reason"] = ( - "No tasks executed; require planner to produce a plan." - ) - return state + # Safety check: Critic should only run when planner claims done + if not is_final: + logger.warning("Critic invoked but is_final=False; defaulting to replan") + return { + "next_action": NextAction.REPLAN, + "critique_feedback": "Planner has not completed the workflow.", + } + + history_text = "\n".join(execution_history) if execution_history else "(Empty)" system_prompt = ( - "You are a critical reviewer for an execution graph in a financial agent. " - "Review the completed tasks and errors. Decide whether the current results " - "are sufficient to exit, or whether the planner should continue with additional " - "planning to achieve the user's goal. Respond strictly in JSON per the schema." + "You are a gatekeeper critic for an iterative financial planning system.\n\n" + "**Your Role**: Compare the User's Request with the Execution History.\n" + "- If the goal is fully satisfied, approve (approved=True).\n" + "- If something is missing or incomplete, reject (approved=False) and provide specific feedback.\n\n" + "**Decision Criteria**:\n" + "1. All requested tasks completed successfully.\n" + "2. No critical errors that prevent goal satisfaction.\n" + "3. Results align with user's intent.\n" ) + + context = { + "user_request": user_profile, + "execution_history": history_text, + } user_msg = json.dumps(context, ensure_ascii=False) try: @@ -67,24 +76,26 @@ async def critic_node(state: dict[str, Any]) -> dict[str, Any]: ) response = await agent.arun(user_msg) decision: CriticDecision = response.content - state["_critic_summary"] = { - "ok_count": len(ok), - "error_count": len(errors), - "errors": errors, - } - action = decision.next_action - state["next_action"] = action - state["next_action_reason"] = decision.reason - logger.info("Critic decided: {a} - {r}", a=action.value, r=decision.reason) - return state + + if decision.approved: + logger.info("Critic APPROVED: {r}", r=decision.reason) + return { + "next_action": NextAction.EXIT, + "_critic_summary": {"approved": True, "reason": decision.reason}, + } + else: + logger.info("Critic REJECTED: {r}", r=decision.reason) + return { + "next_action": NextAction.REPLAN, + "critique_feedback": decision.feedback or decision.reason, + "is_final": False, # Reset is_final to allow re-planning + "_critic_summary": {"approved": False, "reason": decision.reason}, + } except Exception as exc: logger.warning("Critic agent error: {err}", err=str(exc)) - # On error, default to replan to allow recovery - state["_critic_summary"] = { - "ok_count": len(ok), - "error_count": len(errors), - "errors": errors, + # On error, default to replan for safety + return { + "next_action": NextAction.REPLAN, + "critique_feedback": f"Critic error: {str(exc)[:100]}", + "is_final": False, } - state["next_action"] = NextAction.REPLAN - state["next_action_reason"] = "Critic error; safe default is to replan." - return state diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index 3f118c16d..ce8b56887 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -1,16 +1,15 @@ from __future__ import annotations from typing import Any, Callable -from ..state import AgentState from langchain_core.callbacks import adispatch_custom_event from loguru import logger from pydantic import BaseModel from ..models import ExecutorResult +from ..state import AgentState from ..tool_registry import registry - _TOOLS_REGISTERED = False @@ -46,10 +45,11 @@ def _register_tool( async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, Any]: - """Execute a single task in a stateless manner. + """Execute a single task and return execution summary for history. - Selects the tool by `task["tool_name"]` and returns updated state with - `completed_tasks[task_id]`. Artifacts are not persisted in state at this layer. + Returns: + - completed_tasks: {task_id: ExecutorResult} + - execution_history: [concise summary string] """ task_id = task.get("id") or "" tool = task.get("tool_name") or "" @@ -68,15 +68,19 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An runtime_args = {"state": state} result = await registry.execute(tool, args, runtime_args=runtime_args) exec_res = ExecutorResult(task_id=task_id, ok=True, result=result) + + # Generate concise summary for execution history + summary = _generate_summary(task_id, tool, args, result) except Exception as exc: logger.warning("Executor error: {err}", err=str(exc)) exec_res = ExecutorResult( task_id=task_id, ok=False, error=str(exc), error_code="ERR_EXEC" ) + summary = f"Task {task_id} ({tool}) failed: {str(exc)[:50]}" await _emit_progress(95, "Finishing") - # Return only the delta for completed_tasks to enable safe parallel merging + # Return delta for completed_tasks and execution_history completed_delta = {task_id: exec_res.model_dump()} # Emit a task-done event so the LangGraph event stream clearly shows completion @@ -89,7 +93,10 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An pass await _emit_progress(100, "Done") - return {"completed_tasks": completed_delta} + return { + "completed_tasks": completed_delta, + "execution_history": [summary], + } async def _emit_progress(percent: int, msg: str) -> None: @@ -105,6 +112,28 @@ async def _emit_progress(percent: int, msg: str) -> None: pass +def _generate_summary(task_id: str, tool: str, args: dict, result: Any) -> str: + """Generate a concise summary for execution_history (token-efficient). + + Example: "Task t1 (market_data): Fetched 3 symbols" + """ + # Extract key info based on tool type + if tool == "market_data": + symbols = args.get("symbols") or result.get("symbols", []) + return f"Task {task_id} (market_data): Fetched {len(symbols)} symbols" + elif tool == "screen": + risk = args.get("risk") or result.get("risk", "Unknown") + count = len(result.get("table", [])) if isinstance(result, dict) else 0 + return f"Task {task_id} (screen): Risk={risk}, {count} candidates" + elif tool == "backtest": + symbols = args.get("symbols") or result.get("symbols", []) + ret_pct = result.get("return_pct", 0) if isinstance(result, dict) else 0 + return f"Task {task_id} (backtest): {len(symbols)} symbols, return={ret_pct}%" + else: + # Generic fallback + return f"Task {task_id} ({tool}): completed" + + async def _tool_market_data(symbols: list[str] | None = None) -> dict[str, Any]: await _emit_progress(15, "Fetching market data") symbols = symbols or ["AAPL", "MSFT", "GOOGL"] diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index 14c6d4280..3bfffab6d 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -1,42 +1,22 @@ from __future__ import annotations import json -from typing import Any, Dict, List +from typing import Any from agno.agent import Agent from agno.models.openrouter import OpenRouter from loguru import logger -from pydantic import BaseModel, Field -from ..models import FinancialIntent, Task +from ..models import ExecutionPlan, FinancialIntent, PlannedTask, Task from ..tool_registry import registry -ARG_VAL_TYPES = str | float | int - - -class PlannedTask(BaseModel): - id: str = Field(description="Unique task identifier, e.g., 't1'") - tool_id: str = Field(description="The EXACT tool_id from the available tool list") - tool_args: Dict[str, ARG_VAL_TYPES | list[ARG_VAL_TYPES]] = Field( - default_factory=dict, - description="The arguments to pass to the tool. " - "MUST strictly match the 'Arguments' list in the tool definition. " - "DO NOT leave empty if the tool requires parameters. " - "Example: {'symbol': 'AAPL', 'period': '1y'}", - ) - dependencies: List[str] = Field( - default_factory=list, - description="Task IDs that must complete BEFORE this task starts", - ) - description: str = Field(description="Short description for logs") - - -class ExecutionPlan(BaseModel): - logic: str = Field(description="Reasoning behind the plan") - tasks: List[PlannedTask] = Field(description="Directed acyclic graph of tasks") - async def planner_node(state: dict[str, Any]) -> dict[str, Any]: + """Iterative batch planner: generates the IMMEDIATE next batch of tasks. + + Looks at execution_history to understand what has been done, + and critique_feedback to fix any issues from previous iteration. + """ profile_dict = state.get("user_profile") or {} profile = ( FinancialIntent.model_validate(profile_dict) @@ -44,35 +24,46 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: else FinancialIntent() ) - logger.info("Planner start: profile={p}", p=profile.model_dump()) + execution_history = state.get("execution_history") or [] + critique_feedback = state.get("critique_feedback") - # Build prompt for Agno Agent planning + logger.info( + "Planner start: profile={p}, history_len={h}", + p=profile.model_dump(), + h=len(execution_history), + ) + + # Build iterative planning prompt tool_context = registry.get_prompt_context() + + history_text = ( + "\n".join(execution_history) if execution_history else "(No history yet)" + ) + feedback_text = ( + f"\n\n**Critic Feedback**: {critique_feedback}" if critique_feedback else "" + ) + system_prompt_text = ( - "You are a Financial Systems Architect. Break down the user's financial request into a specific execution plan.\n\n" - "Use ONLY these available tools:\n" + tool_context + "\n\n" - "Planning Rules:\n" - "1. Dependency Management:\n" - " - If a task needs data from a previous task, add the previous task's ID to dependencies.\n" - " - If tasks are independent, keep dependencies empty for parallel execution.\n" - "2. Argument Precision:\n" - " - Ensure tool_args strictly match in the tool list.\n" - " - Do not invent arguments.\n" - "3. Logical Flow:\n" - " - Typically: Fetch Data -> Analyze -> Summarize.\n" - "4. Output Constraint:\n" - " - tool_args must contain only literal values or values from the user profile.\n" - " - Do not reference other task outputs (e.g., 't2.output...') inside tool_args.\n" - " - Use dependencies to express ordering; do not encode dataflow by string placeholders.\n" + "You are an iterative financial planning agent.\n\n" + "**Your Role**: Look at the Execution History below and decide the **IMMEDIATE next batch** of tasks.\n\n" + f"**Available Tools**:\n{tool_context}\n\n" + "**Planning Rules**:\n" + "1. **Iterative Planning**: Plan only the next step(s), not the entire workflow.\n" + "2. **Context Awareness**: Read the Execution History carefully. Don't repeat completed work.\n" + "3. **Concrete Arguments**: tool_args must contain only literal values (no placeholders like '$t1.output').\n" + "4. **Parallel Execution**: Tasks in the same batch run concurrently.\n" + "5. **Completion Signal**: If the goal is fully satisfied, return `tasks=[]` and `is_final=True`.\n" + "6. **Critique Integration**: If Critic Feedback is present, address the issues mentioned.\n\n" + f"**Execution History**:\n{history_text}{feedback_text}\n" ) user_profile_json = json.dumps(profile.model_dump(), ensure_ascii=False) user_msg = f"User Request Context: {user_profile_json}" - planned_tasks = [] - plan_logic = "" + is_final = False + strategy_update = "" + planned_tasks: list[PlannedTask] = [] - # TODO: add retry with backoff try: agent = Agent( model=OpenRouter(id="google/gemini-2.5-flash"), @@ -85,19 +76,20 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: plan_obj: ExecutionPlan = response.content planned_tasks = plan_obj.tasks - plan_logic = plan_obj.logic + strategy_update = plan_obj.strategy_update + is_final = plan_obj.is_final + logger.info( - "Planner Agno produced {} tasks, reason: `{}`, all: {}", + "Planner produced {} tasks, is_final={}, strategy: {}", len(planned_tasks), - plan_logic, - planned_tasks, + is_final, + strategy_update, ) except Exception as exc: logger.warning("Planner Agno error: {err}", err=str(exc)) - # Do not fall back to a deterministic plan here; return an empty plan - # so higher-level orchestration can decide how to proceed. planned_tasks = [] - plan_logic = "No plan produced due to Agno/LLM error." + strategy_update = "No plan produced due to Agno/LLM error." + is_final = False # Validate tool registration and convert to internal Task models tasks: list[Task] = [] @@ -109,28 +101,26 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: tasks.append( Task( id=pt.id, - tool_name=pt.tool_id, # our Task's tool_name equals registry tool_id + tool_name=pt.tool_id, tool_args=pt.tool_args, - dependencies=pt.dependencies, ) ) _validate_plan(tasks) - state["plan"] = [t.model_dump() for t in tasks] - state["plan_logic"] = plan_logic - logger.info("Planner completed: {n} tasks", n=len(tasks)) - return state + # Clear critique_feedback after consuming it + return { + "plan": [t.model_dump() for t in tasks], + "plan_logic": strategy_update, # For backwards compatibility + "is_final": is_final, + "critique_feedback": None, # Clear after consuming + } def _validate_plan(tasks: list[Task]) -> None: + """Basic validation: check for duplicate task IDs.""" ids = set() for t in tasks: if t.id in ids: raise ValueError(f"Duplicate task id: {t.id}") ids.add(t.id) - valid_ids = ids.copy() - for t in tasks: - for dep in t.dependencies: - if dep not in valid_ids: - raise ValueError(f"Missing dependency '{dep}' for task '{t.id}'") diff --git a/python/valuecell/agents/react_agent/state.py b/python/valuecell/agents/react_agent/state.py index 89243c94c..11b9240b5 100644 --- a/python/valuecell/agents/react_agent/state.py +++ b/python/valuecell/agents/react_agent/state.py @@ -1,7 +1,7 @@ from __future__ import annotations from operator import ior -from typing import Any, Annotated, TypedDict +from typing import Annotated, Any, TypedDict class AgentState(TypedDict, total=False): @@ -10,19 +10,21 @@ class AgentState(TypedDict, total=False): user_profile: dict[str, Any] | None inquirer_turns: int - # Planning - plan: list[dict[str, Any]] | None - plan_logic: str | None + # Planning (iterative batch planning) + plan: list[dict[str, Any]] | None # Current batch of tasks + plan_logic: str | None # Deprecated: replaced by strategy_update # Execution results (merged across parallel executors) completed_tasks: Annotated[dict[str, Any], ior] - - # Track dispatched tasks to prevent duplicate scheduling (merged across routing passes) - _dispatched: Annotated[dict[str, bool], ior] - # Scheduler internal fields - _schedule_status: str | None - _runnable: list[dict[str, Any]] | None + # Iterative planning: growing list of execution summaries + execution_history: Annotated[list[str], list.__add__] + + # Feedback from Critic to guide next planning iteration + critique_feedback: str | None + + # Flag to signal Planner believes goal is complete + is_final: bool # Critic decision next_action: Any | None diff --git a/python/valuecell/agents/react_agent/tool_registry.py b/python/valuecell/agents/react_agent/tool_registry.py index 0fec3497b..495670c6a 100644 --- a/python/valuecell/agents/react_agent/tool_registry.py +++ b/python/valuecell/agents/react_agent/tool_registry.py @@ -7,7 +7,6 @@ from loguru import logger from pydantic import BaseModel, create_model - CallableType = Callable[..., Any] @@ -180,7 +179,9 @@ def _validate_args( return validated.model_dump() @staticmethod - def _filter_runtime_args(func: CallableType, runtime_args: dict[str, Any]) -> dict[str, Any]: + def _filter_runtime_args( + func: CallableType, runtime_args: dict[str, Any] + ) -> dict[str, Any]: try: signature = inspect.signature(func) except (TypeError, ValueError): @@ -220,7 +221,10 @@ def _infer_schema(func: CallableType) -> Type[BaseModel] | None: for name, param in signature.parameters.items(): if name in {"self", "cls"}: continue - if param.kind in {inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD}: + if param.kind in { + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + }: return None annotation = ( param.annotation @@ -228,9 +232,7 @@ def _infer_schema(func: CallableType) -> Type[BaseModel] | None: else Any ) default = ( - param.default - if param.default is not inspect.Signature.empty - else ... + param.default if param.default is not inspect.Signature.empty else ... ) fields[name] = (annotation, default) From 801a842b86d1e6ab60ed60197d5f2503b0f4e1a3 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:41:29 +0800 Subject: [PATCH 07/50] refactor(scheduler): remove redundant scheduler_node function --- .../agents/react_agent/nodes/scheduler.py | 63 ------------------- 1 file changed, 63 deletions(-) delete mode 100644 python/valuecell/agents/react_agent/nodes/scheduler.py diff --git a/python/valuecell/agents/react_agent/nodes/scheduler.py b/python/valuecell/agents/react_agent/nodes/scheduler.py deleted file mode 100644 index 19d0d2692..000000000 --- a/python/valuecell/agents/react_agent/nodes/scheduler.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from loguru import logger - - -def scheduler_node(state: dict[str, Any]) -> dict[str, Any]: - """Compute runnable tasks and detect completion/deadlock. - - Returns state with `_runnable` list of task dicts, `_schedule_status`, and - marks newly runnable tasks as `_dispatched` to prevent duplicate scheduling. - """ - plan: list[dict] = state.get("plan") or [] - completed: dict = state.get("completed_tasks") or {} - dispatched: dict = state.get("_dispatched") or {} - - logger.info( - "Scheduler start: plan={p_len} completed={c_len} dispatched={d_len}", - p_len=len(plan), - c_len=len(completed), - d_len=len(dispatched), - ) - - remaining = [t for t in plan if t.get("id") not in completed] - if not remaining: - state["_schedule_status"] = "complete" - logger.info("Scheduler: plan complete") - return state - - runnable: list[dict] = [] - completed_ids = set(completed.keys()) - dispatched_ids = set(dispatched.keys()) - - for t in remaining: - task_id = t.get("id") - # Skip if already dispatched (prevents duplicate scheduling) - if task_id in dispatched_ids: - continue - deps = set(t.get("dependencies") or []) - if deps.issubset(completed_ids): - runnable.append(t) - - if runnable: - # Mark newly runnable tasks as dispatched - new_dispatched = {t.get("id"): True for t in runnable if t.get("id")} - state["_runnable"] = runnable - state["_schedule_status"] = "runnable" - state["_dispatched"] = {**dispatched, **new_dispatched} - logger.info("Scheduler: {n} tasks runnable (newly dispatched)", n=len(runnable)) - return state - - # Deadlock or waiting: remaining tasks but none runnable (all may be dispatched) - if any(t.get("id") in dispatched_ids for t in remaining): - # Some tasks are dispatched but not yet completed - wait - state["_schedule_status"] = "waiting" - logger.info("Scheduler: waiting for dispatched tasks to complete") - else: - # True deadlock: tasks exist but can't be dispatched - state["_schedule_status"] = "deadlock" - state["_deadlock_reason"] = "No tasks runnable; unmet dependencies" - logger.warning("Scheduler: deadlock detected") - return state From e076d387a144d6f63d5a0c9ec0bdd06e1f93aea7 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:52:14 +0800 Subject: [PATCH 08/50] refactor(executor): register new research tools and remove unused tool functions --- .../agents/react_agent/nodes/executor.py | 52 +++------- .../agents/react_agent/tools/__init__.py | 0 .../agents/react_agent/tools/research.py | 98 +++++++++++++++++++ 3 files changed, 111 insertions(+), 39 deletions(-) create mode 100644 python/valuecell/agents/react_agent/tools/__init__.py create mode 100644 python/valuecell/agents/react_agent/tools/research.py diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index ce8b56887..7c654da55 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -10,6 +10,14 @@ from ..state import AgentState from ..tool_registry import registry +from ..tools.research import ( + research, + search_crypto_people, + search_crypto_projects, + search_crypto_vcs, + web_search, +) + _TOOLS_REGISTERED = False @@ -18,9 +26,11 @@ def ensure_default_tools_registered() -> None: if _TOOLS_REGISTERED: return - _register_tool("market_data", _tool_market_data) - _register_tool("screen", _tool_screen) - _register_tool("backtest", _tool_backtest) + _register_tool("research", research) + _register_tool("search_crypto_people", search_crypto_people) + _register_tool("search_crypto_projects", search_crypto_projects) + _register_tool("search_crypto_vcs", search_crypto_vcs) + _register_tool("web_search", web_search) _TOOLS_REGISTERED = True @@ -134,40 +144,4 @@ def _generate_summary(task_id: str, tool: str, args: dict, result: Any) -> str: return f"Task {task_id} ({tool}): completed" -async def _tool_market_data(symbols: list[str] | None = None) -> dict[str, Any]: - await _emit_progress(15, "Fetching market data") - symbols = symbols or ["AAPL", "MSFT", "GOOGL"] - # Placeholder: return mock stats; real integration later - data = {"symbols": symbols, "stats": {"count": len(symbols)}} - await _emit_progress(40, "Market data fetched") - return data - - -async def _tool_screen(risk: str | None = None) -> dict[str, Any]: - await _emit_progress(50, "Screening candidates") - risk = risk or "Medium" - table = ( - [{"symbol": "AAPL", "score": 0.8}, {"symbol": "MSFT", "score": 0.78}] - if risk != "High" - else [{"symbol": "TSLA", "score": 0.82}] - ) - await _emit_progress(70, "Screening done") - return {"risk": risk, "table": table} - - -async def _tool_backtest( - symbols: list[str] | None = None, horizon_days: int = 90 -) -> dict[str, Any]: - await _emit_progress(75, "Backtesting") - horizon = int(horizon_days or 90) - # Placeholder: simple buy-hold mock result - result = { - "symbols": symbols or [], - "horizon_days": horizon, - "return_pct": 5.2, - } - await _emit_progress(85, "Backtest done") - return result - - ensure_default_tools_registered() diff --git a/python/valuecell/agents/react_agent/tools/__init__.py b/python/valuecell/agents/react_agent/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/react_agent/tools/research.py b/python/valuecell/agents/react_agent/tools/research.py new file mode 100644 index 000000000..ea6583d95 --- /dev/null +++ b/python/valuecell/agents/react_agent/tools/research.py @@ -0,0 +1,98 @@ +import os + +from agno.agent import Agent +from agno.db.in_memory import InMemoryDb +from edgar import set_identity +from loguru import logger + +from valuecell.agents.research_agent.prompts import ( + KNOWLEDGE_AGENT_EXPECTED_OUTPUT, + KNOWLEDGE_AGENT_INSTRUCTION, +) +from valuecell.agents.research_agent.sources import ( + fetch_ashare_filings, + fetch_event_sec_filings, + fetch_periodic_sec_filings, + search_crypto_people, + search_crypto_projects, + search_crypto_vcs, + web_search, +) +from valuecell.utils.env import agent_debug_mode_enabled + +research_agent: None | Agent = None + + +def build_research_agent() -> Agent: + import valuecell.utils.model as model_utils_mod + from valuecell.agents.research_agent.knowledge import get_knowledge + + tools = [ + fetch_periodic_sec_filings, + fetch_event_sec_filings, + fetch_ashare_filings, + web_search, + search_crypto_projects, + search_crypto_vcs, + search_crypto_people, + ] + # Configure EDGAR identity only when SEC_EMAIL is present + sec_email = os.getenv("SEC_EMAIL") + if sec_email: + set_identity(sec_email) + else: + logger.warning( + "SEC_EMAIL not set; EDGAR identity is not configured for ResearchAgent." + ) + + # Lazily obtain knowledge; disable search if unavailable + knowledge = get_knowledge() + return Agent( + model=model_utils_mod.get_model_for_agent("research_agent"), + instructions=[KNOWLEDGE_AGENT_INSTRUCTION], + expected_output=KNOWLEDGE_AGENT_EXPECTED_OUTPUT, + tools=tools, + knowledge=knowledge, + db=InMemoryDb(), + # context + search_knowledge=knowledge is not None, + add_datetime_to_context=True, + # configuration + debug_mode=agent_debug_mode_enabled(), + ) + + +def get_research_agent() -> Agent: + """Lazily create and cache the ResearchAgent instance.""" + global research_agent + if research_agent is None: + research_agent = build_research_agent() + return research_agent + + +async def research(query: str) -> str: + """ + Perform asynchronous research using the cached ResearchAgent. + + The ResearchAgent is configured with a set of research tools + (SEC/ASHARE filings fetchers, web search, and crypto-related search + functions), an optional knowledge source, and an in-memory DB for + short-lived context. The agent may call multiple tools internally and + composes their outputs into a single human-readable string. + + The returned value is the agent's aggregated textual answer. Callers + should treat the response as plain text suitable for display or further + downstream natural-language processing. + + :param query: The natural-language research query or prompt to submit to + the ResearchAgent (for example, "Summarize recent SEC filings for + AAPL"). + :type query: str + :return: A string containing the agent's aggregated research result. + :rtype: str + :raises RuntimeError: If the underlying agent fails or returns an + unexpected error while executing the query. + """ + agent = get_research_agent() + result = await agent.arun(query) + return result.content From 8bf1b479d628887bceae03b68720985c0df4038e Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:52:20 +0800 Subject: [PATCH 09/50] refactor(critic): enhance decision criteria with synthesis phase guidance --- python/valuecell/agents/react_agent/nodes/critic.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/valuecell/agents/react_agent/nodes/critic.py b/python/valuecell/agents/react_agent/nodes/critic.py index 13366a01b..82402fa4a 100644 --- a/python/valuecell/agents/react_agent/nodes/critic.py +++ b/python/valuecell/agents/react_agent/nodes/critic.py @@ -58,6 +58,9 @@ async def critic_node(state: dict[str, Any]) -> dict[str, Any]: "1. All requested tasks completed successfully.\n" "2. No critical errors that prevent goal satisfaction.\n" "3. Results align with user's intent.\n" + "4. **Synthesis Phase**: If sufficient research/data-gathering tasks are complete to answer the user's request, " + "APPROVE the plan. The system will synthesize the final response from the execution history. " + "Do NOT demand an explicit 'generate_report' or 'create_plan' task when the necessary data is already available.\n" ) context = { From f3dd60dbe3733235a0a30d4d8af48cc945d61364 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:16:24 +0800 Subject: [PATCH 10/50] refactor(react_agent): add summarizer node for generating final response --- python/valuecell/agents/react_agent/graph.py | 10 +- .../agents/react_agent/nodes/summarizer.py | 152 ++++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 python/valuecell/agents/react_agent/nodes/summarizer.py diff --git a/python/valuecell/agents/react_agent/graph.py b/python/valuecell/agents/react_agent/graph.py index 26430e81f..64fcf08f4 100644 --- a/python/valuecell/agents/react_agent/graph.py +++ b/python/valuecell/agents/react_agent/graph.py @@ -7,6 +7,7 @@ from .nodes.executor import executor_node from .nodes.inquirer import inquirer_node from .nodes.planner import planner_node +from .nodes.summarizer import summarizer_node from .state import AgentState @@ -62,6 +63,7 @@ def build_app() -> Any: graph.add_node("planner", planner_node) graph.add_node("executor", _executor_entry) graph.add_node("critic", critic_node) + graph.add_node("summarizer", summarizer_node) graph.add_edge(START, "inquirer") @@ -86,12 +88,16 @@ def _route_after_critic(st: AgentState) -> str: # Clear is_final flag to allow fresh planning cycle st["is_final"] = False return "replan" - return "end" + # Critic approved: route to summarizer for final report + return "summarize" graph.add_conditional_edges( - "critic", _route_after_critic, {"replan": "planner", "end": END} + "critic", _route_after_critic, {"replan": "planner", "summarize": "summarizer"} ) + # Summarizer generates final report, then END + graph.add_edge("summarizer", END) + memory = MemorySaver() app = graph.compile(checkpointer=memory) return app diff --git a/python/valuecell/agents/react_agent/nodes/summarizer.py b/python/valuecell/agents/react_agent/nodes/summarizer.py new file mode 100644 index 000000000..24b90081b --- /dev/null +++ b/python/valuecell/agents/react_agent/nodes/summarizer.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import json +from typing import Any + +from agno.agent import Agent +from agno.models.openrouter import OpenRouter +from langchain_core.messages import AIMessage +from loguru import logger + +from ..state import AgentState + + +async def summarizer_node(state: AgentState) -> dict[str, Any]: + """ + Generate a polished final report from raw execution history. + + This node is the final step in the workflow. It transforms the technical + execution log into a user-friendly financial analysis report suitable for + beginner investors. + + Takes: + - user_profile: Original user request + - execution_history: Raw task completion summaries + - completed_tasks: Actual data results + + Returns: + - messages: Final AIMessage with formatted report + - is_final: True (confirm completion) + """ + user_profile = state.get("user_profile") or {} + execution_history = state.get("execution_history") or [] + completed_tasks = state.get("completed_tasks") or {} + + logger.info( + "Summarizer start: history_len={h}, tasks={t}", + h=len(execution_history), + t=len(completed_tasks), + ) + + # Build context for the summarizer + # Extract key data points from completed_tasks for evidence + data_summary = _extract_key_results(completed_tasks) + + system_prompt = ( + "You are a concise Financial Assistant for beginner investors.\n" + "Your goal is to synthesize the execution results into a short, actionable insight card.\n\n" + "**User Request**:\n" + f"{json.dumps(user_profile, ensure_ascii=False)}\n\n" + "**Key Data**:\n" + f"{data_summary}\n\n" + "**Strict Constraints**:\n" + "1. **Length Limit**: Keep the total response under 400 words. Be ruthless with cutting fluff.\n" + "2. **No Generic Intros**: DELETE sections like 'Company Overview' or 'What they do'.\n" + "3. **Focus on NOW**: Only mention historical data if it directly explains a current trend.\n\n" + "**Required Structure**:\n" + "(1-2 sentences direct answer to user's question)\n\n" + "## Key Findings\n" + "- **Metric 1**: Value (Interpretation)\n" + "- **Metric 2**: Value (Interpretation)\n" + "(Only include the top 3 most relevant numbers)\n\n" + "## Analysis\n" + "(One short paragraph explaining WHY. Use the 'Recent Developments' here)\n\n" + "## Risk Note\n" + "(One sentence on the specific risk found in the data, e.g., 'High volatility detected.')" + ) + + user_msg = "Please generate the final financial analysis report." + + try: + agent = Agent( + model=OpenRouter(id="google/gemini-2.5-flash"), + instructions=[system_prompt], + markdown=True, # Enable markdown in response + debug_mode=True, + ) + response = await agent.arun(user_msg) + report_content = response.content + + logger.info("Summarizer completed: report_len={l}", l=len(report_content)) + + # Return as AIMessage for conversation history + return { + "messages": [AIMessage(content=report_content)], + "is_final": True, + "_summarizer_complete": True, + } + except Exception as exc: + logger.exception("Summarizer error: {err}", err=str(exc)) + # Fallback: return a basic summary + fallback = ( + "## Analysis Complete\n\n" + f"Completed {len(completed_tasks)} tasks based on your request. " + "Please review the execution history for details." + ) + return { + "messages": [AIMessage(content=fallback)], + "is_final": True, + "_summarizer_error": str(exc), + } + + +def _extract_key_results(completed_tasks: dict[str, Any]) -> str: + """Extract and format key data points from completed tasks for LLM context. + + This reduces token usage by summarizing only the most important results + instead of dumping entire task outputs. + """ + if not completed_tasks: + return "(No results available)" + + lines = [] + for task_id, task_data in completed_tasks.items(): + if not isinstance(task_data, dict): + continue + + result = task_data.get("result") + if not result: + continue + + # Extract different types of results + if isinstance(result, dict): + # Market data + if "symbols" in result: + symbols = result.get("symbols", []) + lines.append( + f"- Task {task_id}: Market data for {len(symbols)} symbols" + ) + + # Screen results + if "table" in result: + count = len(result.get("table", [])) + risk = result.get("risk", "Unknown") + lines.append( + f"- Task {task_id}: Screened {count} candidates (Risk: {risk})" + ) + + # Backtest results + if "return_pct" in result: + ret = result.get("return_pct", 0) + sharpe = result.get("sharpe_ratio", 0) + lines.append( + f"- Task {task_id}: Backtest return={ret:.2f}%, Sharpe={sharpe:.2f}" + ) + + elif isinstance(result, str): + # Research/text results - truncate to avoid token overflow + # preview = result[:150] + "..." if len(result) > 150 else result + preview = result + lines.append(f"- Task {task_id}: {preview}") + + return "\n".join(lines) if lines else "(No extractable data)" From 36140d501ad399a48df41b7088db7ef015d4fa0f Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:44:01 +0800 Subject: [PATCH 11/50] refactor(inquirer): enhance decision logic and context handling for user interactions --- python/valuecell/agents/react_agent/models.py | 20 +- .../agents/react_agent/nodes/inquirer.py | 216 ++++++++++++------ 2 files changed, 157 insertions(+), 79 deletions(-) diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py index 207d8c9dc..603ad7059 100644 --- a/python/valuecell/agents/react_agent/models.py +++ b/python/valuecell/agents/react_agent/models.py @@ -76,15 +76,21 @@ class ExecutionPlan(BaseModel): class InquirerDecision(BaseModel): - """The decision output from the LLM-driven Inquirer Agent.""" + """The decision output from the LLM-driven Inquirer Agent with context-switching.""" - intent: FinancialIntent = Field( - description="Extracted financial intent from conversation" + intent: FinancialIntent | None = Field( + default=None, description="Extracted financial intent from conversation" ) - status: Literal["COMPLETE", "INCOMPLETE"] = Field( - description="Set to COMPLETE if essential info (risk) is present OR if max turns reached." + status: Literal["COMPLETE", "INCOMPLETE", "CHAT"] = Field( + description="COMPLETE: Ready for planning. INCOMPLETE: Need more info. CHAT: Casual conversation/follow-up." ) reasoning: str = Field(description="Brief thought process explaining the decision") - response_to_user: str = Field( - description="If INCOMPLETE: A follow-up question. If COMPLETE: A confirmation message." + response_to_user: str | None = Field( + default=None, + description="Direct response to user (for INCOMPLETE questions or CHAT replies).", + ) + should_clear_history: bool = Field( + default=False, + description="True if user is starting a NEW task (e.g., changing stocks). " + "False if asking follow-up questions about existing analysis.", ) diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py index a48e9e2c4..438a166ce 100644 --- a/python/valuecell/agents/react_agent/nodes/inquirer.py +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -4,65 +4,100 @@ from agno.agent import Agent from agno.models.openrouter import OpenRouter +from langchain_core.messages import AIMessage, SystemMessage from loguru import logger from ..models import FinancialIntent, InquirerDecision +def _trim_messages(messages: list, max_messages: int = 10) -> list: + """Keep only the last N messages to prevent token overflow. + + Always preserves system messages. + """ + if len(messages) <= max_messages: + return messages + + # Separate system messages from others + system_msgs = [m for m in messages if isinstance(m, SystemMessage)] + other_msgs = [m for m in messages if not isinstance(m, SystemMessage)] + + # Keep last N-len(system_msgs) of other messages + trimmed_others = other_msgs[-(max_messages - len(system_msgs)) :] + + return system_msgs + trimmed_others + + async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: - """Use LLM-driven structured output to extract financial intent from conversation. + """Smart Inquirer: Extracts intent, detects context switches, handles follow-ups. + + Multi-turn conversation logic: + 1. **New Task**: User changes target (e.g., "Check MSFT") -> Clear history + 2. **Follow-up**: User asks about results (e.g., "Why risk high?") -> Keep history + 3. **Chat**: User casual talk (e.g., "Thanks") -> Direct response, no planning - Inputs: state["messages"], state["inquirer_turns"]. - Outputs: updated state with user_profile (if COMPLETE) or follow-up question (if INCOMPLETE). + Inputs: state["messages"], state["user_profile"], state["execution_history"]. + Outputs: Updated state with user_profile, history reset if needed, or chat response. """ messages = state.get("messages") or [] + current_profile = state.get("user_profile") + execution_history = state.get("execution_history") or [] turns = int(state.get("inquirer_turns") or 0) - logger.info("Inquirer node start: turns={turns}", turns=turns) + # Trim messages to prevent token overflow + trimmed_messages = _trim_messages(messages, max_messages=10) + + logger.info( + "Inquirer start: turns={t}, current_profile={p}, history_len={h}", + t=turns, + p=current_profile, + h=len(execution_history), + ) is_final_turn = turns >= 2 + # Build context-aware system prompt system_prompt = ( - "You are a professional Financial Advisor Assistant. " - "Your goal is to extract structured investment requirements from the conversation.\n\n" - "# REQUIRED INFORMATION:\n" - "1. **Asset/Target**: What does the user want to buy? (e.g., AAPL, Tech Stocks, Gold)\n" - "2. **Risk Tolerance**: Low, Medium, or High.\n\n" + "You are a Financial Advisor Assistant's State Manager.\n\n" + "# YOUR ROLE:\n" + "Analyze the user's latest message in the context of previous conversation and execution state.\n" + "Decide what to do with the agent's state.\n\n" + f"# CURRENT CONTEXT:\n" + f"- Known Profile: {current_profile or 'None (First interaction)'}\n" + f"- Completed Tasks: {len(execution_history)} execution steps\n" + f"- Interaction Turn: {turns} (Max: 2)\n\n" + "# DECISION LOGIC:\n" + "1. **CHAT**: If user is just chatting (e.g., 'Thanks', 'OK', casual reply), " + "set status='CHAT' and provide a polite response in `response_to_user`. Set intent=None.\n\n" + "2. **NEW TASK**: If user is starting a NEW analysis (e.g., 'Analyze MSFT', 'Switch to Gold'), " + "set status='COMPLETE', extract the new intent, and set `should_clear_history=True` to reset old data.\n\n" + "3. **FOLLOW-UP**: If user is asking about EXISTING results (e.g., 'Why is the return low?', 'Show more details'), " + "set status='COMPLETE', keep the current intent (or update if refined), and set `should_clear_history=False`.\n\n" + "4. **INCOMPLETE**: If essential info (risk preference) is MISSING on a NEW task, " + "set status='INCOMPLETE' and ask a follow-up question in `response_to_user`.\n\n" + "# REQUIRED INFO FOR COMPLETE:\n" + "- Asset/Target: What to analyze (stock, sector, etc.)\n" + "- Risk Tolerance: Low, Medium, or High (can infer from keywords)\n\n" "# INFERENCE RULES:\n" - "- If user says 'safe', 'stable', 'conservative': infer Risk='Low'.\n" - "- If user says 'growth', 'aggressive', 'dynamic': infer Risk='High'.\n" - "- If user says 'balanced': infer Risk='Medium'.\n" - "- Analyze conversation history to infer intent.\n\n" - f"# CURRENT STATUS:\n" - f"- Interaction Turn: {turns}\n" - f"- Max Turns Allowed: 2\n\n" + "- 'safe', 'stable', 'conservative' -> Risk='Low'\n" + "- 'growth', 'aggressive', 'dynamic' -> Risk='High'\n" + "- 'balanced', unspecified -> Risk='Medium'\n\n" ) - if not is_final_turn: - system_prompt += ( - "# INSTRUCTION:\n" - "If essential information (risk preference) is MISSING, set status='INCOMPLETE' " - "and ask a natural, concise follow-up question. " - "Do NOT assume or infer defaults yet." - ) - else: + if is_final_turn: system_prompt += ( - "# CRITICAL INSTRUCTION (MAX TURNS REACHED):\n" - "You have reached the maximum interaction limit. " - "Do NOT ask more questions. Instead: " - "Set status='COMPLETE' and infer reasonable defaults for any missing fields. " - "(e.g., Risk='Medium' if unspecified). " - "The response_to_user should confirm the inferred values." + "# CRITICAL: MAX TURNS REACHED\n" + "Do NOT set status='INCOMPLETE'. Infer reasonable defaults (Risk='Medium') and proceed.\n" ) # Build user message from conversation history message_strs = [] - for m in messages: - if hasattr(m, "content"): - message_strs.append(m.content) - else: - message_strs.append(str(m)) - user_msg = "Conversation history:\n" + "\n".join(message_strs) + for m in trimmed_messages: + role = getattr(m, "type", "unknown") + content = getattr(m, "content", str(m)) + message_strs.append(f"[{role}]: {content}") + + user_msg = "# Conversation History:\n" + "\n".join(message_strs) try: agent = Agent( @@ -76,46 +111,83 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: decision: InquirerDecision = response.content logger.info( - "Inquirer decision: status={s} reason={r}", + "Inquirer decision: status={s}, clear_history={c}, reason={r}", s=decision.status, + c=decision.should_clear_history, r=decision.reasoning, ) - if decision.status == "INCOMPLETE": - # Increment turns and ask the follow-up question - new_turns = turns + 1 - state["inquirer_turns"] = new_turns - state["user_profile"] = None - state["_inquirer_question"] = decision.response_to_user - state.setdefault("completed_tasks", {}) - return state - else: # COMPLETE - # Store profile and reset turns for next session - state["user_profile"] = decision.intent.model_dump() - state["inquirer_turns"] = 0 - logger.info( - "Inquirer completed: profile={p} reason={r}", - p=state["user_profile"], - r=decision.reasoning, - ) - return state + # --- State Update Logic (Core Context Switching) --- + + updates: dict[str, Any] = {} + + # CASE 1: CHAT - Direct response, no planning + if decision.status == "CHAT": + updates["messages"] = [ + AIMessage(content=decision.response_to_user or "Understood.") + ] + updates["user_profile"] = None # Signal to route to END + updates["inquirer_turns"] = 0 + return updates + + # CASE 2: INCOMPLETE - Ask follow-up question + if decision.status == "INCOMPLETE" and not is_final_turn: + updates["inquirer_turns"] = turns + 1 + updates["user_profile"] = None # Signal to route to END (wait for user) + updates["messages"] = [ + AIMessage( + content=decision.response_to_user + or "Could you tell me your risk preference (Low, Medium, High)?" + ) + ] + return updates + + # CASE 3: COMPLETE - Ready for planning + # Update profile (use new intent or keep current) + if decision.intent: + updates["user_profile"] = decision.intent.model_dump() + elif current_profile: + # Follow-up without changing intent: keep existing profile + updates["user_profile"] = current_profile + else: + # Fallback: no intent provided, default to Medium risk + updates["user_profile"] = FinancialIntent(risk="Medium").model_dump() + + # Context Switch: Clear history if starting new task + if decision.should_clear_history: + logger.info("Inquirer: Clearing history for NEW TASK") + updates["plan"] = [] + updates["completed_tasks"] = {} + updates["execution_history"] = [] + updates["is_final"] = False + updates["critique_feedback"] = None + updates["messages"] = [ + SystemMessage(content="User started a new task. Previous context cleared.") + ] + # Otherwise, keep history intact for follow-up questions + + updates["inquirer_turns"] = 0 # Reset turn counter after completion + return updates except Exception as exc: - logger.warning("Inquirer LLM error: {err}", err=str(exc)) - # Graceful fallback: if error on final turn, default to Medium risk - if is_final_turn: - state["user_profile"] = FinancialIntent( - asset_symbols=None, risk="Medium" - ).model_dump() - state["inquirer_turns"] = 0 - logger.info("Inquirer fallback: defaulting to Medium risk due to LLM error") - return state + logger.exception("Inquirer LLM error: {err}", err=str(exc)) + + # Graceful fallback + if is_final_turn or current_profile: + # If we have a profile, assume user wants to continue + return { + "user_profile": current_profile + or FinancialIntent(risk="Medium").model_dump(), + "inquirer_turns": 0, + } else: - # On error and not final turn, ask the user to repeat - state["inquirer_turns"] = turns + 1 - state["user_profile"] = None - state["_inquirer_question"] = ( - "I didn't quite understand. Could you tell me your risk preference (Low, Medium, or High)?" - ) - state.setdefault("completed_tasks", {}) - return state + # Ask user to retry + return { + "inquirer_turns": turns + 1, + "user_profile": None, + "messages": [ + AIMessage( + content="I didn't quite understand. Could you tell me what you'd like to analyze?" + ) + ], + } From c4c1256e49ee1233e6b195b8e5e68e0b8189e293 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:46:23 +0800 Subject: [PATCH 12/50] refactor(executor): simplify summary generation with result preview for execution history --- .../agents/react_agent/nodes/executor.py | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index 7c654da55..78d91cac5 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -123,25 +123,10 @@ async def _emit_progress(percent: int, msg: str) -> None: def _generate_summary(task_id: str, tool: str, args: dict, result: Any) -> str: - """Generate a concise summary for execution_history (token-efficient). - - Example: "Task t1 (market_data): Fetched 3 symbols" - """ - # Extract key info based on tool type - if tool == "market_data": - symbols = args.get("symbols") or result.get("symbols", []) - return f"Task {task_id} (market_data): Fetched {len(symbols)} symbols" - elif tool == "screen": - risk = args.get("risk") or result.get("risk", "Unknown") - count = len(result.get("table", [])) if isinstance(result, dict) else 0 - return f"Task {task_id} (screen): Risk={risk}, {count} candidates" - elif tool == "backtest": - symbols = args.get("symbols") or result.get("symbols", []) - ret_pct = result.get("return_pct", 0) if isinstance(result, dict) else 0 - return f"Task {task_id} (backtest): {len(symbols)} symbols, return={ret_pct}%" - else: - # Generic fallback - return f"Task {task_id} ({tool}): completed" + result_preview = str(result) + if len(result_preview) > 100: + result_preview = result_preview[:100] + "..." + return f"Task {task_id} ({tool} with args {args}): completed. Result preview: {result_preview}" ensure_default_tools_registered() From 19d0ced2e21a00b75fe854fe6d922026994f9987 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:46:36 +0800 Subject: [PATCH 13/50] refactor(inquirer, planner, state): introduce focus_topic for enhanced user query handling --- python/valuecell/agents/react_agent/models.py | 5 ++++ .../agents/react_agent/nodes/inquirer.py | 17 +++++++++-- .../agents/react_agent/nodes/planner.py | 29 ++++++++++++++----- python/valuecell/agents/react_agent/state.py | 1 + 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py index 603ad7059..e00a4ffc9 100644 --- a/python/valuecell/agents/react_agent/models.py +++ b/python/valuecell/agents/react_agent/models.py @@ -94,3 +94,8 @@ class InquirerDecision(BaseModel): description="True if user is starting a NEW task (e.g., changing stocks). " "False if asking follow-up questions about existing analysis.", ) + focus_topic: str | None = Field( + default=None, + description="Specific sub-topic or question the user is asking about (e.g., 'iPhone 17 sales forecasts', 'dividend history'). " + "Extract this for FOLLOW-UP questions to guide the Planner.", + ) diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py index 438a166ce..ea5f5291b 100644 --- a/python/valuecell/agents/react_agent/nodes/inquirer.py +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -71,8 +71,10 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: "set status='CHAT' and provide a polite response in `response_to_user`. Set intent=None.\n\n" "2. **NEW TASK**: If user is starting a NEW analysis (e.g., 'Analyze MSFT', 'Switch to Gold'), " "set status='COMPLETE', extract the new intent, and set `should_clear_history=True` to reset old data.\n\n" - "3. **FOLLOW-UP**: If user is asking about EXISTING results (e.g., 'Why is the return low?', 'Show more details'), " - "set status='COMPLETE', keep the current intent (or update if refined), and set `should_clear_history=False`.\n\n" + "3. **FOLLOW-UP**: If user is asking about EXISTING results (e.g., 'Why is the return low?', 'Tell me about iPhone 17 sales'), " + "set status='COMPLETE', keep the current intent (or update if refined), set `should_clear_history=False`, " + "and **CRITICALLY**: extract the specific `focus_topic` the user is asking about (e.g., 'iPhone 17 sales forecasts', 'dividend policy', 'ESG rating'). " + "This helps the Planner determine if new research is needed for that specific topic.\n\n" "4. **INCOMPLETE**: If essential info (risk preference) is MISSING on a NEW task, " "set status='INCOMPLETE' and ask a follow-up question in `response_to_user`.\n\n" "# REQUIRED INFO FOR COMPLETE:\n" @@ -161,10 +163,19 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: updates["execution_history"] = [] updates["is_final"] = False updates["critique_feedback"] = None + updates["focus_topic"] = None # Clear old focus updates["messages"] = [ SystemMessage(content="User started a new task. Previous context cleared.") ] - # Otherwise, keep history intact for follow-up questions + else: + # Follow-up: Keep history but reset is_final and set focus_topic + # This forces Planner to re-evaluate whether new data is needed + logger.info( + "Inquirer: FOLLOW-UP detected, focus_topic={topic}", + topic=decision.focus_topic, + ) + updates["is_final"] = False # Force replanning + updates["focus_topic"] = decision.focus_topic or None updates["inquirer_turns"] = 0 # Reset turn counter after completion return updates diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index 3bfffab6d..6743fd922 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -15,7 +15,8 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: """Iterative batch planner: generates the IMMEDIATE next batch of tasks. Looks at execution_history to understand what has been done, - and critique_feedback to fix any issues from previous iteration. + critique_feedback to fix any issues from previous iteration, + and focus_topic to prioritize specific user questions. """ profile_dict = state.get("user_profile") or {} profile = ( @@ -26,11 +27,13 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: execution_history = state.get("execution_history") or [] critique_feedback = state.get("critique_feedback") + focus_topic = state.get("focus_topic") logger.info( - "Planner start: profile={p}, history_len={h}", + "Planner start: profile={p}, history_len={h}, focus={f}", p=profile.model_dump(), h=len(execution_history), + f=focus_topic or "General", ) # Build iterative planning prompt @@ -42,6 +45,13 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: feedback_text = ( f"\n\n**Critic Feedback**: {critique_feedback}" if critique_feedback else "" ) + focus_text = ( + f"\n\n**Current Focus Topic**: {focus_topic}\n" + f"(User is specifically asking about this. Verify if the Execution History already covers this specific topic. " + f"If the history only has general data but NOT this specific topic, you MUST generate new research tasks.)" + if focus_topic + else "" + ) system_prompt_text = ( "You are an iterative financial planning agent.\n\n" @@ -50,11 +60,13 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: "**Planning Rules**:\n" "1. **Iterative Planning**: Plan only the next step(s), not the entire workflow.\n" "2. **Context Awareness**: Read the Execution History carefully. Don't repeat completed work.\n" - "3. **Concrete Arguments**: tool_args must contain only literal values (no placeholders like '$t1.output').\n" - "4. **Parallel Execution**: Tasks in the same batch run concurrently.\n" - "5. **Completion Signal**: If the goal is fully satisfied, return `tasks=[]` and `is_final=True`.\n" - "6. **Critique Integration**: If Critic Feedback is present, address the issues mentioned.\n\n" - f"**Execution History**:\n{history_text}{feedback_text}\n" + "3. **Focus Topic Priority**: If a `Current Focus Topic` is specified, CHECK if the Execution History contains data specifically about that topic.\n" + "4. **Concrete Arguments**: tool_args must contain only literal values (no placeholders like '$t1.output').\n" + "5. **Parallel Execution**: Tasks in the same batch run concurrently.\n" + "6. **Completion Signal**: If the goal is fully satisfied AND the Focus Topic (if any) is addressed, " + "return `tasks=[]` and `is_final=True`.\n" + "7. **Critique Integration**: If Critic Feedback is present, address the issues mentioned.\n\n" + f"**Execution History**:\n{history_text}{feedback_text}{focus_text}\n" ) user_profile_json = json.dumps(profile.model_dump(), ensure_ascii=False) @@ -108,12 +120,13 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: _validate_plan(tasks) - # Clear critique_feedback after consuming it + # Clear critique_feedback and focus_topic after consuming them return { "plan": [t.model_dump() for t in tasks], "plan_logic": strategy_update, # For backwards compatibility "is_final": is_final, "critique_feedback": None, # Clear after consuming + "focus_topic": None, # Clear after consuming } diff --git a/python/valuecell/agents/react_agent/state.py b/python/valuecell/agents/react_agent/state.py index 11b9240b5..0aea53bb0 100644 --- a/python/valuecell/agents/react_agent/state.py +++ b/python/valuecell/agents/react_agent/state.py @@ -9,6 +9,7 @@ class AgentState(TypedDict, total=False): messages: list[Any] user_profile: dict[str, Any] | None inquirer_turns: int + focus_topic: str | None # Specific user question/topic for current turn (e.g., 'iPhone 17 sales') # Planning (iterative batch planning) plan: list[dict[str, Any]] | None # Current batch of tasks From d9d29bd65323ede64e20f60aaf9f4c567d6a1eb4 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:06:51 +0800 Subject: [PATCH 14/50] refactor(inquirer, planner, state): remove focus_topic and enhance state management for intent deltas --- python/valuecell/agents/react_agent/models.py | 21 +- .../agents/react_agent/nodes/executor.py | 1 - .../agents/react_agent/nodes/inquirer.py | 199 +++++++++++++----- .../agents/react_agent/nodes/planner.py | 26 +-- python/valuecell/agents/react_agent/state.py | 1 - 5 files changed, 159 insertions(+), 89 deletions(-) diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py index e00a4ffc9..d1ce7f34b 100644 --- a/python/valuecell/agents/react_agent/models.py +++ b/python/valuecell/agents/react_agent/models.py @@ -13,7 +13,6 @@ class Task(BaseModel): class FinancialIntent(BaseModel): asset_symbols: Optional[list[str]] = None - risk: Optional[Literal["Low", "Medium", "High"]] = None @field_validator("asset_symbols", mode="before") def _coerce_asset_symbols(cls, v): @@ -76,10 +75,12 @@ class ExecutionPlan(BaseModel): class InquirerDecision(BaseModel): - """The decision output from the LLM-driven Inquirer Agent with context-switching.""" + """The decision output from the LLM-driven Inquirer Agent with state accumulation.""" - intent: FinancialIntent | None = Field( - default=None, description="Extracted financial intent from conversation" + intent_delta: FinancialIntent | None = Field( + default=None, + description="The NEW information extracted from this message only (delta, not full state). " + "For 'Compare with MSFT', this should only contain ['MSFT'], not ['AAPL', 'MSFT'].", ) status: Literal["COMPLETE", "INCOMPLETE", "CHAT"] = Field( description="COMPLETE: Ready for planning. INCOMPLETE: Need more info. CHAT: Casual conversation/follow-up." @@ -89,13 +90,9 @@ class InquirerDecision(BaseModel): default=None, description="Direct response to user (for INCOMPLETE questions or CHAT replies).", ) - should_clear_history: bool = Field( + is_hard_switch: bool = Field( default=False, - description="True if user is starting a NEW task (e.g., changing stocks). " - "False if asking follow-up questions about existing analysis.", - ) - focus_topic: str | None = Field( - default=None, - description="Specific sub-topic or question the user is asking about (e.g., 'iPhone 17 sales forecasts', 'dividend history'). " - "Extract this for FOLLOW-UP questions to guide the Planner.", + description="True ONLY if user explicitly asks to ignore previous context or switch domains completely. " + "Examples: 'Start over', 'Forget that', 'Clear everything', domain change (Stocks -> Crypto). " + "DO NOT set to True for comparisons like 'Compare with MSFT'.", ) diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index 78d91cac5..53ab84faa 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -9,7 +9,6 @@ from ..models import ExecutorResult from ..state import AgentState from ..tool_registry import registry - from ..tools.research import ( research, search_crypto_people, diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py index ea5f5291b..9813f7183 100644 --- a/python/valuecell/agents/react_agent/nodes/inquirer.py +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -10,6 +10,62 @@ from ..models import FinancialIntent, InquirerDecision +def _merge_profiles(old: dict | None, delta: dict | None) -> dict: + """Merge new intent delta into existing profile (set union for assets). + + Args: + old: Existing user_profile dict or None + delta: New intent delta dict from LLM or None + + Returns: + Merged profile dict + + Examples: + old={'asset_symbols': ['AAPL']}, delta={'asset_symbols': ['MSFT']} + -> {'asset_symbols': ['AAPL', 'MSFT']} + """ + if not old: + return delta or {} + if not delta: + return old + + merged = old.copy() + + # 1. Merge asset lists (Set union for deduplication) + old_assets = set(old.get("asset_symbols") or []) + new_assets = set(delta.get("asset_symbols") or []) + if new_assets: + merged["asset_symbols"] = list(old_assets | new_assets) + + # 2. Update risk preference (overwrite if new one provided) + if delta.get("risk"): + merged["risk"] = delta["risk"] + + return merged + + +def _compress_history(history: list[str]) -> str: + """Compress long execution history to prevent token explosion. + + Args: + history: List of execution history strings + + Returns: + Single compressed summary string + """ + # Simple compression: Keep first 3 and last 3 entries + if len(history) <= 6: + return "\n".join(history) + + compressed = [ + "[Execution History - Compressed]", + *history[:3], + f"... ({len(history) - 6} steps omitted) ...", + *history[-3:], + ] + return "\n".join(compressed) + + def _trim_messages(messages: list, max_messages: int = 10) -> list: """Keep only the last N messages to prevent token overflow. @@ -56,34 +112,39 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: is_final_turn = turns >= 2 - # Build context-aware system prompt system_prompt = ( - "You are a Financial Advisor Assistant's State Manager.\n\n" - "# YOUR ROLE:\n" - "Analyze the user's latest message in the context of previous conversation and execution state.\n" - "Decide what to do with the agent's state.\n\n" - f"# CURRENT CONTEXT:\n" - f"- Known Profile: {current_profile or 'None (First interaction)'}\n" - f"- Completed Tasks: {len(execution_history)} execution steps\n" - f"- Interaction Turn: {turns} (Max: 2)\n\n" - "# DECISION LOGIC:\n" - "1. **CHAT**: If user is just chatting (e.g., 'Thanks', 'OK', casual reply), " - "set status='CHAT' and provide a polite response in `response_to_user`. Set intent=None.\n\n" - "2. **NEW TASK**: If user is starting a NEW analysis (e.g., 'Analyze MSFT', 'Switch to Gold'), " - "set status='COMPLETE', extract the new intent, and set `should_clear_history=True` to reset old data.\n\n" - "3. **FOLLOW-UP**: If user is asking about EXISTING results (e.g., 'Why is the return low?', 'Tell me about iPhone 17 sales'), " - "set status='COMPLETE', keep the current intent (or update if refined), set `should_clear_history=False`, " - "and **CRITICALLY**: extract the specific `focus_topic` the user is asking about (e.g., 'iPhone 17 sales forecasts', 'dividend policy', 'ESG rating'). " - "This helps the Planner determine if new research is needed for that specific topic.\n\n" - "4. **INCOMPLETE**: If essential info (risk preference) is MISSING on a NEW task, " - "set status='INCOMPLETE' and ask a follow-up question in `response_to_user`.\n\n" - "# REQUIRED INFO FOR COMPLETE:\n" - "- Asset/Target: What to analyze (stock, sector, etc.)\n" - "- Risk Tolerance: Low, Medium, or High (can infer from keywords)\n\n" - "# INFERENCE RULES:\n" - "- 'safe', 'stable', 'conservative' -> Risk='Low'\n" - "- 'growth', 'aggressive', 'dynamic' -> Risk='High'\n" - "- 'balanced', unspecified -> Risk='Medium'\n\n" + "You are the **State Manager** for a Financial Advisor Assistant.\n" + "Your role: Extract ONLY the NEW information (delta) from each user message.\n\n" + f"# CURRENT STATE (Context Only - DO NOT Output):\n" + f"- **Active Profile**: {current_profile or 'None (Empty)'}\n" + f"- **Execution History**: {len(execution_history)} tasks completed\n\n" + "# CORE PRINCIPLE: **State Accumulation**\n" + "- Extract DELTA only (new information from THIS message)\n" + "- Do NOT merge with existing state (merging happens automatically)\n" + "- Default behavior: APPEND to existing context (never clear)\n" + "- Only set `is_hard_switch=True` for EXPLICIT resets\n\n" + "# DECISION LOGIC:\n\n" + "1. **CHAT / ACKNOWLEDGEMENT**\n" + " - Pattern: 'Thanks', 'Okay', 'Got it'\n" + " - Output: status='CHAT', intent_delta=None, response_to_user=[polite reply]\n\n" + "2. **EXPLICIT RESET (Rare)**\n" + " - Pattern: 'Start over', 'Forget that', 'Clear everything', 'Switch to Crypto'\n" + " - Output: status='COMPLETE', is_hard_switch=True, intent_delta=[NEW intent from scratch]\n" + " - **CRITICAL**: DO NOT trigger for comparisons like 'Compare with MSFT'\n\n" + "3. **INCREMENTAL ADDITION**\n" + " - Pattern: 'Compare with MSFT', 'Add TSLA', 'What about Gold?'\n" + " - Output: status='COMPLETE', is_hard_switch=False, intent_delta={'asset_symbols': ['MSFT']}\n" + " - **ONLY include the NEW asset**, not the old ones (e.g., if context has AAPL, just output ['MSFT'])\n\n" + "4. **IMPLICIT REFERENCE (Follow-up)**\n" + " - Pattern: 'Which is better?', 'Why did it drop?', 'Tell me more about iPhone 17'\n" + " - **Context Check**: If Active Profile exists, assume user refers to it\n" + " - Output: status='COMPLETE', is_hard_switch=False, intent_delta=None\n" + " - **DO NOT** mark as INCOMPLETE if context is sufficient\n\n" + "5. **INCOMPLETE (Vague Start)**\n" + " - Pattern: 'I want to invest', 'Recommend something' (AND Active Profile is None)\n" + " - Output: status='INCOMPLETE', ask user for specifics\n\n" + "# RISK INFERENCE:\n" + "- 'Safe/Retirement' -> Low | 'Aggressive/Growth' -> High | Default -> Medium\n" ) if is_final_turn: @@ -113,13 +174,13 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: decision: InquirerDecision = response.content logger.info( - "Inquirer decision: status={s}, clear_history={c}, reason={r}", + "Inquirer decision: status={s}, hard_switch={h}, reason={r}", s=decision.status, - c=decision.should_clear_history, + h=decision.is_hard_switch, r=decision.reasoning, ) - # --- State Update Logic (Core Context Switching) --- + # --- State Update Logic: Append-Only with Explicit Resets --- updates: dict[str, Any] = {} @@ -139,45 +200,72 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: updates["messages"] = [ AIMessage( content=decision.response_to_user - or "Could you tell me your risk preference (Low, Medium, High)?" + or "Could you tell me your preference?" ) ] return updates - # CASE 3: COMPLETE - Ready for planning - # Update profile (use new intent or keep current) - if decision.intent: - updates["user_profile"] = decision.intent.model_dump() - elif current_profile: - # Follow-up without changing intent: keep existing profile - updates["user_profile"] = current_profile - else: - # Fallback: no intent provided, default to Medium risk - updates["user_profile"] = FinancialIntent(risk="Medium").model_dump() + # CASE 3: COMPLETE - Ready for planning (with state accumulation) - # Context Switch: Clear history if starting new task - if decision.should_clear_history: - logger.info("Inquirer: Clearing history for NEW TASK") + # Branch A: HARD RESET (rare - explicit user command) + if decision.is_hard_switch: + logger.info( + "Inquirer: HARD RESET - User explicitly requested context clear" + ) + # Extract fresh intent from delta + new_profile = ( + decision.intent_delta.model_dump() + if decision.intent_delta + else FinancialIntent().model_dump() + ) + updates["user_profile"] = new_profile + # Clear all accumulated state updates["plan"] = [] updates["completed_tasks"] = {} updates["execution_history"] = [] updates["is_final"] = False updates["critique_feedback"] = None - updates["focus_topic"] = None # Clear old focus updates["messages"] = [ - SystemMessage(content="User started a new task. Previous context cleared.") + SystemMessage(content="Context reset. Starting fresh analysis.") ] + + # Branch B: DEFAULT ACCUMULATION (90% of cases) else: - # Follow-up: Keep history but reset is_final and set focus_topic - # This forces Planner to re-evaluate whether new data is needed - logger.info( - "Inquirer: FOLLOW-UP detected, focus_topic={topic}", - topic=decision.focus_topic, + # Merge delta into existing profile + if decision.intent_delta: + merged_profile = _merge_profiles( + current_profile, decision.intent_delta.model_dump() + ) + updates["user_profile"] = merged_profile + logger.info( + "Inquirer: DELTA MERGE - Old: {old}, Delta: {delta}, Merged: {merged}", + old=current_profile, + delta=decision.intent_delta.model_dump(), + merged=merged_profile, + ) + elif current_profile: + # Follow-up without new intent: keep existing profile + updates["user_profile"] = current_profile + logger.info("Inquirer: FOLLOW-UP - No delta, preserving profile") + else: + # Fallback: no delta and no existing profile + updates["user_profile"] = FinancialIntent().model_dump() + logger.info("Inquirer: DEFAULT PROFILE - No context, using Medium risk") + + # Always reset is_final to trigger replanning (Planner decides what to reuse) + updates["is_final"] = False + + # History Compression (Garbage Collection) + current_history = state.get("execution_history") or [] + if len(current_history) > 20: + logger.warning( + "Execution history too long ({n} entries), compressing...", + n=len(current_history), ) - updates["is_final"] = False # Force replanning - updates["focus_topic"] = decision.focus_topic or None + compressed = _compress_history(current_history) + updates["execution_history"] = [compressed] - updates["inquirer_turns"] = 0 # Reset turn counter after completion + updates["inquirer_turns"] = 0 # Reset turn counter return updates except Exception as exc: @@ -187,8 +275,7 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: if is_final_turn or current_profile: # If we have a profile, assume user wants to continue return { - "user_profile": current_profile - or FinancialIntent(risk="Medium").model_dump(), + "user_profile": current_profile or FinancialIntent().model_dump(), "inquirer_turns": 0, } else: diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index 6743fd922..a1543aae3 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -14,9 +14,8 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: """Iterative batch planner: generates the IMMEDIATE next batch of tasks. - Looks at execution_history to understand what has been done, - critique_feedback to fix any issues from previous iteration, - and focus_topic to prioritize specific user questions. + Looks at execution_history to understand what has been done + and critique_feedback to fix any issues from previous iteration. """ profile_dict = state.get("user_profile") or {} profile = ( @@ -27,13 +26,11 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: execution_history = state.get("execution_history") or [] critique_feedback = state.get("critique_feedback") - focus_topic = state.get("focus_topic") logger.info( - "Planner start: profile={p}, history_len={h}, focus={f}", + "Planner start: profile={p}, history_len={h}", p=profile.model_dump(), h=len(execution_history), - f=focus_topic or "General", ) # Build iterative planning prompt @@ -45,14 +42,6 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: feedback_text = ( f"\n\n**Critic Feedback**: {critique_feedback}" if critique_feedback else "" ) - focus_text = ( - f"\n\n**Current Focus Topic**: {focus_topic}\n" - f"(User is specifically asking about this. Verify if the Execution History already covers this specific topic. " - f"If the history only has general data but NOT this specific topic, you MUST generate new research tasks.)" - if focus_topic - else "" - ) - system_prompt_text = ( "You are an iterative financial planning agent.\n\n" "**Your Role**: Look at the Execution History below and decide the **IMMEDIATE next batch** of tasks.\n\n" @@ -60,13 +49,13 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: "**Planning Rules**:\n" "1. **Iterative Planning**: Plan only the next step(s), not the entire workflow.\n" "2. **Context Awareness**: Read the Execution History carefully. Don't repeat completed work.\n" - "3. **Focus Topic Priority**: If a `Current Focus Topic` is specified, CHECK if the Execution History contains data specifically about that topic.\n" + "3. **Reuse vs New Research**: Prefer reusing existing Execution History when it already covers the user's request.\n" "4. **Concrete Arguments**: tool_args must contain only literal values (no placeholders like '$t1.output').\n" "5. **Parallel Execution**: Tasks in the same batch run concurrently.\n" - "6. **Completion Signal**: If the goal is fully satisfied AND the Focus Topic (if any) is addressed, " + "6. **Completion Signal**: If the goal is fully satisfied and the user's latest request is addressed, " "return `tasks=[]` and `is_final=True`.\n" "7. **Critique Integration**: If Critic Feedback is present, address the issues mentioned.\n\n" - f"**Execution History**:\n{history_text}{feedback_text}{focus_text}\n" + f"**Execution History**:\n{history_text}{feedback_text}\n" ) user_profile_json = json.dumps(profile.model_dump(), ensure_ascii=False) @@ -120,13 +109,12 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: _validate_plan(tasks) - # Clear critique_feedback and focus_topic after consuming them + # Clear critique_feedback after consuming it return { "plan": [t.model_dump() for t in tasks], "plan_logic": strategy_update, # For backwards compatibility "is_final": is_final, "critique_feedback": None, # Clear after consuming - "focus_topic": None, # Clear after consuming } diff --git a/python/valuecell/agents/react_agent/state.py b/python/valuecell/agents/react_agent/state.py index 0aea53bb0..11b9240b5 100644 --- a/python/valuecell/agents/react_agent/state.py +++ b/python/valuecell/agents/react_agent/state.py @@ -9,7 +9,6 @@ class AgentState(TypedDict, total=False): messages: list[Any] user_profile: dict[str, Any] | None inquirer_turns: int - focus_topic: str | None # Specific user question/topic for current turn (e.g., 'iPhone 17 sales') # Planning (iterative batch planning) plan: list[dict[str, Any]] | None # Current batch of tasks From 748d562ea43519b90226a50ef0bb5433c0404bca Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:15:27 +0800 Subject: [PATCH 15/50] refactor(inquirer): streamline asset merging and enhance system prompt clarity --- .../agents/react_agent/nodes/inquirer.py | 70 ++++++++++--------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py index 9813f7183..6a93233e1 100644 --- a/python/valuecell/agents/react_agent/nodes/inquirer.py +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -37,13 +37,10 @@ def _merge_profiles(old: dict | None, delta: dict | None) -> dict: if new_assets: merged["asset_symbols"] = list(old_assets | new_assets) - # 2. Update risk preference (overwrite if new one provided) - if delta.get("risk"): - merged["risk"] = delta["risk"] - return merged +# TODO: summarize with LLM def _compress_history(history: list[str]) -> str: """Compress long execution history to prevent token explosion. @@ -114,37 +111,42 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: system_prompt = ( "You are the **State Manager** for a Financial Advisor Assistant.\n" - "Your role: Extract ONLY the NEW information (delta) from each user message.\n\n" - f"# CURRENT STATE (Context Only - DO NOT Output):\n" + "Your PRIMARY GOAL is to extract **only the new information (delta)** from the user's latest message.\n\n" + f"# CURRENT STATE (Context):\n" f"- **Active Profile**: {current_profile or 'None (Empty)'}\n" - f"- **Execution History**: {len(execution_history)} tasks completed\n\n" - "# CORE PRINCIPLE: **State Accumulation**\n" - "- Extract DELTA only (new information from THIS message)\n" - "- Do NOT merge with existing state (merging happens automatically)\n" - "- Default behavior: APPEND to existing context (never clear)\n" - "- Only set `is_hard_switch=True` for EXPLICIT resets\n\n" - "# DECISION LOGIC:\n\n" - "1. **CHAT / ACKNOWLEDGEMENT**\n" - " - Pattern: 'Thanks', 'Okay', 'Got it'\n" - " - Output: status='CHAT', intent_delta=None, response_to_user=[polite reply]\n\n" - "2. **EXPLICIT RESET (Rare)**\n" - " - Pattern: 'Start over', 'Forget that', 'Clear everything', 'Switch to Crypto'\n" - " - Output: status='COMPLETE', is_hard_switch=True, intent_delta=[NEW intent from scratch]\n" - " - **CRITICAL**: DO NOT trigger for comparisons like 'Compare with MSFT'\n\n" - "3. **INCREMENTAL ADDITION**\n" - " - Pattern: 'Compare with MSFT', 'Add TSLA', 'What about Gold?'\n" - " - Output: status='COMPLETE', is_hard_switch=False, intent_delta={'asset_symbols': ['MSFT']}\n" - " - **ONLY include the NEW asset**, not the old ones (e.g., if context has AAPL, just output ['MSFT'])\n\n" - "4. **IMPLICIT REFERENCE (Follow-up)**\n" - " - Pattern: 'Which is better?', 'Why did it drop?', 'Tell me more about iPhone 17'\n" - " - **Context Check**: If Active Profile exists, assume user refers to it\n" - " - Output: status='COMPLETE', is_hard_switch=False, intent_delta=None\n" - " - **DO NOT** mark as INCOMPLETE if context is sufficient\n\n" - "5. **INCOMPLETE (Vague Start)**\n" - " - Pattern: 'I want to invest', 'Recommend something' (AND Active Profile is None)\n" - " - Output: status='INCOMPLETE', ask user for specifics\n\n" - "# RISK INFERENCE:\n" - "- 'Safe/Retirement' -> Low | 'Aggressive/Growth' -> High | Default -> Medium\n" + f"- **History Length**: {len(execution_history)} items\n\n" + "# OUTPUT INSTRUCTIONS:\n" + "1. **intent_delta**: Return ONLY the new fields found in the latest message. Do NOT repeat old info.\n" + "2. **HARD RESET (System Command)**\n" + " - Trigger ONLY if user says: 'Start over', 'Reset', 'Clear history', 'New session'.\n" + " - Output: status='COMPLETE', is_hard_switch=True.\n" + "3. **focus_topic**: If the user asks a specific question (e.g. 'Why did it drop?'), extract the topic string.\n\n" + "# EXAMPLES (Few-Shot):\n\n" + "**Scenario 1: Incremental Addition**\n" + "Context: {assets: ['AAPL']}\n" + "User: 'Compare with MSFT'\n" + "Output: {intent_delta: {asset_symbols: ['MSFT']}, status: 'COMPLETE', is_hard_switch: False}\n" + "(Note: Only output MSFT. The system will merge it to get [AAPL, MSFT])\n\n" + "**Scenario 2: Parameter Refinement**\n" + "Context: {assets: ['AAPL'], risk: 'Medium'}\n" + "User: 'Actually, I want low risk'\n" + "Output: {intent_delta: {risk: 'Low'}, status: 'COMPLETE', is_hard_switch: False}\n\n" + "**Scenario 3: Implicit Follow-up**\n" + "Context: {assets: ['AAPL']}\n" + "User: 'Why are sales down?'\n" + "Output: {intent_delta: null, focus_topic: 'sales drop reasons', status: 'COMPLETE', is_hard_switch: False}\n" + "(Note: Context has assets, so we don't need to ask 'what asset?'. Just extract the topic.)\n\n" + "**Scenario 4: Hard Switch**\n" + "Context: {assets: ['AAPL']}\n" + "User: 'Forget that. Let's look at Bitcoin.'\n" + "Output: {intent_delta: {asset_symbols: ['BTC']}, status: 'COMPLETE', is_hard_switch: True}\n\n" + "**Scenario 5: Incomplete Start**\n" + "Context: None\n" + "User: 'I want to invest'\n" + "Output: {status: 'INCOMPLETE', response: 'What assets are you interested in?'}\n\n" + "# INFERENCE RULES:\n" + "- Risk: 'Safe/Retirement' -> Low | 'Aggressive/Growth' -> High\n" + "- Default behavior: Assume the user is building on the previous conversation." ) if is_final_turn: From adcecc707d07a4cbd671cb9f8eebf1ef085eda8a Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:25:35 +0800 Subject: [PATCH 16/50] refactor(research): remove unused crypto search tools from research agent --- python/valuecell/agents/react_agent/tools/research.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/python/valuecell/agents/react_agent/tools/research.py b/python/valuecell/agents/react_agent/tools/research.py index ea6583d95..16be791ba 100644 --- a/python/valuecell/agents/react_agent/tools/research.py +++ b/python/valuecell/agents/react_agent/tools/research.py @@ -13,10 +13,6 @@ fetch_ashare_filings, fetch_event_sec_filings, fetch_periodic_sec_filings, - search_crypto_people, - search_crypto_projects, - search_crypto_vcs, - web_search, ) from valuecell.utils.env import agent_debug_mode_enabled @@ -31,10 +27,6 @@ def build_research_agent() -> Agent: fetch_periodic_sec_filings, fetch_event_sec_filings, fetch_ashare_filings, - web_search, - search_crypto_projects, - search_crypto_vcs, - search_crypto_people, ] # Configure EDGAR identity only when SEC_EMAIL is present sec_email = os.getenv("SEC_EMAIL") From 7c2a736272ae122cf6e47f0eba14ef66c56f6b12 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:38:30 +0800 Subject: [PATCH 17/50] refactor(state): update messages type to use Annotated with List[BaseMessage] --- python/valuecell/agents/react_agent/state.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/python/valuecell/agents/react_agent/state.py b/python/valuecell/agents/react_agent/state.py index 11b9240b5..06744dc0b 100644 --- a/python/valuecell/agents/react_agent/state.py +++ b/python/valuecell/agents/react_agent/state.py @@ -1,12 +1,15 @@ from __future__ import annotations +import operator from operator import ior -from typing import Annotated, Any, TypedDict +from typing import Annotated, Any, List, TypedDict + +from langchain_core.messages import BaseMessage class AgentState(TypedDict, total=False): # Conversation and intent - messages: list[Any] + messages: Annotated[List[BaseMessage], operator.add] user_profile: dict[str, Any] | None inquirer_turns: int From eab820263c85e3d80f20c12e6b94aeb6fc97fb1f Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:38:48 +0800 Subject: [PATCH 18/50] refactor(executor, research): reorganize import statements for clarity --- python/valuecell/agents/react_agent/nodes/executor.py | 10 +++++----- python/valuecell/agents/react_agent/tools/research.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index 53ab84faa..dfd21c054 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -6,16 +6,16 @@ from loguru import logger from pydantic import BaseModel -from ..models import ExecutorResult -from ..state import AgentState -from ..tool_registry import registry -from ..tools.research import ( - research, +from ...research_agent.sources import ( search_crypto_people, search_crypto_projects, search_crypto_vcs, web_search, ) +from ..models import ExecutorResult +from ..state import AgentState +from ..tool_registry import registry +from ..tools.research import research _TOOLS_REGISTERED = False diff --git a/python/valuecell/agents/react_agent/tools/research.py b/python/valuecell/agents/react_agent/tools/research.py index 16be791ba..15377a307 100644 --- a/python/valuecell/agents/react_agent/tools/research.py +++ b/python/valuecell/agents/react_agent/tools/research.py @@ -50,7 +50,7 @@ def build_research_agent() -> Agent: search_knowledge=knowledge is not None, add_datetime_to_context=True, # configuration - debug_mode=agent_debug_mode_enabled(), + # debug_mode=agent_debug_mode_enabled(), ) From 9ec343dd9fd5a43bed87dd70071930ceffdfa18a Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:43:47 +0800 Subject: [PATCH 19/50] refactor(inquirer, planner): enhance execution context handling and improve conversation history formatting --- .../agents/react_agent/nodes/inquirer.py | 26 +++++++++++++++---- .../agents/react_agent/nodes/planner.py | 3 ++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py index 6a93233e1..3dd976461 100644 --- a/python/valuecell/agents/react_agent/nodes/inquirer.py +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -109,18 +109,24 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: is_final_turn = turns >= 2 + # Extract recent execution history for context (last 3 items) + recent_history = execution_history[-3:] if execution_history else [] + history_context = ( + "\n".join(recent_history) if recent_history else "(No execution history yet)" + ) + system_prompt = ( "You are the **State Manager** for a Financial Advisor Assistant.\n" "Your PRIMARY GOAL is to extract **only the new information (delta)** from the user's latest message.\n\n" f"# CURRENT STATE (Context):\n" f"- **Active Profile**: {current_profile or 'None (Empty)'}\n" - f"- **History Length**: {len(execution_history)} items\n\n" + f"- **Recent Execution Summary**:\n{history_context}\n\n" "# OUTPUT INSTRUCTIONS:\n" "1. **intent_delta**: Return ONLY the new fields found in the latest message. Do NOT repeat old info.\n" "2. **HARD RESET (System Command)**\n" " - Trigger ONLY if user says: 'Start over', 'Reset', 'Clear history', 'New session'.\n" " - Output: status='COMPLETE', is_hard_switch=True.\n" - "3. **focus_topic**: If the user asks a specific question (e.g. 'Why did it drop?'), extract the topic string.\n\n" + "3. **Implicit Reference**: If user refers to a previous asset or topic (e.g., 'Why did it drop?'), assume they refer to the Active Profile. Use the Recent Execution Summary to understand context.\n\n" "# EXAMPLES (Few-Shot):\n\n" "**Scenario 1: Incremental Addition**\n" "Context: {assets: ['AAPL']}\n" @@ -133,9 +139,10 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: "Output: {intent_delta: {risk: 'Low'}, status: 'COMPLETE', is_hard_switch: False}\n\n" "**Scenario 3: Implicit Follow-up**\n" "Context: {assets: ['AAPL']}\n" + "Execution: Task result shows 'Sales down 10% YoY'\n" "User: 'Why are sales down?'\n" - "Output: {intent_delta: null, focus_topic: 'sales drop reasons', status: 'COMPLETE', is_hard_switch: False}\n" - "(Note: Context has assets, so we don't need to ask 'what asset?'. Just extract the topic.)\n\n" + "Output: {intent_delta: null, status: 'COMPLETE', is_hard_switch: false}\n" + "(Note: Context has assets and execution history shows sales trend. Inquirer extracts the topic implicitly; Planner generates deep-dive tasks.)\n\n" "**Scenario 4: Hard Switch**\n" "Context: {assets: ['AAPL']}\n" "User: 'Forget that. Let's look at Bitcoin.'\n" @@ -162,7 +169,16 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: content = getattr(m, "content", str(m)) message_strs.append(f"[{role}]: {content}") - user_msg = "# Conversation History:\n" + "\n".join(message_strs) + conversation_text = ( + "\n".join(message_strs) if message_strs else "(No conversation yet)" + ) + user_msg = ( + "# Conversation History:\n" + f"{conversation_text}\n\n" + "# Execution Context:\n" + f"Recent execution summary is already injected in CURRENT STATE above. " + f"Use it to understand what data/analysis has already been completed." + ) try: agent = Agent( diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index a1543aae3..9d2e5e607 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -37,7 +37,7 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: tool_context = registry.get_prompt_context() history_text = ( - "\n".join(execution_history) if execution_history else "(No history yet)" + "\n\n".join(execution_history) if execution_history else "(No history yet)" ) feedback_text = ( f"\n\n**Critic Feedback**: {critique_feedback}" if critique_feedback else "" @@ -63,6 +63,7 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: is_final = False strategy_update = "" + # TODO: organize plan like a TODO list planned_tasks: list[PlannedTask] = [] try: From 48249f6f4d7a07e1cbfaad4b62dee192ecc52837 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:55:22 +0800 Subject: [PATCH 20/50] refactor(summarizer): enhance report generation with langchain-native model class --- .../agents/react_agent/nodes/summarizer.py | 172 ++++++++---------- 1 file changed, 79 insertions(+), 93 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/summarizer.py b/python/valuecell/agents/react_agent/nodes/summarizer.py index 24b90081b..4bf05c848 100644 --- a/python/valuecell/agents/react_agent/nodes/summarizer.py +++ b/python/valuecell/agents/react_agent/nodes/summarizer.py @@ -1,11 +1,12 @@ from __future__ import annotations import json +import os from typing import Any -from agno.agent import Agent -from agno.models.openrouter import OpenRouter -from langchain_core.messages import AIMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_core.prompts import ChatPromptTemplate +from langchain_openai import ChatOpenAI from loguru import logger from ..state import AgentState @@ -13,20 +14,7 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: """ - Generate a polished final report from raw execution history. - - This node is the final step in the workflow. It transforms the technical - execution log into a user-friendly financial analysis report suitable for - beginner investors. - - Takes: - - user_profile: Original user request - - execution_history: Raw task completion summaries - - completed_tasks: Actual data results - - Returns: - - messages: Final AIMessage with formatted report - - is_final: True (confirm completion) + Generate a polished final report using LangChain native model for streaming. """ user_profile = state.get("user_profile") or {} execution_history = state.get("execution_history") or [] @@ -38,74 +26,91 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: t=len(completed_tasks), ) - # Build context for the summarizer - # Extract key data points from completed_tasks for evidence + # 1. Extract context data_summary = _extract_key_results(completed_tasks) - system_prompt = ( - "You are a concise Financial Assistant for beginner investors.\n" - "Your goal is to synthesize the execution results into a short, actionable insight card.\n\n" - "**User Request**:\n" - f"{json.dumps(user_profile, ensure_ascii=False)}\n\n" - "**Key Data**:\n" - f"{data_summary}\n\n" - "**Strict Constraints**:\n" - "1. **Length Limit**: Keep the total response under 400 words. Be ruthless with cutting fluff.\n" - "2. **No Generic Intros**: DELETE sections like 'Company Overview' or 'What they do'.\n" - "3. **Focus on NOW**: Only mention historical data if it directly explains a current trend.\n\n" - "**Required Structure**:\n" - "(1-2 sentences direct answer to user's question)\n\n" - "## Key Findings\n" - "- **Metric 1**: Value (Interpretation)\n" - "- **Metric 2**: Value (Interpretation)\n" - "(Only include the top 3 most relevant numbers)\n\n" - "## Analysis\n" - "(One short paragraph explaining WHY. Use the 'Recent Developments' here)\n\n" - "## Risk Note\n" - "(One sentence on the specific risk found in the data, e.g., 'High volatility detected.')" + # 2. Build Prompt (Optimized for transparency and brevity) + system_template = """ +You are a concise Financial Assistant for beginner investors. +Your goal is to synthesize the execution results into a short, actionable insight card. + +**User Request**: +{user_profile} + +**Key Data extracted from tools**: +{data_summary} + +**Strict Constraints**: +1. **Length Limit**: Keep the total response under 400 words. Be ruthless with cutting fluff. +2. **Completeness Check**: You MUST address every asset requested. + - If the data contains errors (e.g. "content seems to be AMD" when user asked for "AAPL"), you MUST explicitly write: "āš ļø Data Error: Failed to retrieve data for [Asset]." + - Do NOT ignore missing data. +3. **No Generic Intros**: Start directly with the answer. +4. **Structure**: Use the format below. + +**Required Structure**: +(1-2 sentences direct answer to user's question) + +## Key Findings +- **[Metric Name]**: Value (Interpretation) +(List top 3 metrics. If data is missing/error, state it here) + +## Analysise +(One short paragraph synthesizing the "Why". Connect the dots.) + +## Risk Note +(One specific risk factor found in the data) +""" + + prompt = ChatPromptTemplate.from_messages( + [("system", system_template), ("human", "Please generate the final report.")] ) - user_msg = "Please generate the final financial analysis report." + # 3. Initialize LangChain Model (Native Streaming Support) + # Using ChatOpenAI to connect to OpenRouter (compatible API) + llm = ChatOpenAI( + model="google/gemini-2.5-flash", + openai_api_base="https://openrouter.ai/api/v1", + openai_api_key=os.getenv("OPENROUTER_API_KEY"), # Ensure ENV is set + temperature=0, + streaming=True, # Crucial for astream_events + ) + + chain = prompt | llm try: - agent = Agent( - model=OpenRouter(id="google/gemini-2.5-flash"), - instructions=[system_prompt], - markdown=True, # Enable markdown in response - debug_mode=True, + # 4. Invoke Chain + # LangGraph automatically captures 'on_chat_model_stream' events here + response = await chain.ainvoke( + { + "user_profile": json.dumps(user_profile, ensure_ascii=False), + "data_summary": data_summary, + } ) - response = await agent.arun(user_msg) - report_content = response.content - logger.info("Summarizer completed: report_len={l}", l=len(report_content)) + report_content = response.content + logger.info("Summarizer completed: len={l}", l=len(report_content)) - # Return as AIMessage for conversation history return { "messages": [AIMessage(content=report_content)], "is_final": True, "_summarizer_complete": True, } + except Exception as exc: logger.exception("Summarizer error: {err}", err=str(exc)) - # Fallback: return a basic summary - fallback = ( - "## Analysis Complete\n\n" - f"Completed {len(completed_tasks)} tasks based on your request. " - "Please review the execution history for details." - ) return { - "messages": [AIMessage(content=fallback)], + "messages": [ + AIMessage( + content="I encountered an error generating the report. Please check the execution logs." + ) + ], "is_final": True, - "_summarizer_error": str(exc), } def _extract_key_results(completed_tasks: dict[str, Any]) -> str: - """Extract and format key data points from completed tasks for LLM context. - - This reduces token usage by summarizing only the most important results - instead of dumping entire task outputs. - """ + """Extract results with Error Highlighting.""" if not completed_tasks: return "(No results available)" @@ -115,38 +120,19 @@ def _extract_key_results(completed_tasks: dict[str, Any]) -> str: continue result = task_data.get("result") - if not result: - continue - # Extract different types of results - if isinstance(result, dict): - # Market data - if "symbols" in result: - symbols = result.get("symbols", []) - lines.append( - f"- Task {task_id}: Market data for {len(symbols)} symbols" - ) + # Handle errors reported by Executor + if task_data.get("error"): + lines.append(f"- Task {task_id} [FAILED]: {task_data['error']}") + continue - # Screen results - if "table" in result: - count = len(result.get("table", [])) - risk = result.get("risk", "Unknown") - lines.append( - f"- Task {task_id}: Screened {count} candidates (Risk: {risk})" - ) + if not result: + continue - # Backtest results - if "return_pct" in result: - ret = result.get("return_pct", 0) - sharpe = result.get("sharpe_ratio", 0) - lines.append( - f"- Task {task_id}: Backtest return={ret:.2f}%, Sharpe={sharpe:.2f}" - ) + preview = str(result) + if len(preview) > 500: + preview = preview[:500] + "... (truncated)" - elif isinstance(result, str): - # Research/text results - truncate to avoid token overflow - # preview = result[:150] + "..." if len(result) > 150 else result - preview = result - lines.append(f"- Task {task_id}: {preview}") + lines.append(f"- Task {task_id}: {preview}") - return "\n".join(lines) if lines else "(No extractable data)" + return "\n".join(lines) From b00491900699584f942dffd3d57c9b2189d3ce78 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:55:30 +0800 Subject: [PATCH 21/50] refactor(executor): enhance progress logging with task_id and tool details --- python/valuecell/agents/react_agent/nodes/executor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index dfd21c054..8c8c6914d 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -71,7 +71,7 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An if task_id and task_id in completed_snapshot: logger.info("Executor skip (already completed): task_id={tid}", tid=task_id) return {} - await _emit_progress(5, "Starting") + await _emit_progress(5, f"Starting with {task_id=}, {tool=}") try: runtime_args = {"state": state} @@ -87,7 +87,7 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An ) summary = f"Task {task_id} ({tool}) failed: {str(exc)[:50]}" - await _emit_progress(95, "Finishing") + await _emit_progress(95, f"Finishing with {task_id=}, {tool=}") # Return delta for completed_tasks and execution_history completed_delta = {task_id: exec_res.model_dump()} @@ -101,7 +101,7 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An except Exception: pass - await _emit_progress(100, "Done") + await _emit_progress(100, f"Done with {task_id=}, {tool=}") return { "completed_tasks": completed_delta, "execution_history": [summary], From f0d484b918155b6c7d830a9665b0943563a83aff Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:29:51 +0800 Subject: [PATCH 22/50] refactor(inquirer): replace SystemMessage with AIMessage for context reset notification --- python/valuecell/agents/react_agent/nodes/inquirer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py index 3dd976461..45209a2b2 100644 --- a/python/valuecell/agents/react_agent/nodes/inquirer.py +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -244,7 +244,7 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: updates["is_final"] = False updates["critique_feedback"] = None updates["messages"] = [ - SystemMessage(content="Context reset. Starting fresh analysis.") + AIMessage(content="Context reset. Starting fresh analysis.") ] # Branch B: DEFAULT ACCUMULATION (90% of cases) @@ -268,7 +268,9 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: else: # Fallback: no delta and no existing profile updates["user_profile"] = FinancialIntent().model_dump() - logger.info("Inquirer: DEFAULT PROFILE - No context, using Medium risk") + logger.info( + "Inquirer: DEFAULT PROFILE - No context, using default profile" + ) # Always reset is_final to trigger replanning (Planner decides what to reuse) updates["is_final"] = False From 8dfd659aa9ceb647eb109f991394fb6d76a0b4dd Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:58:44 +0800 Subject: [PATCH 23/50] feat(react-agent): add FastAPI server with SSE support for chat streaming --- .../agents/react_agent/demo/__init__.py | 0 .../agents/react_agent/demo/index.html | 384 ++++++++++++++++++ .../agents/react_agent/demo/server.py | 183 +++++++++ 3 files changed, 567 insertions(+) create mode 100644 python/valuecell/agents/react_agent/demo/__init__.py create mode 100644 python/valuecell/agents/react_agent/demo/index.html create mode 100644 python/valuecell/agents/react_agent/demo/server.py diff --git a/python/valuecell/agents/react_agent/demo/__init__.py b/python/valuecell/agents/react_agent/demo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/react_agent/demo/index.html b/python/valuecell/agents/react_agent/demo/index.html new file mode 100644 index 000000000..31db67883 --- /dev/null +++ b/python/valuecell/agents/react_agent/demo/index.html @@ -0,0 +1,384 @@ + + + + + + React Agent - Financial Assistant + + + + + + + + +
+ + +
+ +
+
+

+ šŸ¤– Financial Agent +

+

Powered by LangGraph & Agno

+
+
+ Session: ... +
+
+ + +
+ +
+
+
+

šŸ‘‹ Hello! I'm your AI financial analyst.

+

I can help you with deeper market research. Try asking:

+
    +
  • "Analyze AAPL's recent earnings"
  • +
  • "Compare Microsoft and Google cloud growth"
  • +
  • "Why is Tesla stock volatile?"
  • +
+
+
+
+
+ + +
+
+ + +
+
+ + Thinking & Researching... + +
+
+
+ + +
+ +
+

+ + SYSTEM LOGS +

+
+ IDLE +
+
+ + +
+
+ Waiting for events... +
+
+
+
+ + + + + diff --git a/python/valuecell/agents/react_agent/demo/server.py b/python/valuecell/agents/react_agent/demo/server.py new file mode 100644 index 000000000..852e1b099 --- /dev/null +++ b/python/valuecell/agents/react_agent/demo/server.py @@ -0,0 +1,183 @@ +""" +FastAPI server for React Agent with SSE (Server-Sent Events) streaming. +Fixed for: Pydantic serialization, Router filtering, and Node observability. +""" + +from __future__ import annotations + +import json +from typing import Any + +import uvicorn +from fastapi import FastAPI +from fastapi.encoders import jsonable_encoder +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from langchain_core.messages import HumanMessage, AIMessage +from loguru import logger +from pydantic import BaseModel + +from valuecell.agents.react_agent.graph import get_app + +app = FastAPI(title="React Agent API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +class ChatRequest(BaseModel): + message: str + thread_id: str + + +def format_sse(event_type: str, data: Any) -> str: + """Format SSE message with proper JSON serialization for Pydantic objects.""" + # jsonable_encoder converts Pydantic models to dicts automatically + clean_data = jsonable_encoder(data) + return f"data: {json.dumps({'type': event_type, 'data': clean_data})}\n\n" + + +async def event_stream_generator(user_input: str, thread_id: str): + """ + Convert LangGraph v2 event stream to frontend UI protocol. + """ + try: + graph = get_app() + inputs = {"messages": [HumanMessage(content=user_input)]} + config = {"configurable": {"thread_id": thread_id}} + + logger.info(f"Stream start: {thread_id}") + last_emitted_text: str | None = None + + async for event in graph.astream_events(inputs, config=config, version="v2"): + kind = event.get("event", "") + node = event.get("metadata", {}).get("langgraph_node", "") + data = event.get("data") or {} + + # --- Helper: Check if this is a valid node output (not a router string) --- + def is_real_node_output(d): + output = d.get("output") + # Routers return strings like "wait", "plan". Nodes return dicts or Messages. + if isinstance(output, str): + return False + return True + + # ================================================================= + # 1. OBSERVABILITY EVENTS (Planner, Executor, Critic) + # ================================================================= + + # PLANNER: Emit the task list + if kind == "on_chain_end" and node == "planner": + if is_real_node_output(data): + output = data.get("output", {}) + # Ensure we have a plan + if isinstance(output, dict) and "plan" in output: + yield format_sse( + "planner_update", + { + "plan": output.get("plan"), + "reasoning": output.get("plan_logic") + or output.get("strategy_update"), + }, + ) + + # EXECUTOR: Emit specific task results (text/data) + elif kind == "on_chain_end" and node == "executor": + if is_real_node_output(data): + output = data.get("output", {}) + if isinstance(output, dict) and "completed_tasks" in output: + for task_id, res in output["completed_tasks"].items(): + # res structure: {'task_id': 't1', 'ok': True, 'result': '...'} + yield format_sse( + "task_result", + { + "task_id": task_id, + "status": "success" if res.get("ok") else "error", + "result": res.get( + "result" + ), # This is the markdown text + }, + ) + + # CRITIC: Emit approval/rejection logic + elif kind == "on_chain_end" and node == "critic": + if is_real_node_output(data): + output = data.get("output", {}) + if isinstance(output, dict): + summary = output.get("_critic_summary") + if summary: + yield format_sse("critic_decision", summary) + + # AGNO/TOOL LOGS: Intermediate progress + elif kind == "on_custom_event" and event.get("name") == "agno_event": + yield format_sse( + "tool_progress", {"node": node or "executor", "details": data} + ) + + # ================================================================= + # 2. CHAT CONTENT EVENTS (Inquirer, Summarizer) + # ================================================================= + + # STREAMING CONTENT (Summarizer) + if kind == "on_chat_model_stream" and node == "summarizer": + chunk = data.get("chunk") + text = chunk.content if chunk else None + if text: + yield format_sse("content_token", {"delta": text}) + last_emitted_text = text # Track to avoid duplicates if mixed + + # STATIC CONTENT (Inquirer / Fallback) + # Inquirer returns a full AIMessage at the end, not streamed + elif kind == "on_chain_end" and node == "inquirer": + if is_real_node_output(data): + output = data.get("output", {}) + msgs = output.get("messages", []) + if msgs and isinstance(msgs, list): + last_msg = msgs[-1] + # Verify it's an AI message meant for the user + if isinstance(last_msg, AIMessage) and last_msg.content: + # Only emit if we haven't streamed this content already + # (Inquirer doesn't stream, so this is safe) + yield format_sse( + "content_token", {"delta": last_msg.content} + ) + + # ================================================================= + # 3. UI STATE EVENTS + # ================================================================= + + elif kind == "on_chain_start" and node: + yield format_sse("step_change", {"step": node, "status": "started"}) + + elif kind == "on_chain_end" and node: + # Filter out routers for UI cleanliness + if is_real_node_output(data): + yield format_sse( + "step_change", {"step": node, "status": "completed"} + ) + + # End of stream + yield format_sse("done", {}) + logger.info(f"Stream done: {thread_id}") + + except Exception as exc: + logger.exception(f"Stream error: {exc}") + yield format_sse("error", {"message": str(exc)}) + + +@app.post("/chat/stream") +async def chat_stream(request: ChatRequest): + return StreamingResponse( + event_stream_generator(request.message, request.thread_id), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +if __name__ == "__main__": + uvicorn.run("server:app", host="0.0.0.0", port=8009) From f20e0104b9773c127b2e1a55942ec5d115ed430b Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:58:07 +0800 Subject: [PATCH 24/50] refactor(inquirer, planner, summarizer, state): simplify decision model and enhance focus handling --- python/valuecell/agents/react_agent/models.py | 28 +- .../agents/react_agent/nodes/inquirer.py | 248 +++++++----------- .../agents/react_agent/nodes/planner.py | 47 +++- .../agents/react_agent/nodes/summarizer.py | 50 +++- python/valuecell/agents/react_agent/state.py | 1 + 5 files changed, 192 insertions(+), 182 deletions(-) diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py index d1ce7f34b..bd9073938 100644 --- a/python/valuecell/agents/react_agent/models.py +++ b/python/valuecell/agents/react_agent/models.py @@ -75,24 +75,26 @@ class ExecutionPlan(BaseModel): class InquirerDecision(BaseModel): - """The decision output from the LLM-driven Inquirer Agent with state accumulation.""" + """Simplified decision model: Direct full-state output.""" - intent_delta: FinancialIntent | None = Field( + updated_profile: FinancialIntent | None = Field( default=None, - description="The NEW information extracted from this message only (delta, not full state). " - "For 'Compare with MSFT', this should only contain ['MSFT'], not ['AAPL', 'MSFT'].", + description="The FULL, UPDATED user profile after processing this message. " + "If user adds assets (e.g., 'Compare with MSFT' when context has ['AAPL']), " + "output the MERGED list: ['AAPL', 'MSFT']. " + "If user switches targets, output only new ones. " + "If follow-up question with no profile change, output the same profile.", ) - status: Literal["COMPLETE", "INCOMPLETE", "CHAT"] = Field( - description="COMPLETE: Ready for planning. INCOMPLETE: Need more info. CHAT: Casual conversation/follow-up." + focus_topic: str | None = Field( + default=None, + description="Specific sub-topic or question user is asking about (e.g., 'iPhone 17 sales', 'dividend history'). " + "Extract this for follow-up questions to guide Planner's research focus.", + ) + status: Literal["PLAN", "CHAT", "RESET"] = Field( + description="PLAN: Ready for task execution. CHAT: Casual conversation/greeting. RESET: Explicit command to start over." ) reasoning: str = Field(description="Brief thought process explaining the decision") response_to_user: str | None = Field( default=None, - description="Direct response to user (for INCOMPLETE questions or CHAT replies).", - ) - is_hard_switch: bool = Field( - default=False, - description="True ONLY if user explicitly asks to ignore previous context or switch domains completely. " - "Examples: 'Start over', 'Forget that', 'Clear everything', domain change (Stocks -> Crypto). " - "DO NOT set to True for comparisons like 'Compare with MSFT'.", + description="Direct response to user (for CHAT replies or clarifications).", ) diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py index 45209a2b2..0f8d30ed7 100644 --- a/python/valuecell/agents/react_agent/nodes/inquirer.py +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -10,36 +10,6 @@ from ..models import FinancialIntent, InquirerDecision -def _merge_profiles(old: dict | None, delta: dict | None) -> dict: - """Merge new intent delta into existing profile (set union for assets). - - Args: - old: Existing user_profile dict or None - delta: New intent delta dict from LLM or None - - Returns: - Merged profile dict - - Examples: - old={'asset_symbols': ['AAPL']}, delta={'asset_symbols': ['MSFT']} - -> {'asset_symbols': ['AAPL', 'MSFT']} - """ - if not old: - return delta or {} - if not delta: - return old - - merged = old.copy() - - # 1. Merge asset lists (Set union for deduplication) - old_assets = set(old.get("asset_symbols") or []) - new_assets = set(delta.get("asset_symbols") or []) - if new_assets: - merged["asset_symbols"] = list(old_assets | new_assets) - - return merged - - # TODO: summarize with LLM def _compress_history(history: list[str]) -> str: """Compress long execution history to prevent token explosion. @@ -107,8 +77,6 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: h=len(execution_history), ) - is_final_turn = turns >= 2 - # Extract recent execution history for context (last 3 items) recent_history = execution_history[-3:] if execution_history else [] history_context = ( @@ -117,51 +85,53 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: system_prompt = ( "You are the **State Manager** for a Financial Advisor Assistant.\n" - "Your PRIMARY GOAL is to extract **only the new information (delta)** from the user's latest message.\n\n" - f"# CURRENT STATE (Context):\n" + "Your job is to produce the NEXT STATE based on current context and user input.\n\n" + f"# CURRENT STATE:\n" f"- **Active Profile**: {current_profile or 'None (Empty)'}\n" f"- **Recent Execution Summary**:\n{history_context}\n\n" - "# OUTPUT INSTRUCTIONS:\n" - "1. **intent_delta**: Return ONLY the new fields found in the latest message. Do NOT repeat old info.\n" - "2. **HARD RESET (System Command)**\n" - " - Trigger ONLY if user says: 'Start over', 'Reset', 'Clear history', 'New session'.\n" - " - Output: status='COMPLETE', is_hard_switch=True.\n" - "3. **Implicit Reference**: If user refers to a previous asset or topic (e.g., 'Why did it drop?'), assume they refer to the Active Profile. Use the Recent Execution Summary to understand context.\n\n" - "# EXAMPLES (Few-Shot):\n\n" - "**Scenario 1: Incremental Addition**\n" - "Context: {assets: ['AAPL']}\n" - "User: 'Compare with MSFT'\n" - "Output: {intent_delta: {asset_symbols: ['MSFT']}, status: 'COMPLETE', is_hard_switch: False}\n" - "(Note: Only output MSFT. The system will merge it to get [AAPL, MSFT])\n\n" - "**Scenario 2: Parameter Refinement**\n" - "Context: {assets: ['AAPL'], risk: 'Medium'}\n" - "User: 'Actually, I want low risk'\n" - "Output: {intent_delta: {risk: 'Low'}, status: 'COMPLETE', is_hard_switch: False}\n\n" - "**Scenario 3: Implicit Follow-up**\n" - "Context: {assets: ['AAPL']}\n" - "Execution: Task result shows 'Sales down 10% YoY'\n" - "User: 'Why are sales down?'\n" - "Output: {intent_delta: null, status: 'COMPLETE', is_hard_switch: false}\n" - "(Note: Context has assets and execution history shows sales trend. Inquirer extracts the topic implicitly; Planner generates deep-dive tasks.)\n\n" - "**Scenario 4: Hard Switch**\n" - "Context: {assets: ['AAPL']}\n" - "User: 'Forget that. Let's look at Bitcoin.'\n" - "Output: {intent_delta: {asset_symbols: ['BTC']}, status: 'COMPLETE', is_hard_switch: True}\n\n" - "**Scenario 5: Incomplete Start**\n" - "Context: None\n" - "User: 'I want to invest'\n" - "Output: {status: 'INCOMPLETE', response: 'What assets are you interested in?'}\n\n" - "# INFERENCE RULES:\n" - "- Risk: 'Safe/Retirement' -> Low | 'Aggressive/Growth' -> High\n" - "- Default behavior: Assume the user is building on the previous conversation." + "# YOUR TASK: Output the COMPLETE, UPDATED state\n\n" + "# DECISION LOGIC:\n\n" + "## 1. CHAT (Greeting/Acknowledgement)\n" + "- Pattern: 'Thanks', 'Hello', 'Got it'\n" + "- Output: status='CHAT', response_to_user=[polite reply]\n\n" + "## 2. RESET (Explicit Command)\n" + "- Pattern: 'Start over', 'Reset', 'Clear everything', 'Forget that'\n" + "- Output: status='RESET', updated_profile=None\n\n" + "## 3. PLAN (Task Execution Needed)\n" + "### 3a. Adding Assets\n" + "- Pattern: 'Compare with MSFT' (when context has ['AAPL'])\n" + "- Output: status='PLAN', updated_profile={assets: ['AAPL', 'MSFT']}\n" + "- **CRITICAL**: Output the MERGED list, not just the new asset!\n\n" + "### 3b. Switching Assets\n" + "- Pattern: 'Check TSLA instead' (when context has ['AAPL'])\n" + "- Output: status='PLAN', updated_profile={assets: ['TSLA']}\n" + "- **CRITICAL**: Only output the new asset when user explicitly switches!\n\n" + "### 3c. Follow-up Questions\n" + "- Pattern: 'Why did it drop?', 'Tell me about iPhone sales'\n" + "- Output: status='PLAN', updated_profile={assets: ['AAPL']} (same as current), focus_topic='price drop reasons'\n" + "- **CRITICAL**: Keep profile unchanged, extract the specific question in focus_topic!\n\n" + "### 3d. New Analysis Request\n" + "- Pattern: 'Analyze Apple'\n" + "- Output: status='PLAN', updated_profile={assets: ['AAPL']}\n\n" + "# EXAMPLES:\n\n" + "**Example 1: Adding Asset**\n" + "Current: {assets: ['AAPL']}\n" + "User: 'Compare with Microsoft'\n" + "→ {status: 'PLAN', updated_profile: {asset_symbols: ['AAPL', 'MSFT']}, focus_topic: null}\n\n" + "**Example 2: Follow-up**\n" + "Current: {assets: ['AAPL']}\n" + "Recent: 'Task completed: AAPL price $150, down 5%'\n" + "User: 'Why did it drop?'\n" + "→ {status: 'PLAN', updated_profile: {asset_symbols: ['AAPL']}, focus_topic: 'price drop reasons'}\n\n" + "**Example 3: Switch**\n" + "Current: {assets: ['AAPL']}\n" + "User: 'Forget Apple, look at Tesla'\n" + "→ {status: 'RESET', updated_profile: {asset_symbols: ['TSLA']}}\n\n" + "**Example 4: Greeting**\n" + "User: 'Thanks!'\n" + "→ {status: 'CHAT', response_to_user: 'You're welcome!'}\n" ) - if is_final_turn: - system_prompt += ( - "# CRITICAL: MAX TURNS REACHED\n" - "Do NOT set status='INCOMPLETE'. Infer reasonable defaults (Risk='Medium') and proceed.\n" - ) - # Build user message from conversation history message_strs = [] for m in trimmed_messages: @@ -192,88 +162,71 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: decision: InquirerDecision = response.content logger.info( - "Inquirer decision: status={s}, hard_switch={h}, reason={r}", + "Inquirer decision: status={s}, profile={p}, focus={f}, reason={r}", s=decision.status, - h=decision.is_hard_switch, + p=decision.updated_profile, + f=decision.focus_topic, r=decision.reasoning, ) - # --- State Update Logic: Append-Only with Explicit Resets --- - + # --- Simplified State Update Logic: Direct Application --- updates: dict[str, Any] = {} # CASE 1: CHAT - Direct response, no planning if decision.status == "CHAT": - updates["messages"] = [ - AIMessage(content=decision.response_to_user or "Understood.") - ] - updates["user_profile"] = None # Signal to route to END - updates["inquirer_turns"] = 0 - return updates - - # CASE 2: INCOMPLETE - Ask follow-up question - if decision.status == "INCOMPLETE" and not is_final_turn: - updates["inquirer_turns"] = turns + 1 - updates["user_profile"] = None # Signal to route to END (wait for user) - updates["messages"] = [ - AIMessage( - content=decision.response_to_user - or "Could you tell me your preference?" - ) - ] - return updates - - # CASE 3: COMPLETE - Ready for planning (with state accumulation) - - # Branch A: HARD RESET (rare - explicit user command) - if decision.is_hard_switch: - logger.info( - "Inquirer: HARD RESET - User explicitly requested context clear" - ) - # Extract fresh intent from delta + return { + "messages": [ + AIMessage(content=decision.response_to_user or "Understood.") + ], + "user_profile": None, # Signal to route to END + "inquirer_turns": 0, + } + + # CASE 2: RESET - Clear everything and start fresh + if decision.status == "RESET": + logger.info("Inquirer: RESET - Clearing all context") new_profile = ( - decision.intent_delta.model_dump() - if decision.intent_delta - else FinancialIntent().model_dump() + decision.updated_profile.model_dump() + if decision.updated_profile + else None + ) + return { + "user_profile": new_profile, + "plan": [], + "completed_tasks": {}, + "execution_history": [], + "is_final": False, + "critique_feedback": None, + "focus_topic": None, + "messages": [ + AIMessage( + content="Starting fresh session. What would you like to analyze?" + ) + ], + "inquirer_turns": 0, + } + + # CASE 3: PLAN - Apply the updated profile directly (trust the LLM) + if decision.updated_profile: + updates["user_profile"] = decision.updated_profile.model_dump() + logger.info( + "Inquirer: PLAN - Profile updated to {p}", + p=decision.updated_profile.model_dump(), ) - updates["user_profile"] = new_profile - # Clear all accumulated state - updates["plan"] = [] - updates["completed_tasks"] = {} - updates["execution_history"] = [] - updates["is_final"] = False - updates["critique_feedback"] = None - updates["messages"] = [ - AIMessage(content="Context reset. Starting fresh analysis.") - ] - - # Branch B: DEFAULT ACCUMULATION (90% of cases) + elif current_profile: + # Fallback: LLM didn't return profile but we have existing context + updates["user_profile"] = current_profile + logger.info("Inquirer: PLAN - Preserving existing profile") else: - # Merge delta into existing profile - if decision.intent_delta: - merged_profile = _merge_profiles( - current_profile, decision.intent_delta.model_dump() - ) - updates["user_profile"] = merged_profile - logger.info( - "Inquirer: DELTA MERGE - Old: {old}, Delta: {delta}, Merged: {merged}", - old=current_profile, - delta=decision.intent_delta.model_dump(), - merged=merged_profile, - ) - elif current_profile: - # Follow-up without new intent: keep existing profile - updates["user_profile"] = current_profile - logger.info("Inquirer: FOLLOW-UP - No delta, preserving profile") - else: - # Fallback: no delta and no existing profile - updates["user_profile"] = FinancialIntent().model_dump() - logger.info( - "Inquirer: DEFAULT PROFILE - No context, using default profile" - ) - - # Always reset is_final to trigger replanning (Planner decides what to reuse) - updates["is_final"] = False + # No profile at all - shouldn't happen in PLAN status, but handle gracefully + updates["user_profile"] = FinancialIntent().model_dump() + logger.warning("Inquirer: PLAN with no profile - using empty default") + + # Update focus topic (critical for follow-up questions) + updates["focus_topic"] = decision.focus_topic + + # Force replanning + updates["is_final"] = False # History Compression (Garbage Collection) current_history = state.get("execution_history") or [] @@ -292,16 +245,17 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: logger.exception("Inquirer LLM error: {err}", err=str(exc)) # Graceful fallback - if is_final_turn or current_profile: + if current_profile: # If we have a profile, assume user wants to continue return { - "user_profile": current_profile or FinancialIntent().model_dump(), + "user_profile": current_profile, "inquirer_turns": 0, + "is_final": False, } else: # Ask user to retry return { - "inquirer_turns": turns + 1, + "inquirer_turns": 0, "user_profile": None, "messages": [ AIMessage( diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index 9d2e5e607..5c82da723 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -14,8 +14,12 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: """Iterative batch planner: generates the IMMEDIATE next batch of tasks. - Looks at execution_history to understand what has been done - and critique_feedback to fix any issues from previous iteration. + Looks at execution_history to understand what has been done, + critique_feedback to fix any issues, and focus_topic to prioritize research focus. + + Two Modes: + - General Scan: Profile fully explored (focus_topic=None) -> Research all assets + - Surgical: Specific question (focus_topic=set) -> Research only relevant topics, ignore unrelated assets """ profile_dict = state.get("user_profile") or {} profile = ( @@ -26,11 +30,13 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: execution_history = state.get("execution_history") or [] critique_feedback = state.get("critique_feedback") + focus_topic = state.get("focus_topic") logger.info( - "Planner start: profile={p}, history_len={h}", + "Planner start: profile={p}, history_len={h}, focus={f}", p=profile.model_dump(), h=len(execution_history), + f=focus_topic or "General", ) # Build iterative planning prompt @@ -42,18 +48,43 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: feedback_text = ( f"\n\n**Critic Feedback**: {critique_feedback}" if critique_feedback else "" ) + + # Dynamic mode instruction based on focus_topic + if focus_topic: + mode_instruction = ( + f"šŸŽÆ **SURGICAL MODE** šŸŽÆ\n" + f'**Current User Question**: "{focus_topic}"\n' + "**Strategy**:\n" + "1. **FOCUS ONLY**: Research ONLY what's needed to answer this question.\n" + "2. **IGNORE IRRELEVANT ASSETS**: If the user has [AAPL, MSFT] but asks 'Tell me about MSFT earnings', " + "do NOT also fetch AAPL data.\n" + "3. **VERIFY FRESHNESS**: Check if the Execution History has *recent, specific* data for this question. " + "If the history only has generic data or is from a previous turn, GENERATE NEW TASKS.\n" + "4. **Be Surgical**: Don't over-fetch. Narrow your scope to the exact question.\n" + ) + else: + mode_instruction = ( + "šŸŒ **GENERAL SCAN MODE** šŸŒ\n" + "**Strategy**:\n" + "1. **COMPREHENSIVE**: Ensure ALL assets in the User Profile are researched.\n" + "2. **BALANCED**: Generate tasks that cover all dimensions (price, news, fundamentals) for each asset.\n" + ) + system_prompt_text = ( "You are an iterative financial planning agent.\n\n" - "**Your Role**: Look at the Execution History below and decide the **IMMEDIATE next batch** of tasks.\n\n" + f"{mode_instruction}\n\n" + "**Your Role**: Decide the **IMMEDIATE next batch** of tasks.\n\n" f"**Available Tools**:\n{tool_context}\n\n" "**Planning Rules**:\n" "1. **Iterative Planning**: Plan only the next step(s), not the entire workflow.\n" "2. **Context Awareness**: Read the Execution History carefully. Don't repeat completed work.\n" - "3. **Reuse vs New Research**: Prefer reusing existing Execution History when it already covers the user's request.\n" - "4. **Concrete Arguments**: tool_args must contain only literal values (no placeholders like '$t1.output').\n" + "3. **Relevance & Freshness**:\n" + " - If user asks 'latest', 'today', or 'recent news' -> Check if history data is fresh (from current turn).\n" + " - If history only has old/generic data from previous turns, GENERATE NEW TASKS.\n" + " - Be skeptical of old data. When in doubt, fetch fresh data rather than stale data.\n" + "4. **Concrete Arguments**: tool_args must contain only literal values (no placeholders).\n" "5. **Parallel Execution**: Tasks in the same batch run concurrently.\n" - "6. **Completion Signal**: If the goal is fully satisfied and the user's latest request is addressed, " - "return `tasks=[]` and `is_final=True`.\n" + "6. **Completion Signal**: Return `tasks=[]` and `is_final=True` only when the goal is fully satisfied.\n" "7. **Critique Integration**: If Critic Feedback is present, address the issues mentioned.\n\n" f"**Execution History**:\n{history_text}{feedback_text}\n" ) diff --git a/python/valuecell/agents/react_agent/nodes/summarizer.py b/python/valuecell/agents/react_agent/nodes/summarizer.py index 4bf05c848..1281a78d5 100644 --- a/python/valuecell/agents/react_agent/nodes/summarizer.py +++ b/python/valuecell/agents/react_agent/nodes/summarizer.py @@ -4,7 +4,7 @@ import os from typing import Any -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_core.messages import AIMessage from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI from loguru import logger @@ -15,38 +15,60 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: """ Generate a polished final report using LangChain native model for streaming. + + Respects focus_topic: if set, only addresses that specific question. + Otherwise, provides comprehensive overview of all requested assets. """ user_profile = state.get("user_profile") or {} execution_history = state.get("execution_history") or [] completed_tasks = state.get("completed_tasks") or {} + focus_topic = state.get("focus_topic") logger.info( - "Summarizer start: history_len={h}, tasks={t}", + "Summarizer start: history_len={h}, tasks={t}, focus={f}", h=len(execution_history), t=len(completed_tasks), + f=focus_topic or "General", ) # 1. Extract context data_summary = _extract_key_results(completed_tasks) - # 2. Build Prompt (Optimized for transparency and brevity) - system_template = """ + # 2. Build focus-aware prompt + # Avoid interpolating `user_profile` (a dict) directly into the template string + # because its braces (e.g. "{'asset_symbols': ...}") are picked up by + # ChatPromptTemplate as template variables. Instead use placeholders + # and pass `user_profile` as a variable when invoking the chain. + immediate_task_section = ( + f'**IMMEDIATE TASK**: Answer ONLY this specific question: "{focus_topic}"\n' + "**INSTRUCTION**: Scan the 'Key Data' below to find evidence supporting this topic. " + "Synthesize the existing data to answer the question directly. Do not just summarize everything." + if focus_topic + else "" + ) + + system_template = f""" You are a concise Financial Assistant for beginner investors. Your goal is to synthesize the execution results into a short, actionable insight card. **User Request**: -{user_profile} +{{user_profile}} + +{immediate_task_section} **Key Data extracted from tools**: -{data_summary} +{{data_summary}} **Strict Constraints**: 1. **Length Limit**: Keep the total response under 400 words. Be ruthless with cutting fluff. -2. **Completeness Check**: You MUST address every asset requested. - - If the data contains errors (e.g. "content seems to be AMD" when user asked for "AAPL"), you MUST explicitly write: "āš ļø Data Error: Failed to retrieve data for [Asset]." - - Do NOT ignore missing data. -3. **No Generic Intros**: Start directly with the answer. -4. **Structure**: Use the format below. +2. **Relevance Check**: + - If focus_topic is set, ONLY answer that question. Ignore unrelated data. + - If focus_topic is not set, ensure you address every asset requested. +3. **Completeness Check**: You MUST surface data errors explicitly. + - If data is missing or mismatched + you MUST write: "āš ļø Data Retrieval Issue: [Details]" +4. **No Generic Intros**: Start directly with the answer. +5. **Structure**: Use the format below. **Required Structure**: (1-2 sentences direct answer to user's question) @@ -55,7 +77,7 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: - **[Metric Name]**: Value (Interpretation) (List top 3 metrics. If data is missing/error, state it here) -## Analysise +## Analysis (One short paragraph synthesizing the "Why". Connect the dots.) ## Risk Note @@ -70,8 +92,8 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: # Using ChatOpenAI to connect to OpenRouter (compatible API) llm = ChatOpenAI( model="google/gemini-2.5-flash", - openai_api_base="https://openrouter.ai/api/v1", - openai_api_key=os.getenv("OPENROUTER_API_KEY"), # Ensure ENV is set + base_url="https://openrouter.ai/api/v1", + api_key=os.getenv("OPENROUTER_API_KEY"), # Ensure ENV is set temperature=0, streaming=True, # Crucial for astream_events ) diff --git a/python/valuecell/agents/react_agent/state.py b/python/valuecell/agents/react_agent/state.py index 06744dc0b..b38123174 100644 --- a/python/valuecell/agents/react_agent/state.py +++ b/python/valuecell/agents/react_agent/state.py @@ -12,6 +12,7 @@ class AgentState(TypedDict, total=False): messages: Annotated[List[BaseMessage], operator.add] user_profile: dict[str, Any] | None inquirer_turns: int + focus_topic: str | None # Specific user question/topic for Planner focus # Planning (iterative batch planning) plan: list[dict[str, Any]] | None # Current batch of tasks From 6fe8882821745507e2a9d912dd6e0348e180ae42 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:09:15 +0800 Subject: [PATCH 25/50] feat(planner): extract recent conversation context for improved task planning --- .../agents/react_agent/nodes/planner.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index 5c82da723..6fd3cdc08 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -5,6 +5,7 @@ from agno.agent import Agent from agno.models.openrouter import OpenRouter +from langchain_core.messages import AIMessage, HumanMessage from loguru import logger from ..models import ExecutionPlan, FinancialIntent, PlannedTask, Task @@ -49,6 +50,23 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: f"\n\n**Critic Feedback**: {critique_feedback}" if critique_feedback else "" ) + # 1. Extract recent conversation (last Assistant + User messages) + messages_list = state.get("messages", []) or [] + recent_msgs: list[tuple[str, str]] = [] + for m in messages_list: + # support both Message objects and plain dicts + if isinstance(m, (HumanMessage, AIMessage)): + role = "User" if isinstance(m, HumanMessage) else "Assistant" + recent_msgs.append((role, m.content)) + + # Keep only the last 3 relevant messages (AI/User pairs preferred) + recent_msgs = recent_msgs[-3:] + if recent_msgs: + context_str = "\n\n".join(f"{r}: {c}" for r, c in recent_msgs) + recent_context_text = f"**RECENT CONVERSATION**:\n{context_str}\n(Use this context to resolve references. If user asks about a phrase mentioned by the Assistant, target your research to verify or expand on that claim.)\n\n" + else: + recent_context_text = "" + # Dynamic mode instruction based on focus_topic if focus_topic: mode_instruction = ( @@ -86,7 +104,7 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: "5. **Parallel Execution**: Tasks in the same batch run concurrently.\n" "6. **Completion Signal**: Return `tasks=[]` and `is_final=True` only when the goal is fully satisfied.\n" "7. **Critique Integration**: If Critic Feedback is present, address the issues mentioned.\n\n" - f"**Execution History**:\n{history_text}{feedback_text}\n" + f"{recent_context_text}**Execution History**:\n{history_text}{feedback_text}\n" ) user_profile_json = json.dumps(profile.model_dump(), ensure_ascii=False) From 9141503f1fa51915932abd4f56bdce0cc3602d44 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:40:53 +0800 Subject: [PATCH 26/50] refactor(react-agent): update decision model to use natural language intent for improved task planning --- python/valuecell/agents/react_agent/graph.py | 4 +- python/valuecell/agents/react_agent/models.py | 19 +-- .../agents/react_agent/nodes/inquirer.py | 150 ++++++++++-------- .../agents/react_agent/nodes/planner.py | 52 ++---- .../agents/react_agent/nodes/summarizer.py | 43 ++--- python/valuecell/agents/react_agent/state.py | 3 +- 6 files changed, 114 insertions(+), 157 deletions(-) diff --git a/python/valuecell/agents/react_agent/graph.py b/python/valuecell/agents/react_agent/graph.py index 64fcf08f4..3a623f603 100644 --- a/python/valuecell/agents/react_agent/graph.py +++ b/python/valuecell/agents/react_agent/graph.py @@ -68,7 +68,9 @@ def build_app() -> Any: graph.add_edge(START, "inquirer") def _route_after_inquirer(st: AgentState) -> str: - return "plan" if st.get("user_profile") else "wait" + # After refactor: Inquirer now writes `current_intent` (natural language string) + # Route to planner when an intent is present, otherwise wait/end. + return "plan" if st.get("current_intent") else "wait" graph.add_conditional_edges( "inquirer", _route_after_inquirer, {"plan": "planner", "wait": END} diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py index bd9073938..9f779d09a 100644 --- a/python/valuecell/agents/react_agent/models.py +++ b/python/valuecell/agents/react_agent/models.py @@ -75,20 +75,15 @@ class ExecutionPlan(BaseModel): class InquirerDecision(BaseModel): - """Simplified decision model: Direct full-state output.""" + """Simplified decision model: Natural language intent output.""" - updated_profile: FinancialIntent | None = Field( + current_intent: str | None = Field( default=None, - description="The FULL, UPDATED user profile after processing this message. " - "If user adds assets (e.g., 'Compare with MSFT' when context has ['AAPL']), " - "output the MERGED list: ['AAPL', 'MSFT']. " - "If user switches targets, output only new ones. " - "If follow-up question with no profile change, output the same profile.", - ) - focus_topic: str | None = Field( - default=None, - description="Specific sub-topic or question user is asking about (e.g., 'iPhone 17 sales', 'dividend history'). " - "Extract this for follow-up questions to guide Planner's research focus.", + description="A single, comprehensive natural language sentence describing the user's immediate goal. " + "Resolve all context and pronouns from conversation history. " + "Examples: 'Analyze Apple stock price and fundamentals', 'Compare Apple and Tesla 2024 performance', " + "'Find reasons for Apple's recent stock price drop'. " + "Be explicit and complete - this is the primary instruction for task planning.", ) status: Literal["PLAN", "CHAT", "RESET"] = Field( description="PLAN: Ready for task execution. CHAT: Casual conversation/greeting. RESET: Explicit command to start over." diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py index 0f8d30ed7..f7a1d5537 100644 --- a/python/valuecell/agents/react_agent/nodes/inquirer.py +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -52,18 +52,21 @@ def _trim_messages(messages: list, max_messages: int = 10) -> list: async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: - """Smart Inquirer: Extracts intent, detects context switches, handles follow-ups. + """Smart Inquirer: Extracts natural language intent from conversation. + + Produces a single comprehensive sentence describing user's immediate goal. + Resolves pronouns and context using conversation and execution history. Multi-turn conversation logic: - 1. **New Task**: User changes target (e.g., "Check MSFT") -> Clear history - 2. **Follow-up**: User asks about results (e.g., "Why risk high?") -> Keep history + 1. **New Task**: User starts new analysis -> Generate clear intent + 2. **Follow-up**: User asks about prior results -> Resolve references and generate focused intent 3. **Chat**: User casual talk (e.g., "Thanks") -> Direct response, no planning - Inputs: state["messages"], state["user_profile"], state["execution_history"]. - Outputs: Updated state with user_profile, history reset if needed, or chat response. + Inputs: state["messages"], state["current_intent"], state["execution_history"]. + Outputs: Updated state with current_intent (natural language string) or chat response. """ messages = state.get("messages") or [] - current_profile = state.get("user_profile") + current_intent = state.get("current_intent") execution_history = state.get("execution_history") or [] turns = int(state.get("inquirer_turns") or 0) @@ -71,9 +74,9 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: trimmed_messages = _trim_messages(messages, max_messages=10) logger.info( - "Inquirer start: turns={t}, current_profile={p}, history_len={h}", + "Inquirer start: turns={t}, current_intent={i}, history_len={h}", t=turns, - p=current_profile, + i=current_intent or "None", h=len(execution_history), ) @@ -84,49 +87,62 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: ) system_prompt = ( - "You are the **State Manager** for a Financial Advisor Assistant.\n" - "Your job is to produce the NEXT STATE based on current context and user input.\n\n" - f"# CURRENT STATE:\n" - f"- **Active Profile**: {current_profile or 'None (Empty)'}\n" + "You are the **Intent Interpreter** for a Financial Advisor Assistant.\n" + "Your job is to produce a single, comprehensive natural language sentence describing the user's IMMEDIATE goal.\n\n" + f"# CURRENT CONTEXT:\n" + f"- **Active Intent**: {current_intent or 'None (Empty)'}\n" f"- **Recent Execution Summary**:\n{history_context}\n\n" - "# YOUR TASK: Output the COMPLETE, UPDATED state\n\n" + "# YOUR TASK: Output the user's current goal as a natural language instruction\n\n" "# DECISION LOGIC:\n\n" "## 1. CHAT (Greeting/Acknowledgement)\n" "- Pattern: 'Thanks', 'Hello', 'Got it'\n" "- Output: status='CHAT', response_to_user=[polite reply]\n\n" "## 2. RESET (Explicit Command)\n" "- Pattern: 'Start over', 'Reset', 'Clear everything', 'Forget that'\n" - "- Output: status='RESET', updated_profile=None\n\n" + "- Output: status='RESET', current_intent=None\n\n" "## 3. PLAN (Task Execution Needed)\n" - "### 3a. Adding Assets\n" - "- Pattern: 'Compare with MSFT' (when context has ['AAPL'])\n" - "- Output: status='PLAN', updated_profile={assets: ['AAPL', 'MSFT']}\n" - "- **CRITICAL**: Output the MERGED list, not just the new asset!\n\n" - "### 3b. Switching Assets\n" - "- Pattern: 'Check TSLA instead' (when context has ['AAPL'])\n" - "- Output: status='PLAN', updated_profile={assets: ['TSLA']}\n" - "- **CRITICAL**: Only output the new asset when user explicitly switches!\n\n" - "### 3c. Follow-up Questions\n" - "- Pattern: 'Why did it drop?', 'Tell me about iPhone sales'\n" - "- Output: status='PLAN', updated_profile={assets: ['AAPL']} (same as current), focus_topic='price drop reasons'\n" - "- **CRITICAL**: Keep profile unchanged, extract the specific question in focus_topic!\n\n" - "### 3d. New Analysis Request\n" + "### 3a. New Analysis Request\n" "- Pattern: 'Analyze Apple'\n" - "- Output: status='PLAN', updated_profile={assets: ['AAPL']}\n\n" + "- Output: status='PLAN', current_intent='Analyze Apple stock price and fundamentals'\n\n" + "### 3b. Comparison Request\n" + "- Pattern: 'Compare Apple and Tesla'\n" + "- Output: status='PLAN', current_intent='Compare Apple and Tesla 2024 financial performance'\n\n" + "### 3c. Adding to Comparison (Context-Aware)\n" + "- Current Intent: 'Analyze Apple stock'\n" + "- User: 'Compare with Microsoft'\n" + "- Output: status='PLAN', current_intent='Compare Apple and Microsoft stock performance'\n" + "- **CRITICAL**: Merge context! Don't just output 'Microsoft'.\n\n" + "### 3d. Follow-up Questions (Reference Resolution)\n" + "- Current Intent: 'Analyze Apple stock'\n" + "- Recent Execution: 'AAPL price $150, down 5%'\n" + "- User: 'Why did it drop?'\n" + "- Output: status='PLAN', current_intent='Find reasons for Apple stock price drop'\n" + "- **CRITICAL**: Resolve pronouns using context! 'it' → 'Apple stock'.\n\n" + "### 3e. Specific Follow-up (Drill-Down)\n" + "- Current Intent: 'Analyze Apple stock'\n" + "- Assistant mentioned: 'consistent revenue growth'\n" + "- User: 'Tell me more about the revenue growth'\n" + "- Output: status='PLAN', current_intent='Analyze Apple revenue growth trends and details'\n" + "- **CRITICAL**: Extract the specific phrase and make it explicit!\n\n" + "### 3f. Switching Assets\n" + "- Current Intent: 'Analyze Apple stock'\n" + "- User: 'Forget Apple, look at Tesla'\n" + "- Output: status='RESET', current_intent='Analyze Tesla stock'\n\n" "# EXAMPLES:\n\n" "**Example 1: Adding Asset**\n" - "Current: {assets: ['AAPL']}\n" + "Current: 'Analyze Apple stock'\n" "User: 'Compare with Microsoft'\n" - "→ {status: 'PLAN', updated_profile: {asset_symbols: ['AAPL', 'MSFT']}, focus_topic: null}\n\n" - "**Example 2: Follow-up**\n" - "Current: {assets: ['AAPL']}\n" - "Recent: 'Task completed: AAPL price $150, down 5%'\n" + "→ {status: 'PLAN', current_intent: 'Compare Apple and Microsoft stock performance'}\n\n" + "**Example 2: Reference Resolution**\n" + "Current: 'Analyze Apple stock'\n" + "Recent: 'AAPL down 5%'\n" "User: 'Why did it drop?'\n" - "→ {status: 'PLAN', updated_profile: {asset_symbols: ['AAPL']}, focus_topic: 'price drop reasons'}\n\n" - "**Example 3: Switch**\n" - "Current: {assets: ['AAPL']}\n" - "User: 'Forget Apple, look at Tesla'\n" - "→ {status: 'RESET', updated_profile: {asset_symbols: ['TSLA']}}\n\n" + "→ {status: 'PLAN', current_intent: 'Find reasons for Apple stock price drop'}\n\n" + "**Example 3: Drill-Down**\n" + "Current: 'Analyze Apple stock'\n" + "Assistant: 'strong revenue growth'\n" + "User: 'Tell me more about revenue growth'\n" + "→ {status: 'PLAN', current_intent: 'Analyze Apple revenue growth details'}\n\n" "**Example 4: Greeting**\n" "User: 'Thanks!'\n" "→ {status: 'CHAT', response_to_user: 'You're welcome!'}\n" @@ -162,10 +178,9 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: decision: InquirerDecision = response.content logger.info( - "Inquirer decision: status={s}, profile={p}, focus={f}, reason={r}", + "Inquirer decision: status={s}, intent={i}, reason={r}", s=decision.status, - p=decision.updated_profile, - f=decision.focus_topic, + i=decision.current_intent, r=decision.reasoning, ) @@ -178,26 +193,20 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: "messages": [ AIMessage(content=decision.response_to_user or "Understood.") ], - "user_profile": None, # Signal to route to END + "current_intent": None, # Signal to route to END "inquirer_turns": 0, } # CASE 2: RESET - Clear everything and start fresh if decision.status == "RESET": logger.info("Inquirer: RESET - Clearing all context") - new_profile = ( - decision.updated_profile.model_dump() - if decision.updated_profile - else None - ) return { - "user_profile": new_profile, + "current_intent": decision.current_intent, "plan": [], "completed_tasks": {}, "execution_history": [], "is_final": False, "critique_feedback": None, - "focus_topic": None, "messages": [ AIMessage( content="Starting fresh session. What would you like to analyze?" @@ -206,24 +215,29 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: "inquirer_turns": 0, } - # CASE 3: PLAN - Apply the updated profile directly (trust the LLM) - if decision.updated_profile: - updates["user_profile"] = decision.updated_profile.model_dump() + # CASE 3: PLAN - Apply the current intent directly + if decision.current_intent: + updates["current_intent"] = decision.current_intent logger.info( - "Inquirer: PLAN - Profile updated to {p}", - p=decision.updated_profile.model_dump(), + "Inquirer: PLAN - Intent set to: {i}", + i=decision.current_intent, ) - elif current_profile: - # Fallback: LLM didn't return profile but we have existing context - updates["user_profile"] = current_profile - logger.info("Inquirer: PLAN - Preserving existing profile") + elif current_intent: + # Fallback: LLM didn't return intent but we have existing context + updates["current_intent"] = current_intent + logger.info("Inquirer: PLAN - Preserving existing intent") else: - # No profile at all - shouldn't happen in PLAN status, but handle gracefully - updates["user_profile"] = FinancialIntent().model_dump() - logger.warning("Inquirer: PLAN with no profile - using empty default") - - # Update focus topic (critical for follow-up questions) - updates["focus_topic"] = decision.focus_topic + # No intent at all - shouldn't happen in PLAN status + logger.warning("Inquirer: PLAN with no intent - asking for clarification") + return { + "current_intent": None, + "inquirer_turns": 0, + "messages": [ + AIMessage( + content="I didn't quite understand. What would you like to analyze?" + ) + ], + } # Force replanning updates["is_final"] = False @@ -245,10 +259,10 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: logger.exception("Inquirer LLM error: {err}", err=str(exc)) # Graceful fallback - if current_profile: - # If we have a profile, assume user wants to continue + if current_intent: + # If we have an intent, assume user wants to continue return { - "user_profile": current_profile, + "current_intent": current_intent, "inquirer_turns": 0, "is_final": False, } @@ -256,7 +270,7 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: # Ask user to retry return { "inquirer_turns": 0, - "user_profile": None, + "current_intent": None, "messages": [ AIMessage( content="I didn't quite understand. Could you tell me what you'd like to analyze?" diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index 6fd3cdc08..5f130b5f0 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json from typing import Any from agno.agent import Agent @@ -8,36 +7,25 @@ from langchain_core.messages import AIMessage, HumanMessage from loguru import logger -from ..models import ExecutionPlan, FinancialIntent, PlannedTask, Task +from ..models import ExecutionPlan, PlannedTask, Task from ..tool_registry import registry async def planner_node(state: dict[str, Any]) -> dict[str, Any]: """Iterative batch planner: generates the IMMEDIATE next batch of tasks. + Uses natural language current_intent as the primary instruction. Looks at execution_history to understand what has been done, - critique_feedback to fix any issues, and focus_topic to prioritize research focus. - - Two Modes: - - General Scan: Profile fully explored (focus_topic=None) -> Research all assets - - Surgical: Specific question (focus_topic=set) -> Research only relevant topics, ignore unrelated assets + and critique_feedback to fix any issues. """ - profile_dict = state.get("user_profile") or {} - profile = ( - FinancialIntent.model_validate(profile_dict) - if profile_dict - else FinancialIntent() - ) - + current_intent = state.get("current_intent") or "General financial analysis" execution_history = state.get("execution_history") or [] critique_feedback = state.get("critique_feedback") - focus_topic = state.get("focus_topic") logger.info( - "Planner start: profile={p}, history_len={h}, focus={f}", - p=profile.model_dump(), + "Planner start: intent='{i}', history_len={h}", + i=current_intent, h=len(execution_history), - f=focus_topic or "General", ) # Build iterative planning prompt @@ -67,31 +55,10 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: else: recent_context_text = "" - # Dynamic mode instruction based on focus_topic - if focus_topic: - mode_instruction = ( - f"šŸŽÆ **SURGICAL MODE** šŸŽÆ\n" - f'**Current User Question**: "{focus_topic}"\n' - "**Strategy**:\n" - "1. **FOCUS ONLY**: Research ONLY what's needed to answer this question.\n" - "2. **IGNORE IRRELEVANT ASSETS**: If the user has [AAPL, MSFT] but asks 'Tell me about MSFT earnings', " - "do NOT also fetch AAPL data.\n" - "3. **VERIFY FRESHNESS**: Check if the Execution History has *recent, specific* data for this question. " - "If the history only has generic data or is from a previous turn, GENERATE NEW TASKS.\n" - "4. **Be Surgical**: Don't over-fetch. Narrow your scope to the exact question.\n" - ) - else: - mode_instruction = ( - "šŸŒ **GENERAL SCAN MODE** šŸŒ\n" - "**Strategy**:\n" - "1. **COMPREHENSIVE**: Ensure ALL assets in the User Profile are researched.\n" - "2. **BALANCED**: Generate tasks that cover all dimensions (price, news, fundamentals) for each asset.\n" - ) - system_prompt_text = ( "You are an iterative financial planning agent.\n\n" - f"{mode_instruction}\n\n" - "**Your Role**: Decide the **IMMEDIATE next batch** of tasks.\n\n" + f"**CURRENT GOAL**: {current_intent}\n\n" + "**Your Role**: Decide the **IMMEDIATE next batch** of tasks to achieve this goal.\n\n" f"**Available Tools**:\n{tool_context}\n\n" "**Planning Rules**:\n" "1. **Iterative Planning**: Plan only the next step(s), not the entire workflow.\n" @@ -107,8 +74,7 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: f"{recent_context_text}**Execution History**:\n{history_text}{feedback_text}\n" ) - user_profile_json = json.dumps(profile.model_dump(), ensure_ascii=False) - user_msg = f"User Request Context: {user_profile_json}" + user_msg = f"Current Goal: {current_intent}" is_final = False strategy_update = "" diff --git a/python/valuecell/agents/react_agent/nodes/summarizer.py b/python/valuecell/agents/react_agent/nodes/summarizer.py index 1281a78d5..1ce8687e5 100644 --- a/python/valuecell/agents/react_agent/nodes/summarizer.py +++ b/python/valuecell/agents/react_agent/nodes/summarizer.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import os from typing import Any @@ -16,56 +15,38 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: """ Generate a polished final report using LangChain native model for streaming. - Respects focus_topic: if set, only addresses that specific question. - Otherwise, provides comprehensive overview of all requested assets. + Uses natural language current_intent to understand user's goal. """ - user_profile = state.get("user_profile") or {} + current_intent = state.get("current_intent") or "General financial analysis" execution_history = state.get("execution_history") or [] completed_tasks = state.get("completed_tasks") or {} - focus_topic = state.get("focus_topic") logger.info( - "Summarizer start: history_len={h}, tasks={t}, focus={f}", + "Summarizer start: intent='{i}', history_len={h}, tasks={t}", + i=current_intent, h=len(execution_history), t=len(completed_tasks), - f=focus_topic or "General", ) # 1. Extract context data_summary = _extract_key_results(completed_tasks) - # 2. Build focus-aware prompt - # Avoid interpolating `user_profile` (a dict) directly into the template string - # because its braces (e.g. "{'asset_symbols': ...}") are picked up by - # ChatPromptTemplate as template variables. Instead use placeholders - # and pass `user_profile` as a variable when invoking the chain. - immediate_task_section = ( - f'**IMMEDIATE TASK**: Answer ONLY this specific question: "{focus_topic}"\n' - "**INSTRUCTION**: Scan the 'Key Data' below to find evidence supporting this topic. " - "Synthesize the existing data to answer the question directly. Do not just summarize everything." - if focus_topic - else "" - ) - - system_template = f""" + # 2. Build prompt with current_intent + system_template = """ You are a concise Financial Assistant for beginner investors. Your goal is to synthesize the execution results into a short, actionable insight card. -**User Request**: -{{user_profile}} - -{immediate_task_section} +**User's Goal**: +{current_intent} **Key Data extracted from tools**: -{{data_summary}} +{data_summary} **Strict Constraints**: 1. **Length Limit**: Keep the total response under 400 words. Be ruthless with cutting fluff. -2. **Relevance Check**: - - If focus_topic is set, ONLY answer that question. Ignore unrelated data. - - If focus_topic is not set, ensure you address every asset requested. +2. **Relevance Check**: Ensure you address the user's stated goal. 3. **Completeness Check**: You MUST surface data errors explicitly. - - If data is missing or mismatched + - If data is missing or mismatched (e.g. "content seems to be AMD" when user asked for "AAPL"), you MUST write: "āš ļø Data Retrieval Issue: [Details]" 4. **No Generic Intros**: Start directly with the answer. 5. **Structure**: Use the format below. @@ -105,7 +86,7 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: # LangGraph automatically captures 'on_chat_model_stream' events here response = await chain.ainvoke( { - "user_profile": json.dumps(user_profile, ensure_ascii=False), + "current_intent": current_intent, "data_summary": data_summary, } ) diff --git a/python/valuecell/agents/react_agent/state.py b/python/valuecell/agents/react_agent/state.py index b38123174..687a4c1b9 100644 --- a/python/valuecell/agents/react_agent/state.py +++ b/python/valuecell/agents/react_agent/state.py @@ -10,9 +10,8 @@ class AgentState(TypedDict, total=False): # Conversation and intent messages: Annotated[List[BaseMessage], operator.add] - user_profile: dict[str, Any] | None + current_intent: str | None # Natural language description of user's immediate goal inquirer_turns: int - focus_topic: str | None # Specific user question/topic for Planner focus # Planning (iterative batch planning) plan: list[dict[str, Any]] | None # Current batch of tasks From f940dca0fcd512c7d92f053d9b103615f6291d14 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:41:05 +0800 Subject: [PATCH 27/50] make format --- python/valuecell/agents/react_agent/demo/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/valuecell/agents/react_agent/demo/server.py b/python/valuecell/agents/react_agent/demo/server.py index 852e1b099..0d2dc977e 100644 --- a/python/valuecell/agents/react_agent/demo/server.py +++ b/python/valuecell/agents/react_agent/demo/server.py @@ -13,7 +13,7 @@ from fastapi.encoders import jsonable_encoder from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse -from langchain_core.messages import HumanMessage, AIMessage +from langchain_core.messages import AIMessage, HumanMessage from loguru import logger from pydantic import BaseModel From 1555362503cbe42f9d93ffd6dbaf0a8f5c402f7d Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:34:52 +0800 Subject: [PATCH 28/50] feat(react-agent): implement streaming for React Agent execution with standardized protocol events --- .../valuecell/core/coordinate/orchestrator.py | 311 ++++++++++++++---- 1 file changed, 242 insertions(+), 69 deletions(-) diff --git a/python/valuecell/core/coordinate/orchestrator.py b/python/valuecell/core/coordinate/orchestrator.py index 7f8fc1e9c..dce942d5d 100644 --- a/python/valuecell/core/coordinate/orchestrator.py +++ b/python/valuecell/core/coordinate/orchestrator.py @@ -1,6 +1,7 @@ import asyncio from typing import AsyncGenerator, Dict, Optional +from langchain_core.messages import AIMessage, HumanMessage from loguru import logger from valuecell.core.constants import ORIGINAL_USER_INPUT, PLANNING_TASK @@ -95,6 +96,244 @@ def __init__( # ==================== Public API Methods ==================== + async def stream_react_agent( + self, user_input: UserInput, _response_thread_id: str + ) -> AsyncGenerator[BaseResponse, None]: + """ + Stream React Agent (LangGraph) execution as standardized protocol events. + + This function orchestrates the React Agent's multi-node graph execution + and converts internal LangGraph events into the frontend event protocol. + + Event Mappings: + - Planner output -> MESSAGE_CHUNK (TODO: Consider REASONING) + - Executor tasks -> TOOL_CALL_STARTED/COMPLETED (paired with consistent IDs) + - Critic feedback -> MESSAGE_CHUNK (TODO: Consider REASONING) + - Summarizer/Inquirer text -> MESSAGE_CHUNK + + Args: + user_input: User input containing query and conversation context + + Yields: + BaseResponse: Standardized protocol events for frontend consumption + """ + from valuecell.agents.react_agent.graph import get_app + + conversation_id = user_input.meta.conversation_id + + # ID Mapping: + # - LangGraph thread_id = conversation_id (for persistence) + # - EventService thread_id = freshly generated (transient stream session) + graph_thread_id = conversation_id + root_task_id = generate_task_id() + + logger.info( + "stream_react_agent: starting React Agent stream for conversation {}", + conversation_id, + ) + + graph = get_app() + inputs = {"messages": [HumanMessage(content=user_input.query)]} + config = {"configurable": {"thread_id": graph_thread_id}} + + # Track executor task IDs to pair STARTED/COMPLETED events + executor_tasks: Dict[str, str] = {} # task_id -> tool_name + + def is_real_node_output(d: dict) -> bool: + """Filter out router string outputs (e.g., 'wait', 'plan').""" + output = d.get("output") + return not isinstance(output, str) + + try: + async for event in graph.astream_events( + inputs, config=config, version="v2" + ): + kind = event.get("event", "") + node = event.get("metadata", {}).get("langgraph_node", "") + data = event.get("data") or {} + + # ================================================================= + # 1. PLANNER -> MESSAGE_CHUNK (TODO: Consider REASONING) + # ================================================================= + if kind == "on_chain_end" and node == "planner": + if is_real_node_output(data): + output = data.get("output", {}) + if isinstance(output, dict) and "plan" in output: + plan = output.get("plan", []) + reasoning = ( + output.get("plan_logic") + or output.get("strategy_update") + or "" + ) + + # Format plan as markdown + plan_md = f"\n\n**šŸ“… Plan Updated:**\n*{reasoning}*\n" + for task in plan: + task_id = task.get("id", "?") + desc = task.get("description", "No description") + plan_md += f"- `[{task_id}]` {desc}\n" + + # TODO: Consider switching to event_service.reasoning() + yield await self.event_service.emit( + self.event_service.factory.message_response_general( + event=StreamResponseEvent.MESSAGE_CHUNK, + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + content=plan_md, + agent_name="Planner", + ) + ) + + # ================================================================= + # 2. EXECUTOR -> TOOL_CALL (STARTED & COMPLETED) + # ================================================================= + + # Executor STARTED: Extract task metadata from input + elif kind == "on_chain_start" and node == "executor": + task_data = data.get("input", {}).get("task", {}) + task_id = task_data.get("id") + tool_name = task_data.get("tool_name", "unknown_tool") + + if task_id: + # Store for pairing with COMPLETED event + executor_tasks[task_id] = tool_name + + yield await self.event_service.emit( + self.event_service.factory.tool_call( + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + event=StreamResponseEvent.TOOL_CALL_STARTED, + tool_call_id=task_id, + tool_name=tool_name, + agent_name="Executor", + ) + ) + + # Executor COMPLETED: Extract results from output + elif kind == "on_chain_end" and node == "executor": + if is_real_node_output(data): + output = data.get("output", {}) + if isinstance(output, dict) and "completed_tasks" in output: + for task_id_key, res in output["completed_tasks"].items(): + # Extract result (could be dict or direct value) + if isinstance(res, dict): + raw_result = res.get("result", "") + else: + raw_result = str(res) + + # Retrieve tool name from STARTED event (fallback if missing) + tool_name = executor_tasks.get( + task_id_key, "completed_tool" + ) + + yield await self.event_service.emit( + self.event_service.factory.tool_call( + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + event=StreamResponseEvent.TOOL_CALL_COMPLETED, + tool_call_id=task_id_key, + tool_name=tool_name, + tool_result=raw_result, + agent_name="Executor", + ) + ) + + # ================================================================= + # 3. CRITIC -> MESSAGE_CHUNK (TODO: Consider REASONING) + # ================================================================= + elif kind == "on_chain_end" and node == "critic": + if is_real_node_output(data): + output = data.get("output", {}) + if isinstance(output, dict): + summary = output.get("_critic_summary") + if summary: + approved = summary.get("approved", False) + icon = "āœ…" if approved else "🚧" + reason = summary.get("reason") or summary.get( + "feedback", "" + ) + + critic_md = ( + f"\n\n**{icon} Critic Decision:** {reason}\n\n" + ) + + # TODO: Consider switching to event_service.reasoning() + yield await self.event_service.emit( + self.event_service.factory.message_response_general( + event=StreamResponseEvent.MESSAGE_CHUNK, + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + content=critic_md, + agent_name="Critic", + ) + ) + + # ================================================================= + # 4. SUMMARIZER / INQUIRER -> MESSAGE_CHUNK + # ================================================================= + + # Summarizer: Streaming content + elif kind == "on_chat_model_stream" and node == "summarizer": + chunk = data.get("chunk") + text = chunk.content if chunk else None + if text: + yield await self.event_service.emit( + self.event_service.factory.message_response_general( + event=StreamResponseEvent.MESSAGE_CHUNK, + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + content=text, + agent_name="Summarizer", + ) + ) + + # Inquirer: Static content (full message at end) + elif kind == "on_chain_end" and node == "inquirer": + if is_real_node_output(data): + output = data.get("output", {}) + msgs = output.get("messages", []) + if msgs and isinstance(msgs, list): + last_msg = msgs[-1] + if isinstance(last_msg, AIMessage) and last_msg.content: + yield await self.event_service.emit( + self.event_service.factory.message_response_general( + event=StreamResponseEvent.MESSAGE_CHUNK, + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + content=last_msg.content, + agent_name="Inquirer", + ) + ) + + logger.info( + "stream_react_agent: completed React Agent stream for conversation {}", + conversation_id, + ) + + except Exception as exc: + logger.exception( + f"stream_react_agent: execution failed for conversation {conversation_id}: {exc}" + ) + # Emit error message + yield await self.event_service.emit( + self.event_service.factory.message_response_general( + event=StreamResponseEvent.MESSAGE_CHUNK, + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + content=f"āš ļø System Error: {str(exc)}", + agent_name="System", + ) + ) + + # ==================== Public API Methods ==================== + async def process_user_input( self, user_input: UserInput ) -> AsyncGenerator[BaseResponse, None]: @@ -308,76 +547,10 @@ async def _handle_new_request( # 1) Super Agent triage phase (pre-planning) - skip if target agent is specified if user_input.target_agent_name == self.super_agent_service.name: - # Emit reasoning_started before streaming reasoning content - sa_task_id = generate_task_id() - sa_reasoning_item_id = generate_uuid("reasoning") - yield await self.event_service.emit( - self.event_service.factory.reasoning( - conversation_id, - thread_id, - task_id=sa_task_id, - event=StreamResponseEvent.REASONING_STARTED, - agent_name=self.super_agent_service.name, - item_id=sa_reasoning_item_id, - ), - ) - - # Stream reasoning content and collect final outcome - super_outcome: SuperAgentOutcome | None = None - async for item in self.super_agent_service.run(user_input): - if isinstance(item, str): - # Yield reasoning chunk - yield await self.event_service.emit( - self.event_service.factory.reasoning( - conversation_id, - thread_id, - task_id=sa_task_id, - event=StreamResponseEvent.REASONING, - content=item, - agent_name=self.super_agent_service.name, - item_id=sa_reasoning_item_id, - ), - ) - else: - # SuperAgentOutcome received - super_outcome = item - - # Emit reasoning_completed - yield await self.event_service.emit( - self.event_service.factory.reasoning( - conversation_id, - thread_id, - task_id=sa_task_id, - event=StreamResponseEvent.REASONING_COMPLETED, - agent_name=self.super_agent_service.name, - item_id=sa_reasoning_item_id, - ), - ) - - # Fallback if no outcome was received - if super_outcome is None: - super_outcome = SuperAgentOutcome( - decision=SuperAgentDecision.HANDOFF_TO_PLANNER, - enriched_query=user_input.query, - reason="No outcome received from SuperAgent", - ) - - if super_outcome.answer_content: - ans = self.event_service.factory.message_response_general( - StreamResponseEvent.MESSAGE_CHUNK, - conversation_id, - thread_id, - task_id=generate_task_id(), - content=super_outcome.answer_content, - agent_name=self.super_agent_service.name, - ) - yield await self.event_service.emit(ans) - if super_outcome.decision == SuperAgentDecision.ANSWER: - return + async for response in self.stream_react_agent(user_input, thread_id): + yield response - if super_outcome.decision == SuperAgentDecision.HANDOFF_TO_PLANNER: - user_input.target_agent_name = "" - user_input.query = super_outcome.enriched_query + return # 2) Planner phase (existing logic) # Create planning task with user input callback From 905349a3c6270cf58ae331b501480317513710bc Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:42:07 +0800 Subject: [PATCH 29/50] feat(planner): add description to Task instantiation in planner_node for better context --- python/valuecell/agents/react_agent/models.py | 4 ++++ python/valuecell/agents/react_agent/nodes/planner.py | 1 + 2 files changed, 5 insertions(+) diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py index 9f779d09a..d3a0079b5 100644 --- a/python/valuecell/agents/react_agent/models.py +++ b/python/valuecell/agents/react_agent/models.py @@ -9,6 +9,10 @@ class Task(BaseModel): id: str tool_name: str tool_args: dict[str, Any] = Field(default_factory=dict) + description: str = Field( + default="", + description="Optional human-friendly task description (from planner).", + ) class FinancialIntent(BaseModel): diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index 5f130b5f0..8c0069698 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -120,6 +120,7 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: id=pt.id, tool_name=pt.tool_id, tool_args=pt.tool_args, + description=pt.description or "No description provided by planner", ) ) From 1decadd4454a3816a388ccffc236d3e6e934445c Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:56:48 +0800 Subject: [PATCH 30/50] feat(react-agent): enhance logging with task descriptions and update state attributes for clarity --- .../agents/react_agent/demo/server.py | 3 +-- .../agents/react_agent/nodes/executor.py | 22 +++++++++---------- .../agents/react_agent/nodes/planner.py | 2 +- python/valuecell/agents/react_agent/state.py | 2 +- .../valuecell/core/coordinate/orchestrator.py | 12 ++-------- 5 files changed, 16 insertions(+), 25 deletions(-) diff --git a/python/valuecell/agents/react_agent/demo/server.py b/python/valuecell/agents/react_agent/demo/server.py index 0d2dc977e..e3ce19240 100644 --- a/python/valuecell/agents/react_agent/demo/server.py +++ b/python/valuecell/agents/react_agent/demo/server.py @@ -81,8 +81,7 @@ def is_real_node_output(d): "planner_update", { "plan": output.get("plan"), - "reasoning": output.get("plan_logic") - or output.get("strategy_update"), + "reasoning": output.get("strategy_update"), }, ) diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index 8c8c6914d..c5c013470 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -61,15 +61,19 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An - execution_history: [concise summary string] """ task_id = task.get("id") or "" + task_description = task.get("description") or "" tool = task.get("tool_name") or "" args = task.get("tool_args") or {} + task_brief = f"Task {task_description} (id={task_id}, tool={tool}, args={args})" - logger.info("Executor start: task_id={tid} tool={tool}", tid=task_id, tool=tool) + logger.info("Executor start: {task_brief}", task_brief=task_brief) # Idempotency guard: if this task is already completed, no-op completed_snapshot = (state.get("completed_tasks") or {}).keys() if task_id and task_id in completed_snapshot: - logger.info("Executor skip (already completed): task_id={tid}", tid=task_id) + logger.info( + "Executor skip (already completed): {task_brief}", task_brief=task_brief + ) return {} await _emit_progress(5, f"Starting with {task_id=}, {tool=}") @@ -79,13 +83,16 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An exec_res = ExecutorResult(task_id=task_id, ok=True, result=result) # Generate concise summary for execution history - summary = _generate_summary(task_id, tool, args, result) + result_preview = str(result) + if len(result_preview) > 100: + result_preview = result_preview[:100] + "..." + summary = f"{task_brief} completed. Result preview: {result_preview}" except Exception as exc: logger.warning("Executor error: {err}", err=str(exc)) exec_res = ExecutorResult( task_id=task_id, ok=False, error=str(exc), error_code="ERR_EXEC" ) - summary = f"Task {task_id} ({tool}) failed: {str(exc)[:50]}" + summary = f"{task_brief} failed: {str(exc)[:50]}" await _emit_progress(95, f"Finishing with {task_id=}, {tool=}") @@ -121,11 +128,4 @@ async def _emit_progress(percent: int, msg: str) -> None: pass -def _generate_summary(task_id: str, tool: str, args: dict, result: Any) -> str: - result_preview = str(result) - if len(result_preview) > 100: - result_preview = result_preview[:100] + "..." - return f"Task {task_id} ({tool} with args {args}): completed. Result preview: {result_preview}" - - ensure_default_tools_registered() diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index 8c0069698..ec0e130d7 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -129,7 +129,7 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: # Clear critique_feedback after consuming it return { "plan": [t.model_dump() for t in tasks], - "plan_logic": strategy_update, # For backwards compatibility + "strategy_update": strategy_update, "is_final": is_final, "critique_feedback": None, # Clear after consuming } diff --git a/python/valuecell/agents/react_agent/state.py b/python/valuecell/agents/react_agent/state.py index 687a4c1b9..e31aee637 100644 --- a/python/valuecell/agents/react_agent/state.py +++ b/python/valuecell/agents/react_agent/state.py @@ -15,7 +15,7 @@ class AgentState(TypedDict, total=False): # Planning (iterative batch planning) plan: list[dict[str, Any]] | None # Current batch of tasks - plan_logic: str | None # Deprecated: replaced by strategy_update + strategy_update: str | None # Latest planner reasoning summary # Execution results (merged across parallel executors) completed_tasks: Annotated[dict[str, Any], ior] diff --git a/python/valuecell/core/coordinate/orchestrator.py b/python/valuecell/core/coordinate/orchestrator.py index dce942d5d..243acb1a4 100644 --- a/python/valuecell/core/coordinate/orchestrator.py +++ b/python/valuecell/core/coordinate/orchestrator.py @@ -9,11 +9,7 @@ from valuecell.core.event import EventResponseService from valuecell.core.plan import PlanService from valuecell.core.plan.models import ExecutionPlan -from valuecell.core.super_agent import ( - SuperAgentDecision, - SuperAgentOutcome, - SuperAgentService, -) +from valuecell.core.super_agent import SuperAgentService from valuecell.core.task import TaskExecutor from valuecell.core.types import ( BaseResponse, @@ -160,11 +156,7 @@ def is_real_node_output(d: dict) -> bool: output = data.get("output", {}) if isinstance(output, dict) and "plan" in output: plan = output.get("plan", []) - reasoning = ( - output.get("plan_logic") - or output.get("strategy_update") - or "" - ) + reasoning = output.get("strategy_update") or "..." # Format plan as markdown plan_md = f"\n\n**šŸ“… Plan Updated:**\n*{reasoning}*\n" From f01ed9ed3df93fb68eb27055de75844266e3abc9 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:17:03 +0800 Subject: [PATCH 31/50] feat(react-agent): add description field to ExecutorResult for better task context and enhance summarization with execution history --- python/valuecell/agents/react_agent/models.py | 4 + .../agents/react_agent/nodes/executor.py | 10 ++- .../agents/react_agent/nodes/summarizer.py | 84 ++++++++++++------- 3 files changed, 66 insertions(+), 32 deletions(-) diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py index d3a0079b5..f0fb5b5fc 100644 --- a/python/valuecell/agents/react_agent/models.py +++ b/python/valuecell/agents/react_agent/models.py @@ -43,6 +43,10 @@ class ExecutorResult(BaseModel): task_id: str ok: bool = True result: Any | None = None + description: str = Field( + default="", + description="Human-friendly description of the task that produced this result", + ) error: Optional[str] = None error_code: Optional[str] = None # e.g., ERR_NETWORK, ERR_INPUT diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index c5c013470..01898b65f 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -80,7 +80,9 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An try: runtime_args = {"state": state} result = await registry.execute(tool, args, runtime_args=runtime_args) - exec_res = ExecutorResult(task_id=task_id, ok=True, result=result) + exec_res = ExecutorResult( + task_id=task_id, ok=True, result=result, description=task_description + ) # Generate concise summary for execution history result_preview = str(result) @@ -90,7 +92,11 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An except Exception as exc: logger.warning("Executor error: {err}", err=str(exc)) exec_res = ExecutorResult( - task_id=task_id, ok=False, error=str(exc), error_code="ERR_EXEC" + task_id=task_id, + ok=False, + error=str(exc), + error_code="ERR_EXEC", + description=task_description, ) summary = f"{task_brief} failed: {str(exc)[:50]}" diff --git a/python/valuecell/agents/react_agent/nodes/summarizer.py b/python/valuecell/agents/react_agent/nodes/summarizer.py index 1ce8687e5..d7169cb1d 100644 --- a/python/valuecell/agents/react_agent/nodes/summarizer.py +++ b/python/valuecell/agents/react_agent/nodes/summarizer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os from typing import Any @@ -18,7 +19,11 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: Uses natural language current_intent to understand user's goal. """ current_intent = state.get("current_intent") or "General financial analysis" + # TODO: provide relevant recent messages as context if needed execution_history = state.get("execution_history") or [] + execution_history_str = ( + "\n".join(execution_history) if execution_history else "(No history yet)" + ) completed_tasks = state.get("completed_tasks") or {} logger.info( @@ -31,38 +36,38 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: # 1. Extract context data_summary = _extract_key_results(completed_tasks) - # 2. Build prompt with current_intent + # 2. Build prompt with current_intent and adaptive formatting + # Note: intent analysis (is_comparison, is_question) can be used in future + # to select conditional structure; for now provide flexible formatting guidelines system_template = """ You are a concise Financial Assistant for beginner investors. -Your goal is to synthesize the execution results into a short, actionable insight card. +Your goal is to synthesize execution results and historical context to answer the user's specific goal. -**User's Goal**: +**User's Current Goal**: {current_intent} -**Key Data extracted from tools**: +**Data Sources**: + +**1. Aggregated Results** (Completed tasks — includes current round and previously merged results): {data_summary} +**2. Context History** (Previous findings and conclusions): +{execution_history} + **Strict Constraints**: -1. **Length Limit**: Keep the total response under 400 words. Be ruthless with cutting fluff. -2. **Relevance Check**: Ensure you address the user's stated goal. -3. **Completeness Check**: You MUST surface data errors explicitly. +1. **Multi-Source Synthesis**: Combine Aggregated Results AND Context History. +2. **Length Limit**: Keep the total response under 400 words. Be ruthless with cutting fluff. +3. **Relevance Check**: Ensure you address the user's stated goal completely. +4. **Completeness Check**: You MUST surface data errors explicitly. - If data is missing or mismatched (e.g. "content seems to be AMD" when user asked for "AAPL"), you MUST write: "āš ļø Data Retrieval Issue: [Details]" -4. **No Generic Intros**: Start directly with the answer. -5. **Structure**: Use the format below. - -**Required Structure**: -(1-2 sentences direct answer to user's question) - -## Key Findings -- **[Metric Name]**: Value (Interpretation) -(List top 3 metrics. If data is missing/error, state it here) - -## Analysis -(One short paragraph synthesizing the "Why". Connect the dots.) - -## Risk Note -(One specific risk factor found in the data) +5. **No Generic Intros**: Start directly with the answer. +6. **Adaptive Structure**: + - **General Analysis**: Use "Key Findings → Analysis → Risk Note" structure. + - **Comparison**: Use "Side-by-Side" approach. Highlight key differences and similarities. + - **Specific Question**: Answer DIRECTLY. No forced headers if not relevant. +7. **Markdown**: Always use Markdown. Bold all numbers and key metrics. +8. **Truthfulness**: If data is missing, state it explicitly: "Data not available for [X]". """ prompt = ChatPromptTemplate.from_messages( @@ -88,6 +93,7 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: { "current_intent": current_intent, "data_summary": data_summary, + "execution_history": execution_history_str, } ) @@ -113,7 +119,11 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: def _extract_key_results(completed_tasks: dict[str, Any]) -> str: - """Extract results with Error Highlighting.""" + """Extract results with JSON formatting and error highlighting. + + Prefers JSON for structured data (dicts/lists) for better LLM comprehension. + Falls back to string representation for simple values. + """ if not completed_tasks: return "(No results available)" @@ -123,6 +133,7 @@ def _extract_key_results(completed_tasks: dict[str, Any]) -> str: continue result = task_data.get("result") + desc = task_data.get("description") or "" # Handle errors reported by Executor if task_data.get("error"): @@ -132,10 +143,23 @@ def _extract_key_results(completed_tasks: dict[str, Any]) -> str: if not result: continue - preview = str(result) - if len(preview) > 500: - preview = preview[:500] + "... (truncated)" - - lines.append(f"- Task {task_id}: {preview}") - - return "\n".join(lines) + # Prefer JSON formatting for structured data; fallback to str() for simple values + if isinstance(result, (dict, list)): + try: + preview = json.dumps(result, ensure_ascii=False, indent=2) + except (TypeError, ValueError): + preview = str(result) + else: + preview = str(result) + + # Slightly higher truncation limit to preserve structured data context + if len(preview) > 1000: + preview = preview[:1000] + "\n... (truncated)" + + # Include description if available for better context in the summary + if desc: + lines.append(f"### Task {task_id}: {desc}\n{preview}") + else: + lines.append(f"### Task {task_id}\n{preview}") + + return "\n\n".join(lines) From 0da6c340a57501c331c246a91c6dea53ef1770a2 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:21:38 +0800 Subject: [PATCH 32/50] fix(inquirer): update conversation formatting to improve readability --- python/valuecell/agents/react_agent/nodes/inquirer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py index f7a1d5537..fe0e977d7 100644 --- a/python/valuecell/agents/react_agent/nodes/inquirer.py +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -156,7 +156,7 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: message_strs.append(f"[{role}]: {content}") conversation_text = ( - "\n".join(message_strs) if message_strs else "(No conversation yet)" + "\n\n".join(message_strs) if message_strs else "(No conversation yet)" ) user_msg = ( "# Conversation History:\n" From 92cffccf1caab8fede3ffd7abe2684a74aff739a Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:22:47 +0800 Subject: [PATCH 33/50] make lint --- python/valuecell/agents/react_agent/demo/server.py | 1 - python/valuecell/agents/react_agent/nodes/inquirer.py | 2 +- python/valuecell/agents/react_agent/tools/research.py | 3 ++- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/python/valuecell/agents/react_agent/demo/server.py b/python/valuecell/agents/react_agent/demo/server.py index e3ce19240..1ef96f4a5 100644 --- a/python/valuecell/agents/react_agent/demo/server.py +++ b/python/valuecell/agents/react_agent/demo/server.py @@ -128,7 +128,6 @@ def is_real_node_output(d): text = chunk.content if chunk else None if text: yield format_sse("content_token", {"delta": text}) - last_emitted_text = text # Track to avoid duplicates if mixed # STATIC CONTENT (Inquirer / Fallback) # Inquirer returns a full AIMessage at the end, not streamed diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py index fe0e977d7..d465a4015 100644 --- a/python/valuecell/agents/react_agent/nodes/inquirer.py +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -7,7 +7,7 @@ from langchain_core.messages import AIMessage, SystemMessage from loguru import logger -from ..models import FinancialIntent, InquirerDecision +from ..models import InquirerDecision # TODO: summarize with LLM diff --git a/python/valuecell/agents/react_agent/tools/research.py b/python/valuecell/agents/react_agent/tools/research.py index 15377a307..44645e0ac 100644 --- a/python/valuecell/agents/react_agent/tools/research.py +++ b/python/valuecell/agents/react_agent/tools/research.py @@ -14,7 +14,8 @@ fetch_event_sec_filings, fetch_periodic_sec_filings, ) -from valuecell.utils.env import agent_debug_mode_enabled + +# from valuecell.utils.env import agent_debug_mode_enabled research_agent: None | Agent = None From f516b2dc27d4c96ced6908a8981b0611815a6b0b Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:23:10 +0800 Subject: [PATCH 34/50] make lint --- python/valuecell/agents/react_agent/demo/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/valuecell/agents/react_agent/demo/server.py b/python/valuecell/agents/react_agent/demo/server.py index 1ef96f4a5..0725fa90b 100644 --- a/python/valuecell/agents/react_agent/demo/server.py +++ b/python/valuecell/agents/react_agent/demo/server.py @@ -52,7 +52,6 @@ async def event_stream_generator(user_input: str, thread_id: str): config = {"configurable": {"thread_id": thread_id}} logger.info(f"Stream start: {thread_id}") - last_emitted_text: str | None = None async for event in graph.astream_events(inputs, config=config, version="v2"): kind = event.get("event", "") From b7ba4c1eef714ab4cd14d55af979e649af220289 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:24:57 +0800 Subject: [PATCH 35/50] refactor(state): remove unused fields from state class for cleaner code --- python/valuecell/agents/react_agent/state.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/python/valuecell/agents/react_agent/state.py b/python/valuecell/agents/react_agent/state.py index e31aee637..bea1df6c3 100644 --- a/python/valuecell/agents/react_agent/state.py +++ b/python/valuecell/agents/react_agent/state.py @@ -32,7 +32,3 @@ class AgentState(TypedDict, total=False): # Critic decision next_action: Any | None _critic_summary: Any | None - - # Misc - missing_info_field: str | None - user_supplement_info: Any | None From d674512bd9fc5257d73ebad719d538eb48e03950 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:51:55 +0800 Subject: [PATCH 36/50] fix(summarizer): refine Markdown guideline to emphasize key metrics or information --- python/valuecell/agents/react_agent/nodes/summarizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/valuecell/agents/react_agent/nodes/summarizer.py b/python/valuecell/agents/react_agent/nodes/summarizer.py index d7169cb1d..fb6409c6f 100644 --- a/python/valuecell/agents/react_agent/nodes/summarizer.py +++ b/python/valuecell/agents/react_agent/nodes/summarizer.py @@ -66,7 +66,7 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: - **General Analysis**: Use "Key Findings → Analysis → Risk Note" structure. - **Comparison**: Use "Side-by-Side" approach. Highlight key differences and similarities. - **Specific Question**: Answer DIRECTLY. No forced headers if not relevant. -7. **Markdown**: Always use Markdown. Bold all numbers and key metrics. +7. **Markdown**: Always use Markdown. Bold key metrics or information. 8. **Truthfulness**: If data is missing, state it explicitly: "Data not available for [X]". """ From 01b47cb83167677b78e95576ea4861ee5e08b903 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:53:53 +0800 Subject: [PATCH 37/50] refactor(executor): enhance task metadata handling and improve tool name display --- python/valuecell/agents/react_agent/models.py | 1 + .../agents/react_agent/nodes/executor.py | 22 +++--- .../valuecell/core/coordinate/orchestrator.py | 67 ++++++++++++++----- 3 files changed, 64 insertions(+), 26 deletions(-) diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py index f0fb5b5fc..e5b78e1a5 100644 --- a/python/valuecell/agents/react_agent/models.py +++ b/python/valuecell/agents/react_agent/models.py @@ -43,6 +43,7 @@ class ExecutorResult(BaseModel): task_id: str ok: bool = True result: Any | None = None + tool_name: str | None = None description: str = Field( default="", description="Human-friendly description of the task that produced this result", diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index 01898b65f..06f62552b 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -62,9 +62,11 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An """ task_id = task.get("id") or "" task_description = task.get("description") or "" - tool = task.get("tool_name") or "" - args = task.get("tool_args") or {} - task_brief = f"Task {task_description} (id={task_id}, tool={tool}, args={args})" + tool_name = task.get("tool_name") or "" + tool_args = task.get("tool_args") or {} + task_brief = ( + f"Task {task_description} (id={task_id}, tool={tool_name}, args={tool_args})" + ) logger.info("Executor start: {task_brief}", task_brief=task_brief) @@ -75,13 +77,16 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An "Executor skip (already completed): {task_brief}", task_brief=task_brief ) return {} - await _emit_progress(5, f"Starting with {task_id=}, {tool=}") try: runtime_args = {"state": state} - result = await registry.execute(tool, args, runtime_args=runtime_args) + result = await registry.execute(tool_name, tool_args, runtime_args=runtime_args) exec_res = ExecutorResult( - task_id=task_id, ok=True, result=result, description=task_description + task_id=task_id, + ok=True, + result=result, + tool_name=tool_name, + description=task_description, ) # Generate concise summary for execution history @@ -96,12 +101,11 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An ok=False, error=str(exc), error_code="ERR_EXEC", + tool_name=tool_name, description=task_description, ) summary = f"{task_brief} failed: {str(exc)[:50]}" - await _emit_progress(95, f"Finishing with {task_id=}, {tool=}") - # Return delta for completed_tasks and execution_history completed_delta = {task_id: exec_res.model_dump()} @@ -114,13 +118,13 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An except Exception: pass - await _emit_progress(100, f"Done with {task_id=}, {tool=}") return { "completed_tasks": completed_delta, "execution_history": [summary], } +# TODO: display progress updates from within execution async def _emit_progress(percent: int, msg: str) -> None: try: payload = { diff --git a/python/valuecell/core/coordinate/orchestrator.py b/python/valuecell/core/coordinate/orchestrator.py index 243acb1a4..b9f2dc560 100644 --- a/python/valuecell/core/coordinate/orchestrator.py +++ b/python/valuecell/core/coordinate/orchestrator.py @@ -132,8 +132,8 @@ async def stream_react_agent( inputs = {"messages": [HumanMessage(content=user_input.query)]} config = {"configurable": {"thread_id": graph_thread_id}} - # Track executor task IDs to pair STARTED/COMPLETED events - executor_tasks: Dict[str, str] = {} # task_id -> tool_name + # Note: executor task pairing will read tool info from executor output. + # No STARTED->COMPLETED mapping stored here (executor provides `tool`/`tool_name`). def is_real_node_output(d: dict) -> bool: """Filter out router string outputs (e.g., 'wait', 'plan').""" @@ -181,16 +181,29 @@ def is_real_node_output(d: dict) -> bool: # 2. EXECUTOR -> TOOL_CALL (STARTED & COMPLETED) # ================================================================= - # Executor STARTED: Extract task metadata from input + # --------------------------------------------------------- + # Case A: Executor STARTED + # --------------------------------------------------------- elif kind == "on_chain_start" and node == "executor": task_data = data.get("input", {}).get("task", {}) task_id = task_data.get("id") - tool_name = task_data.get("tool_name", "unknown_tool") + raw_tool_name = task_data.get("tool_name", "unknown_tool") + task_description = task_data.get("description", "") + + # [Optimization] Combine description and tool name for UI + # Format: "Get Stock Price (web_search)" + if task_description: + # Optional: Truncate description if it's too long for a header + short_desc = ( + (task_description[:60] + "...") + if len(task_description) > 60 + else task_description + ) + display_tool_name = f"{short_desc} ({raw_tool_name})" + else: + display_tool_name = raw_tool_name if task_id: - # Store for pairing with COMPLETED event - executor_tasks[task_id] = tool_name - yield await self.event_service.emit( self.event_service.factory.tool_call( conversation_id=conversation_id, @@ -198,27 +211,47 @@ def is_real_node_output(d: dict) -> bool: task_id=root_task_id, event=StreamResponseEvent.TOOL_CALL_STARTED, tool_call_id=task_id, - tool_name=tool_name, + tool_name=display_tool_name, agent_name="Executor", ) ) - # Executor COMPLETED: Extract results from output + # --------------------------------------------------------- + # Case B: Executor COMPLETED + # --------------------------------------------------------- elif kind == "on_chain_end" and node == "executor": if is_real_node_output(data): output = data.get("output", {}) if isinstance(output, dict) and "completed_tasks" in output: for task_id_key, res in output["completed_tasks"].items(): - # Extract result (could be dict or direct value) + # 1. Extract Result if isinstance(res, dict): - raw_result = res.get("result", "") + # Try to get 'result' field, fallback to full dict dump + raw_result = res.get("result") or str(res) + + # Try to retrieve metadata preserved by executor + res_tool_name = ( + res.get("tool_name") or "completed_tool" + ) + res_description = res.get("description") else: raw_result = str(res) - - # Retrieve tool name from STARTED event (fallback if missing) - tool_name = executor_tasks.get( - task_id_key, "completed_tool" - ) + res_tool_name = "completed_tool" + res_description = None + + # 2. Re-construct the display name to match STARTED event + # This ensures the UI updates the correct item instead of creating a new one + if res_description: + short_desc = ( + (res_description[:60] + "...") + if len(res_description) > 60 + else res_description + ) + display_tool_name = ( + f"{short_desc} ({res_tool_name})" + ) + else: + display_tool_name = res_tool_name yield await self.event_service.emit( self.event_service.factory.tool_call( @@ -227,7 +260,7 @@ def is_real_node_output(d: dict) -> bool: task_id=root_task_id, event=StreamResponseEvent.TOOL_CALL_COMPLETED, tool_call_id=task_id_key, - tool_name=tool_name, + tool_name=display_tool_name, tool_result=raw_result, agent_name="Executor", ) From 05d1ad6a45fed4336d719d5fce7f8c0f8a580804 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:14:38 +0800 Subject: [PATCH 38/50] refactor(critic): enhance user intent handling and improve execution log formatting --- .../agents/react_agent/nodes/critic.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/critic.py b/python/valuecell/agents/react_agent/nodes/critic.py index 82402fa4a..e9981ee6d 100644 --- a/python/valuecell/agents/react_agent/nodes/critic.py +++ b/python/valuecell/agents/react_agent/nodes/critic.py @@ -35,8 +35,8 @@ async def critic_node(state: dict[str, Any]) -> dict[str, Any]: - If approved: Return next_action="exit" - If rejected: Return critique_feedback and next_action="replan" """ - user_profile = state.get("user_profile") or {} execution_history = state.get("execution_history") or [] + current_intent = state.get("current_intent") or "" is_final = state.get("is_final", False) # Safety check: Critic should only run when planner claims done @@ -47,27 +47,31 @@ async def critic_node(state: dict[str, Any]) -> dict[str, Any]: "critique_feedback": "Planner has not completed the workflow.", } - history_text = "\n".join(execution_history) if execution_history else "(Empty)" + history_text = "\n\n".join(execution_history) if execution_history else "(Empty)" system_prompt = ( "You are a gatekeeper critic for an iterative financial planning system.\n\n" - "**Your Role**: Compare the User's Request with the Execution History.\n" + "**Your Role**: Compare the User's Request (current_intent) with the Execution History.\n" "- If the goal is fully satisfied, approve (approved=True).\n" "- If something is missing or incomplete, reject (approved=False) and provide specific feedback.\n\n" "**Decision Criteria**:\n" "1. All requested tasks completed successfully.\n" "2. No critical errors that prevent goal satisfaction.\n" - "3. Results align with user's intent.\n" + "3. Results align with user's intent (current_intent).\n" "4. **Synthesis Phase**: If sufficient research/data-gathering tasks are complete to answer the user's request, " "APPROVE the plan. The system will synthesize the final response from the execution history. " "Do NOT demand an explicit 'generate_report' or 'create_plan' task when the necessary data is already available.\n" ) - context = { - "user_request": user_profile, - "execution_history": history_text, - } - user_msg = json.dumps(context, ensure_ascii=False) + user_msg = f"""# TARGET GOAL (User Intent): +"{current_intent}" + +# ACTUAL EXECUTION LOG: +{history_text} + +# INSTRUCTION: +Check if the "ACTUAL EXECUTION LOG" provides enough evidence to fulfill the "TARGET GOAL" +""" try: agent = Agent( From b7d3266373af7debf184b6eeb12bde4869829719 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:14:58 +0800 Subject: [PATCH 39/50] refactor(executor): enhance task result handling and improve execution summary formatting --- .../agents/react_agent/nodes/executor.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index 06f62552b..dd11d38c6 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import Any, Callable from langchain_core.callbacks import adispatch_custom_event @@ -65,7 +66,7 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An tool_name = task.get("tool_name") or "" tool_args = task.get("tool_args") or {} task_brief = ( - f"Task {task_description} (id={task_id}, tool={tool_name}, args={tool_args})" + f"Task `{task_description}` (id={task_id}, tool={tool_name}, args={tool_args})" ) logger.info("Executor start: {task_brief}", task_brief=task_brief) @@ -89,11 +90,24 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An description=task_description, ) - # Generate concise summary for execution history - result_preview = str(result) - if len(result_preview) > 100: - result_preview = result_preview[:100] + "..." - summary = f"{task_brief} completed. Result preview: {result_preview}" + if isinstance(result, (dict, list)): + result_str = json.dumps(result, ensure_ascii=False) + else: + result_str = str(result) + + if len(result_str) > 800: + result_preview = result_str[:800] + "... (truncated)" + else: + result_preview = result_str + + summary = f""" + {task_description} + {"SUCCESS" if exec_res.ok else "FAILURE"} + +{result_preview} + + +""" except Exception as exc: logger.warning("Executor error: {err}", err=str(exc)) exec_res = ExecutorResult( From 1e8a2efd7599c61e8b0cb0b6746c32614ec601d4 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:15:04 +0800 Subject: [PATCH 40/50] refactor(summarizer): streamline execution context handling and enhance error reporting in task summaries --- .../agents/react_agent/nodes/summarizer.py | 38 ++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/summarizer.py b/python/valuecell/agents/react_agent/nodes/summarizer.py index fb6409c6f..f72ab59fa 100644 --- a/python/valuecell/agents/react_agent/nodes/summarizer.py +++ b/python/valuecell/agents/react_agent/nodes/summarizer.py @@ -19,17 +19,11 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: Uses natural language current_intent to understand user's goal. """ current_intent = state.get("current_intent") or "General financial analysis" - # TODO: provide relevant recent messages as context if needed - execution_history = state.get("execution_history") or [] - execution_history_str = ( - "\n".join(execution_history) if execution_history else "(No history yet)" - ) completed_tasks = state.get("completed_tasks") or {} logger.info( - "Summarizer start: intent='{i}', history_len={h}, tasks={t}", + "Summarizer start: intent='{i}', tasks={t}", i=current_intent, - h=len(execution_history), t=len(completed_tasks), ) @@ -41,21 +35,16 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: # to select conditional structure; for now provide flexible formatting guidelines system_template = """ You are a concise Financial Assistant for beginner investors. -Your goal is to synthesize execution results and historical context to answer the user's specific goal. +Your goal is to synthesize execution results to answer the user's specific goal. **User's Current Goal**: {current_intent} -**Data Sources**: - -**1. Aggregated Results** (Completed tasks — includes current round and previously merged results): +**Available Data** (Execution Results): {data_summary} -**2. Context History** (Previous findings and conclusions): -{execution_history} - **Strict Constraints**: -1. **Multi-Source Synthesis**: Combine Aggregated Results AND Context History. +1. **Source of Truth**: Use the data provided in "Available Data" above as your single source. 2. **Length Limit**: Keep the total response under 400 words. Be ruthless with cutting fluff. 3. **Relevance Check**: Ensure you address the user's stated goal completely. 4. **Completeness Check**: You MUST surface data errors explicitly. @@ -77,7 +66,7 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: # 3. Initialize LangChain Model (Native Streaming Support) # Using ChatOpenAI to connect to OpenRouter (compatible API) llm = ChatOpenAI( - model="google/gemini-2.5-flash", + model="google/gemini-2.5-pro", base_url="https://openrouter.ai/api/v1", api_key=os.getenv("OPENROUTER_API_KEY"), # Ensure ENV is set temperature=0, @@ -93,7 +82,6 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: { "current_intent": current_intent, "data_summary": data_summary, - "execution_history": execution_history_str, } ) @@ -137,7 +125,12 @@ def _extract_key_results(completed_tasks: dict[str, Any]) -> str: # Handle errors reported by Executor if task_data.get("error"): - lines.append(f"- Task {task_id} [FAILED]: {task_data['error']}") + error_msg = task_data["error"] + error_code = task_data.get("error_code", "") + error_info = f"**Error**: {error_msg}" + if error_code: + error_info += f" (Code: {error_code})" + lines.append(f"### Task {task_id} [FAILED]\n{error_info}") continue if not result: @@ -156,10 +149,11 @@ def _extract_key_results(completed_tasks: dict[str, Any]) -> str: if len(preview) > 1000: preview = preview[:1000] + "\n... (truncated)" - # Include description if available for better context in the summary + # Build header with description (critical for Summarizer to understand task purpose) + header = f"### Task {task_id}" if desc: - lines.append(f"### Task {task_id}: {desc}\n{preview}") - else: - lines.append(f"### Task {task_id}\n{preview}") + header += f": {desc}" + + lines.append(f"{header}\n{preview}") return "\n\n".join(lines) From b12d2fdd3d2d3a5188d20aea96a8d0b4179f9b37 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:15:08 +0800 Subject: [PATCH 41/50] refactor(planner): remove unused variables and streamline task planning logic --- python/valuecell/agents/react_agent/nodes/planner.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index ec0e130d7..eda523480 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -76,11 +76,7 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: user_msg = f"Current Goal: {current_intent}" - is_final = False - strategy_update = "" # TODO: organize plan like a TODO list - planned_tasks: list[PlannedTask] = [] - try: agent = Agent( model=OpenRouter(id="google/gemini-2.5-flash"), From b41bfa46f8c09c12eb8543dcc99538da5608f6bc Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:35:23 +0800 Subject: [PATCH 42/50] refactor: add user context and datetime handling across agent nodes --- python/valuecell/agents/react_agent/nodes/critic.py | 1 + .../valuecell/agents/react_agent/nodes/inquirer.py | 1 + .../valuecell/agents/react_agent/nodes/planner.py | 1 + .../agents/react_agent/nodes/summarizer.py | 13 ++++++++++++- python/valuecell/agents/react_agent/state.py | 5 +++++ python/valuecell/core/coordinate/orchestrator.py | 10 +++++++++- 6 files changed, 29 insertions(+), 2 deletions(-) diff --git a/python/valuecell/agents/react_agent/nodes/critic.py b/python/valuecell/agents/react_agent/nodes/critic.py index e9981ee6d..0d4f4224d 100644 --- a/python/valuecell/agents/react_agent/nodes/critic.py +++ b/python/valuecell/agents/react_agent/nodes/critic.py @@ -80,6 +80,7 @@ async def critic_node(state: dict[str, Any]) -> dict[str, Any]: markdown=False, output_schema=CriticDecision, debug_mode=True, + add_datetime_to_context=True, ) response = await agent.arun(user_msg) decision: CriticDecision = response.content diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py index d465a4015..412482a76 100644 --- a/python/valuecell/agents/react_agent/nodes/inquirer.py +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -173,6 +173,7 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: markdown=False, output_schema=InquirerDecision, debug_mode=True, + add_datetime_to_context=True, ) response = await agent.arun(user_msg) decision: InquirerDecision = response.content diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index eda523480..07cf42d3b 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -84,6 +84,7 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: markdown=False, output_schema=ExecutionPlan, debug_mode=True, + add_datetime_to_context=True, ) response = await agent.arun(user_msg) plan_obj: ExecutionPlan = response.content diff --git a/python/valuecell/agents/react_agent/nodes/summarizer.py b/python/valuecell/agents/react_agent/nodes/summarizer.py index f72ab59fa..9abbc2902 100644 --- a/python/valuecell/agents/react_agent/nodes/summarizer.py +++ b/python/valuecell/agents/react_agent/nodes/summarizer.py @@ -9,6 +9,8 @@ from langchain_openai import ChatOpenAI from loguru import logger +from valuecell.utils import i18n_utils + from ..state import AgentState @@ -20,6 +22,8 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: """ current_intent = state.get("current_intent") or "General financial analysis" completed_tasks = state.get("completed_tasks") or {} + user_context = state.get("user_context") or {} + current_datetime = i18n_utils.format_utc_datetime(i18n_utils.get_utc_now()) logger.info( "Summarizer start: intent='{i}', tasks={t}", @@ -40,6 +44,11 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: **User's Current Goal**: {current_intent} +**User Context**: +{user_context} + +**Current Date and Time**: {current_datetime} + **Available Data** (Execution Results): {data_summary} @@ -66,7 +75,7 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: # 3. Initialize LangChain Model (Native Streaming Support) # Using ChatOpenAI to connect to OpenRouter (compatible API) llm = ChatOpenAI( - model="google/gemini-2.5-pro", + model="google/gemini-2.5-flash", base_url="https://openrouter.ai/api/v1", api_key=os.getenv("OPENROUTER_API_KEY"), # Ensure ENV is set temperature=0, @@ -82,6 +91,8 @@ async def summarizer_node(state: AgentState) -> dict[str, Any]: { "current_intent": current_intent, "data_summary": data_summary, + "user_context": user_context, + "current_datetime": current_datetime, } ) diff --git a/python/valuecell/agents/react_agent/state.py b/python/valuecell/agents/react_agent/state.py index bea1df6c3..f15813e06 100644 --- a/python/valuecell/agents/react_agent/state.py +++ b/python/valuecell/agents/react_agent/state.py @@ -6,6 +6,8 @@ from langchain_core.messages import BaseMessage +from .models import ARG_VAL_TYPES + class AgentState(TypedDict, total=False): # Conversation and intent @@ -32,3 +34,6 @@ class AgentState(TypedDict, total=False): # Critic decision next_action: Any | None _critic_summary: Any | None + + # User context / preferences (optional) + user_context: dict[str, ARG_VAL_TYPES] | None diff --git a/python/valuecell/core/coordinate/orchestrator.py b/python/valuecell/core/coordinate/orchestrator.py index b9f2dc560..ed95ecda7 100644 --- a/python/valuecell/core/coordinate/orchestrator.py +++ b/python/valuecell/core/coordinate/orchestrator.py @@ -16,6 +16,7 @@ StreamResponseEvent, UserInput, ) +from valuecell.utils import i18n_utils from valuecell.utils.uuid import generate_task_id, generate_thread_id, generate_uuid from .services import AgentServiceBundle @@ -129,7 +130,14 @@ async def stream_react_agent( ) graph = get_app() - inputs = {"messages": [HumanMessage(content=user_input.query)]} + user_context = { + "language": i18n_utils.get_current_language(), + "timezone": i18n_utils.get_current_timezone(), + } + inputs = { + "messages": [HumanMessage(content=user_input.query)], + "user_context": user_context, + } config = {"configurable": {"thread_id": graph_thread_id}} # Note: executor task pairing will read tool info from executor output. From 1736d945b7c8bc250866ecdcc2d7b04dcea89119 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 17:54:35 +0800 Subject: [PATCH 43/50] add todos --- python/valuecell/agents/react_agent/nodes/planner.py | 3 ++- python/valuecell/core/coordinate/orchestrator.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index 07cf42d3b..27111b212 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -7,10 +7,11 @@ from langchain_core.messages import AIMessage, HumanMessage from loguru import logger -from ..models import ExecutionPlan, PlannedTask, Task +from ..models import ExecutionPlan, Task from ..tool_registry import registry +# TODO: route human-in-the-loop feedback to user async def planner_node(state: dict[str, Any]) -> dict[str, Any]: """Iterative batch planner: generates the IMMEDIATE next batch of tasks. diff --git a/python/valuecell/core/coordinate/orchestrator.py b/python/valuecell/core/coordinate/orchestrator.py index ed95ecda7..5a7f46419 100644 --- a/python/valuecell/core/coordinate/orchestrator.py +++ b/python/valuecell/core/coordinate/orchestrator.py @@ -134,6 +134,7 @@ async def stream_react_agent( "language": i18n_utils.get_current_language(), "timezone": i18n_utils.get_current_timezone(), } + # TODO: append previous conversation history after restart inputs = { "messages": [HumanMessage(content=user_input.query)], "user_context": user_context, From cd508e67bbd6c30314a13b817ba7e15d180810e3 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 11 Dec 2025 17:56:12 +0800 Subject: [PATCH 44/50] refactor(executor): update tool registration to include AlphaVantage functions --- .../agents/react_agent/nodes/executor.py | 13 +- .../agents/react_agent/tools/alphavantage.py | 462 ++++++++++++++++++ 2 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 python/valuecell/agents/react_agent/tools/alphavantage.py diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index dd11d38c6..7d8790700 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -16,7 +16,13 @@ from ..models import ExecutorResult from ..state import AgentState from ..tool_registry import registry -from ..tools.research import research +from ..tools.alphavantage import ( + get_financial_metrics, + get_market_sentiment, + get_stock_profile, +) + +# from ..tools.research import research _TOOLS_REGISTERED = False @@ -26,11 +32,14 @@ def ensure_default_tools_registered() -> None: if _TOOLS_REGISTERED: return - _register_tool("research", research) + # _register_tool("research", research) _register_tool("search_crypto_people", search_crypto_people) _register_tool("search_crypto_projects", search_crypto_projects) _register_tool("search_crypto_vcs", search_crypto_vcs) _register_tool("web_search", web_search) + _register_tool("get_financial_metrics", get_financial_metrics) + _register_tool("get_stock_profile", get_stock_profile) + _register_tool("get_market_sentiment", get_market_sentiment) _TOOLS_REGISTERED = True diff --git a/python/valuecell/agents/react_agent/tools/alphavantage.py b/python/valuecell/agents/react_agent/tools/alphavantage.py new file mode 100644 index 000000000..8c06a1a91 --- /dev/null +++ b/python/valuecell/agents/react_agent/tools/alphavantage.py @@ -0,0 +1,462 @@ +import asyncio +import os +from datetime import datetime +from typing import Any, Literal + +import httpx +import pandas as pd +from loguru import logger + + +async def _fetch_alpha_vantage( + function: str, symbol: str | None = None, extra_params: dict | None = None +) -> dict[str, Any]: + """ + Robust fetcher for AlphaVantage API. + + Features: + - Handles 'symbol' vs 'tickers' parameter automatically. + - Detects API-level errors (Rate Limits, Invalid Token) even on HTTP 200. + - Merges extra parameters (e.g. limit, sort, outputsize). + """ + api_key = os.getenv("ALPHA_VANTAGE_API_KEY") + if not api_key: + logger.error("Missing ALPHA_VANTAGE_API_KEY environment variable") + return {"error": "Configuration error: API key missing"} + + base_url = "https://www.alphavantage.co/query" + + # 1. Build Query Parameters + params = {"function": function, "apikey": api_key} + + # Handle Symbol Mapping + # NEWS_SENTIMENT uses 'tickers', most others use 'symbol' + if symbol: + if function == "NEWS_SENTIMENT": + params["tickers"] = symbol + else: + params["symbol"] = symbol + + if extra_params: + params.update(extra_params) + + # 2. Execute Request + try: + async with httpx.AsyncClient(timeout=15.0) as client: + logger.debug(f"AlphaVantage Request: {function} for {symbol or 'General'}") + + resp = await client.get(base_url, params=params) + resp.raise_for_status() + data = resp.json() + + # 3. AlphaVantage Specific Error Handling + # AlphaVantage returns 200 OK even for errors, we must check the keys. + + # Case A: Rate Limit Hit (Common on free tier) + # Usually contains "Note" or "Information" + if "Note" in data or "Information" in data: + msg = data.get("Note") or data.get("Information") + logger.warning(f"AlphaVantage Rate Limit/Info: {msg}") + # Optional: Implement retry logic here if needed + return {"error": f"API Rate Limit reached: {msg}"} + + # Case B: Invalid API Call + if "Error Message" in data: + msg = data["Error Message"] + logger.error(f"AlphaVantage API Error: {msg}") + return {"error": f"Invalid API call: {msg}"} + + # Case C: Empty Result (Symbol not found) + if not data: + return {"error": "No data returned (Symbol might be invalid)"} + + return data + + except httpx.TimeoutException: + logger.error("AlphaVantage Request Timed out") + return {"error": "Data provider request timed out"} + + except Exception as exc: + logger.exception(f"AlphaVantage Unhandled Error: {exc}") + return {"error": str(exc)} + + +async def get_financial_metrics( + symbol: str, period: Literal["annual", "quarterly"] = "annual", limit: int = 4 +) -> str: + """ + Retrieves detailed financial metrics for a stock symbol using AlphaVantage. + Automatically calculates ratios like Margins, ROE, and Debt/Equity. + + Args: + symbol: The stock ticker (e.g., 'IBM', 'AAPL'). + period: 'annual' for yearly reports, 'quarterly' for recent quarters. + limit: Number of periods to return (default 4). Keep low to save tokens. + """ + + # Sequentially fetch endpoints with short delays to avoid AlphaVantage "burst" rate-limiting. + try: + # 1) Income Statement + data_inc = await _fetch_alpha_vantage( + symbol=symbol, function="INCOME_STATEMENT" + ) + await asyncio.sleep(1.5) + + # 2) Balance Sheet + data_bal = await _fetch_alpha_vantage(symbol=symbol, function="BALANCE_SHEET") + await asyncio.sleep(1.5) + + # 3) Cash Flow + data_cash = await _fetch_alpha_vantage(symbol=symbol, function="CASH_FLOW") + + # If any endpoint returned an API-level error, surface it for clarity. + if isinstance(data_inc, dict) and "error" in data_inc: + return f"API Error (Income): {data_inc['error']}" + if isinstance(data_bal, dict) and "error" in data_bal: + return f"API Error (Balance): {data_bal['error']}" + if isinstance(data_cash, dict) and "error" in data_cash: + return f"API Error (Cash): {data_cash['error']}" + + # Normalize any remaining error-bearing responses into empty dicts for downstream logic + data_inc = ( + data_inc if not (isinstance(data_inc, dict) and "error" in data_inc) else {} + ) + data_bal = ( + data_bal if not (isinstance(data_bal, dict) and "error" in data_bal) else {} + ) + data_cash = ( + data_cash + if not (isinstance(data_cash, dict) and "error" in data_cash) + else {} + ) + + except Exception as e: + return f"API Error: {str(e)}" + + report_key = "annualReports" if period == "annual" else "quarterlyReports" + + if not data_inc.get(report_key): + return f"No {period} financial data found for {symbol}." + + def to_df(data_dict): + reports = data_dict.get(report_key, []) + if not reports: + return pd.DataFrame() + df = pd.DataFrame(reports) + df = df.replace("None", pd.NA) + if "fiscalDateEnding" in df.columns: + df["fiscalDateEnding"] = pd.to_datetime(df["fiscalDateEnding"]) + df.set_index("fiscalDateEnding", inplace=True) + return df + + df_inc = to_df(data_inc) + df_bal = to_df(data_bal) + df_cash = to_df(data_cash) + + df_merged = pd.concat([df_inc, df_bal, df_cash], axis=1) + df_merged = df_merged.loc[:, ~df_merged.columns.duplicated()] + df_merged.sort_index(ascending=False, inplace=True) + df_final = df_merged.head(limit).copy() + + cols_to_convert = df_final.columns.drop("reportedCurrency", errors="ignore") + for col in cols_to_convert: + df_final[col] = pd.to_numeric(df_final[col], errors="coerce") + + try: + # Profitability + df_final["Gross Margin %"] = ( + df_final["grossProfit"] / df_final["totalRevenue"] + ) * 100 + df_final["Net Margin %"] = ( + df_final["netIncome"] / df_final["totalRevenue"] + ) * 100 + + # Balance Sheet Health + # debt = shortTerm + longTerm + # 1) Data endpoints to request in parallel from AlphaVantage + total_debt = df_final.get( + "shortLongTermDebtTotal", + df_final.get("shortTermDebt", 0) + df_final.get("longTermDebt", 0), + ) + df_final["Total Debt"] = total_debt + df_final["Debt/Equity"] = total_debt / df_final["totalShareholderEquity"] + + # Cash Flow + # Free Cash Flow = Operating Cash Flow - Capital Expenditures + df_final["Free Cash Flow"] = ( + df_final["operatingCashflow"] - df_final["capitalExpenditures"] + ) + + except KeyError: + pass + + df_display = df_final.T + metrics_map = { + "totalRevenue": "Revenue", + "grossProfit": "Gross Profit", + "netIncome": "Net Income", + "Gross Margin %": "Gross Margin %", + "Net Margin %": "Net Margin %", + "reportedEPS": "EPS", + "totalAssets": "Total Assets", + "totalShareholderEquity": "Total Equity", + "Total Debt": "Total Debt", + "Debt/Equity": "Debt/Equity Ratio", + "operatingCashflow": "Operating Cash Flow", + "Free Cash Flow": "Free Cash Flow", + } + existing_metrics = [m for m in metrics_map.keys() if m in df_display.index] + df_display = df_display.loc[existing_metrics] + df_display.rename(index=metrics_map, inplace=True) + + def fmt_val(val, metric_name): + if pd.isna(val): + return "-" + if "%" in metric_name or "Ratio" in metric_name: + return f"{val:.2f}" + ("%" if "%" in metric_name else "x") + if abs(val) >= 1e9: + return f"${val / 1e9:.2f}B" + if abs(val) >= 1e6: + return f"${val / 1e6:.2f}M" + return f"{val:,.0f}" + + for col in df_display.columns: + df_display[col] = df_display.apply( + lambda row: fmt_val(row[col], row.name), axis=1 + ) + + df_display.columns = [d.strftime("%Y-%m-%d") for d in df_display.columns] + + md_table = df_display.to_markdown() + + return ( + f"### Financial Metrics ({period.title()}, Last {limit} periods)\n\n{md_table}" + ) + + +async def get_stock_profile(symbol: str) -> str: + """ + Retrieves a comprehensive profile for a stock symbol. + Includes company description, sector, real-time price, valuation metrics (PE, Market Cap), + and analyst ratings. + """ + # Note: fetching is performed sequentially below to avoid rate limits + + # Fetch sequentially with a short pause to avoid AlphaVantage burst detection + try: + # 1. Global Quote + quote_data = await _fetch_alpha_vantage(symbol=symbol, function="GLOBAL_QUOTE") + # Pause to avoid rapid-fire requests triggering rate limits + await asyncio.sleep(1.5) + + # 2. Overview + overview_data = await _fetch_alpha_vantage(symbol=symbol, function="OVERVIEW") + except Exception as e: + return f"Error fetching profile for {symbol}: {str(e)}" + + # --- Parse the quote response --- + # AlphaVantage formats GLOBAL_QUOTE keys like '01. symbol', '05. price'. + # clean_quote extracts the human-friendly key (text after the numeric prefix). + def clean_quote(q: dict) -> dict: + # Return a mapping like {'price': '123.45', 'volume': '123456'} + return {k.split(". ")[1]: v for k, v in q.get("Global Quote", {}).items()} + + quote = clean_quote(quote_data) + overview = ( + overview_data + if not (isinstance(overview_data, dict) and "error" in overview_data) + else {} + ) + + # If neither quote nor overview has data, return early + if not quote and not overview: + return f"No profile data found for {symbol}." + + # Helper to format large numbers into human-friendly strings + def fmt_num(val): + if not val or val == "None": + return "-" + try: + f = float(val) + if abs(f) >= 1e9: + return f"${f / 1e9:.2f}B" + if abs(f) >= 1e6: + return f"${f / 1e6:.2f}M" + return f"{f:,.2f}" + except Exception: + return val + + # --- Assemble Markdown profile --- + # Header / basic company info + name = overview.get("Name", symbol) + sector = overview.get("Sector", "-") + industry = overview.get("Industry", "-") + desc = overview.get("Description", "No description available.") + # Truncate long descriptions to save tokens + if len(desc) > 300: + desc = desc[:300] + "..." + + profile_md = f"### Stock Profile: {name} ({symbol})\n\n" + profile_md += f"**Sector**: {sector} | **Industry**: {industry}\n\n" + profile_md += f"**Description**: {desc}\n\n" + + # --- Market snapshot table --- + # Format price, change, market cap, volume and 52-week range + price = fmt_num(quote.get("price")) + change_pct = quote.get("change percent", "-") + # Choose a simple textual indicator for trend (avoid emoji) + trend = "Up" if change_pct and "-" not in change_pct else "Down" + + mkt_cap = fmt_num(overview.get("MarketCapitalization")) + vol = fmt_num(quote.get("volume")) + + range_52w = ( + f"{fmt_num(overview.get('52WeekLow'))} - {fmt_num(overview.get('52WeekHigh'))}" + ) + + profile_md += "**Market Snapshot**\n" + profile_md += "| Price | Change | Market Cap | Volume | 52W Range |\n" + profile_md += "|---|---|---|---|---|\n" + profile_md += ( + f"| {price} | {trend} {change_pct} | {mkt_cap} | {vol} | {range_52w} |\n\n" + ) + + # --- Valuation & Financials --- + pe = overview.get("PERatio", "-") + peg = overview.get("PEGRatio", "-") + eps = overview.get("EPS", "-") + div_yield = overview.get("DividendYield", "0") + try: + div_yield_pct = f"{float(div_yield) * 100:.2f}%" + except Exception: + div_yield_pct = "-" + + beta = overview.get("Beta", "-") + profit_margin = overview.get("ProfitMargin", "-") + try: + pm_pct = f"{float(profit_margin) * 100:.1f}%" + except Exception: + pm_pct = "-" + + profile_md += "**Valuation & Financials**\n" + profile_md += "| PE Ratio | PEG | EPS | Div Yield | Beta | Profit Margin |\n" + profile_md += "|---|---|---|---|---|---|\n" + profile_md += f"| {pe} | {peg} | {eps} | {div_yield_pct} | {beta} | {pm_pct} |\n\n" + + # --- Analyst Ratings (if provided) --- + target = overview.get("AnalystTargetPrice") + buy = overview.get("AnalystRatingBuy", "0") + hold = overview.get("AnalystRatingHold", "0") + sell = overview.get("AnalystRatingSell", "0") + + if target and target != "None": + profile_md += f"**Analyst Consensus**: Target Price ${target} (Buy: {buy}, Hold: {hold}, Sell: {sell})" + + return profile_md + + +async def get_market_sentiment(symbol: str, limit: int = 10) -> str: + """ + Retrieves and summarizes market sentiment and news for a specific stock symbol. + Uses AlphaVantage News Sentiment API to get sentiment scores and summaries. + + Args: + symbol: Stock ticker (e.g., 'AAPL', 'TSLA'). + limit: Max number of news items to analyze (default 10). + """ + # 1. Fetch data + # Note: 'tickers' param filters news mentioning this symbol + data = await _fetch_alpha_vantage( + function="NEWS_SENTIMENT", + symbol=None, + extra_params={"tickers": symbol, "limit": str(limit)}, + ) + + feed = data.get("feed", []) + if not feed: + return f"No recent news found for {symbol}." + + # 2. Filter and Process News + # We only want news where the ticker is RELEVANT (score > 0.1) + relevant_news = [] + total_sentiment_score = 0.0 + valid_count = 0 + + for item in feed: + # Find sentiment for OUR symbol within the list of tickers mentioned in this article + ticker_meta = next( + (t for t in item.get("ticker_sentiment", []) if t["ticker"] == symbol), None + ) + + # Fallback: if symbol not explicitly in list (rare), use overall sentiment + sentiment_score = ( + float(ticker_meta["ticker_sentiment_score"]) + if ticker_meta + else item.get("overall_sentiment_score", 0) + ) + relevance = float(ticker_meta["relevance_score"]) if ticker_meta else 0 + + # Filter noise: Skip low relevance articles + if relevance < 0.1: + continue + + valid_count += 1 + total_sentiment_score += sentiment_score + + # Format date: 20251211T001038 -> 2025-12-11 + pub_date = item.get("time_published", "")[:8] + try: + pub_date = datetime.strptime(pub_date, "%Y%m%d").strftime("%Y-%m-%d") + except Exception: + pass + + relevant_news.append( + { + "title": item.get("title"), + "summary": item.get("summary"), + "source": item.get("source"), + "date": pub_date, + "url": item.get("url"), + "sentiment_label": item.get( + "overall_sentiment_label" + ), # Use overall label for readability + "score": sentiment_score, + } + ) + + if not relevant_news: + return f"Found news, but none were highly relevant to {symbol}." + + # 3. Calculate Aggregated Sentiment + avg_score = total_sentiment_score / valid_count if valid_count > 0 else 0 + + # Map score to label (based on AlphaVantage definition) + if avg_score <= -0.15: + aggregate_label = "Bearish 🐻" + elif avg_score >= 0.15: + aggregate_label = "Bullish šŸ‚" + else: + aggregate_label = "Neutral 😐" + + # 4. Construct Markdown Output + # Header + md = f"### Market Sentiment for {symbol}\n" + md += f"**Overall Signal**: {aggregate_label} (Avg Score: {avg_score:.2f})\n" + md += f"**Analysis Basis**: {len(relevant_news)} relevant articles\n\n" + + # Top News List (Markdown) + md += "**Top Relevant News:**\n" + for news in relevant_news[:5]: # Show top 5 to save space + label_icon = ( + "🟢" + if "Bullish" in news["sentiment_label"] + else "šŸ”“" + if "Bearish" in news["sentiment_label"] + else "⚪" + ) + + md += f"- **{news['date']}** [{news['source']}]\n" + md += f" [{news['title']}]({news['url']})\n" + md += f" *Sentiment:* {label_icon} {news['sentiment_label']} | *Summary:* {news['summary'][:150]}...\n\n" + + return md From 5b10829027787523803ceb1b922b614b4893b6b3 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:41:27 +0800 Subject: [PATCH 45/50] refactor(orchestrator): simplify task description formatting in plan updates --- python/valuecell/agents/react_agent/models.py | 1 - python/valuecell/agents/react_agent/nodes/planner.py | 4 +++- python/valuecell/core/coordinate/orchestrator.py | 3 +-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py index e5b78e1a5..9021a6710 100644 --- a/python/valuecell/agents/react_agent/models.py +++ b/python/valuecell/agents/react_agent/models.py @@ -56,7 +56,6 @@ class ExecutorResult(BaseModel): class PlannedTask(BaseModel): - id: str = Field(description="Unique task identifier, e.g., 't1'") tool_id: str = Field(description="The EXACT tool_id from the available tool list") tool_args: Dict[str, ARG_VAL_TYPES | list[ARG_VAL_TYPES]] = Field( default_factory=dict, diff --git a/python/valuecell/agents/react_agent/nodes/planner.py b/python/valuecell/agents/react_agent/nodes/planner.py index 27111b212..55bceb725 100644 --- a/python/valuecell/agents/react_agent/nodes/planner.py +++ b/python/valuecell/agents/react_agent/nodes/planner.py @@ -7,6 +7,8 @@ from langchain_core.messages import AIMessage, HumanMessage from loguru import logger +from valuecell.utils.uuid import generate_task_id + from ..models import ExecutionPlan, Task from ..tool_registry import registry @@ -115,7 +117,7 @@ async def planner_node(state: dict[str, Any]) -> dict[str, Any]: tasks.append( Task( - id=pt.id, + id=generate_task_id(), tool_name=pt.tool_id, tool_args=pt.tool_args, description=pt.description or "No description provided by planner", diff --git a/python/valuecell/core/coordinate/orchestrator.py b/python/valuecell/core/coordinate/orchestrator.py index 5a7f46419..f5a290a0c 100644 --- a/python/valuecell/core/coordinate/orchestrator.py +++ b/python/valuecell/core/coordinate/orchestrator.py @@ -170,9 +170,8 @@ def is_real_node_output(d: dict) -> bool: # Format plan as markdown plan_md = f"\n\n**šŸ“… Plan Updated:**\n*{reasoning}*\n" for task in plan: - task_id = task.get("id", "?") desc = task.get("description", "No description") - plan_md += f"- `[{task_id}]` {desc}\n" + plan_md += f"- {desc}\n" # TODO: Consider switching to event_service.reasoning() yield await self.event_service.emit( From 4ad7e9a3efea8c96491fdc516465ad90ddd68c36 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:17:45 +0800 Subject: [PATCH 46/50] feat(react-agent): implement TaskContext for tool runtime and enhance event dispatching --- .../valuecell/agents/react_agent/context.py | 71 +++++++++++ python/valuecell/agents/react_agent/graph.py | 13 +- .../agents/react_agent/nodes/executor.py | 17 ++- .../agents/react_agent/tool_registry.py | 3 +- .../agents/react_agent/tools/alphavantage.py | 39 +++++- .../valuecell/core/coordinate/orchestrator.py | 115 +++++++++++------- 6 files changed, 203 insertions(+), 55 deletions(-) create mode 100644 python/valuecell/agents/react_agent/context.py diff --git a/python/valuecell/agents/react_agent/context.py b/python/valuecell/agents/react_agent/context.py new file mode 100644 index 000000000..22bccf4af --- /dev/null +++ b/python/valuecell/agents/react_agent/context.py @@ -0,0 +1,71 @@ +"""Task execution context for tool runtime.""" + +from typing import Any, Optional + +from langchain_core.callbacks import adispatch_custom_event +from langchain_core.runnables import RunnableConfig + + +class TaskContext: + """Context object passed to tools, encapsulating task metadata and event dispatch. + + This context binds a task_id with the LangGraph config, allowing tools to + emit progress events and artifacts without polluting their parameter schemas. + + Example: + ```python + async def my_tool(symbol: str, context: Optional[TaskContext] = None) -> str: + if context: + await context.emit_progress("Fetching data...") + # ... tool logic ... + return result + ``` + """ + + def __init__(self, task_id: str, config: RunnableConfig): + """Initialize task context. + + Args: + task_id: Unique identifier for the current task + config: LangGraph RunnableConfig for event dispatch + """ + self.task_id = task_id + self._config = config + + async def emit_progress( + self, + msg: str, + step: Optional[str] = None, + ) -> None: + """Emit a progress event linked to this specific task. + + Args: + msg: Human-readable progress message + percent: Optional completion percentage (0-100) + step: Optional step identifier (e.g., "fetching_income") + """ + if not msg.endswith("\n"): + msg += "\n" + + payload = { + "type": "progress", + "task_id": self.task_id, + "msg": msg, + "step": step, + } + await adispatch_custom_event("tool_event", payload, config=self._config) + + async def emit_artifact(self, artifact_type: str, content: Any) -> None: + """Emit an intermediate artifact (e.g., a chart or table). + + Args: + artifact_type: Type identifier for the artifact (e.g., "chart", "table") + content: Artifact content (JSON-serializable) + """ + payload = { + "type": "artifact", + "task_id": self.task_id, + "artifact_type": artifact_type, + "content": content, + } + await adispatch_custom_event("tool_event", payload, config=self._config) diff --git a/python/valuecell/agents/react_agent/graph.py b/python/valuecell/agents/react_agent/graph.py index 3a623f603..72cad05ae 100644 --- a/python/valuecell/agents/react_agent/graph.py +++ b/python/valuecell/agents/react_agent/graph.py @@ -3,6 +3,8 @@ import uuid from typing import Any +from langchain_core.runnables import RunnableConfig + from .nodes.critic import critic_node from .nodes.executor import executor_node from .nodes.inquirer import inquirer_node @@ -40,10 +42,15 @@ def _route_after_planner(state: AgentState): return "critic" -async def _executor_entry(state: AgentState) -> dict[str, Any]: - """Entry adapter for executor: expects a `task` injected via Send().""" +async def _executor_entry(state: AgentState, config: RunnableConfig) -> dict[str, Any]: + """Entry adapter for executor: expects a `task` injected via Send(). + + Args: + state: Agent state containing task data + config: RunnableConfig injected by LangGraph + """ task = state.get("task") or {} - return await executor_node(state, task) + return await executor_node(state, task, config) def build_app() -> Any: diff --git a/python/valuecell/agents/react_agent/nodes/executor.py b/python/valuecell/agents/react_agent/nodes/executor.py index 7d8790700..7f9ee884e 100644 --- a/python/valuecell/agents/react_agent/nodes/executor.py +++ b/python/valuecell/agents/react_agent/nodes/executor.py @@ -4,6 +4,7 @@ from typing import Any, Callable from langchain_core.callbacks import adispatch_custom_event +from langchain_core.runnables import RunnableConfig from loguru import logger from pydantic import BaseModel @@ -13,6 +14,7 @@ search_crypto_vcs, web_search, ) +from ..context import TaskContext from ..models import ExecutorResult from ..state import AgentState from ..tool_registry import registry @@ -63,9 +65,16 @@ def _register_tool( pass -async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, Any]: +async def executor_node( + state: AgentState, task: dict[str, Any], config: RunnableConfig +) -> dict[str, Any]: """Execute a single task and return execution summary for history. + Args: + state: Current agent state + task: Task dictionary containing id, tool_name, tool_args, description + config: RunnableConfig injected by LangGraph for event dispatch + Returns: - completed_tasks: {task_id: ExecutorResult} - execution_history: [concise summary string] @@ -89,7 +98,11 @@ async def executor_node(state: AgentState, task: dict[str, Any]) -> dict[str, An return {} try: - runtime_args = {"state": state} + # Create task context binding task_id and config + ctx = TaskContext(task_id=task_id, config=config) + + # Pass state and context to registry.execute + runtime_args = {"state": state, "context": ctx} result = await registry.execute(tool_name, tool_args, runtime_args=runtime_args) exec_res = ExecutorResult( task_id=task_id, diff --git a/python/valuecell/agents/react_agent/tool_registry.py b/python/valuecell/agents/react_agent/tool_registry.py index 495670c6a..40713fc52 100644 --- a/python/valuecell/agents/react_agent/tool_registry.py +++ b/python/valuecell/agents/react_agent/tool_registry.py @@ -219,7 +219,8 @@ def _infer_schema(func: CallableType) -> Type[BaseModel] | None: fields: dict[str, tuple[type[Any], Any]] = {} for name, param in signature.parameters.items(): - if name in {"self", "cls"}: + # Skip self, cls and context (runtime-injected parameters) + if name in {"self", "cls", "context"}: continue if param.kind in { inspect.Parameter.VAR_POSITIONAL, diff --git a/python/valuecell/agents/react_agent/tools/alphavantage.py b/python/valuecell/agents/react_agent/tools/alphavantage.py index 8c06a1a91..343c4596d 100644 --- a/python/valuecell/agents/react_agent/tools/alphavantage.py +++ b/python/valuecell/agents/react_agent/tools/alphavantage.py @@ -1,12 +1,14 @@ import asyncio import os from datetime import datetime -from typing import Any, Literal +from typing import Any, Literal, Optional import httpx import pandas as pd from loguru import logger +from ..context import TaskContext + async def _fetch_alpha_vantage( function: str, symbol: str | None = None, extra_params: dict | None = None @@ -82,7 +84,10 @@ async def _fetch_alpha_vantage( async def get_financial_metrics( - symbol: str, period: Literal["annual", "quarterly"] = "annual", limit: int = 4 + symbol: str, + period: Literal["annual", "quarterly"] = "annual", + limit: int = 4, + context: Optional[TaskContext] = None, ) -> str: """ Retrieves detailed financial metrics for a stock symbol using AlphaVantage. @@ -96,16 +101,32 @@ async def get_financial_metrics( # Sequentially fetch endpoints with short delays to avoid AlphaVantage "burst" rate-limiting. try: + # Emit progress event: starting income statement fetch + if context: + await context.emit_progress( + f"Fetching Income Statement for {symbol}...", step="fetching_income" + ) + # 1) Income Statement data_inc = await _fetch_alpha_vantage( symbol=symbol, function="INCOME_STATEMENT" ) await asyncio.sleep(1.5) + # Emit progress event: starting balance sheet fetch + if context: + await context.emit_progress( + "Fetching Balance Sheet...", step="fetching_balance" + ) + # 2) Balance Sheet data_bal = await _fetch_alpha_vantage(symbol=symbol, function="BALANCE_SHEET") await asyncio.sleep(1.5) + # Emit progress event: starting cash flow fetch + if context: + await context.emit_progress("Fetching Cash Flow...", step="fetching_cash") + # 3) Cash Flow data_cash = await _fetch_alpha_vantage(symbol=symbol, function="CASH_FLOW") @@ -234,7 +255,7 @@ def fmt_val(val, metric_name): ) -async def get_stock_profile(symbol: str) -> str: +async def get_stock_profile(symbol: str, context: Optional[TaskContext] = None) -> str: """ Retrieves a comprehensive profile for a stock symbol. Includes company description, sector, real-time price, valuation metrics (PE, Market Cap), @@ -244,11 +265,23 @@ async def get_stock_profile(symbol: str) -> str: # Fetch sequentially with a short pause to avoid AlphaVantage burst detection try: + # Emit progress event: starting quote fetch + if context: + await context.emit_progress( + f"Fetching real-time quote for {symbol}...", step="fetching_quote" + ) + # 1. Global Quote quote_data = await _fetch_alpha_vantage(symbol=symbol, function="GLOBAL_QUOTE") # Pause to avoid rapid-fire requests triggering rate limits await asyncio.sleep(1.5) + # Emit progress event: starting overview fetch + if context: + await context.emit_progress( + "Fetching company overview...", step="fetching_overview" + ) + # 2. Overview overview_data = await _fetch_alpha_vantage(symbol=symbol, function="OVERVIEW") except Exception as e: diff --git a/python/valuecell/core/coordinate/orchestrator.py b/python/valuecell/core/coordinate/orchestrator.py index f5a290a0c..9125620c2 100644 --- a/python/valuecell/core/coordinate/orchestrator.py +++ b/python/valuecell/core/coordinate/orchestrator.py @@ -156,6 +156,7 @@ def is_real_node_output(d: dict) -> bool: kind = event.get("event", "") node = event.get("metadata", {}).get("langgraph_node", "") data = event.get("data") or {} + logger.debug(f"stream_react_agent: event received: {event}") # ================================================================= # 1. PLANNER -> MESSAGE_CHUNK (TODO: Consider REASONING) @@ -198,78 +199,101 @@ def is_real_node_output(d: dict) -> bool: raw_tool_name = task_data.get("tool_name", "unknown_tool") task_description = task_data.get("description", "") - # [Optimization] Combine description and tool name for UI - # Format: "Get Stock Price (web_search)" - if task_description: - # Optional: Truncate description if it's too long for a header - short_desc = ( - (task_description[:60] + "...") - if len(task_description) > 60 - else task_description + if task_id: + yield await self.event_service.emit( + self.event_service.factory.reasoning( + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + event=StreamResponseEvent.REASONING_STARTED, + item_id=task_id, + content=None, + agent_name="Executor", + ) ) - display_tool_name = f"{short_desc} ({raw_tool_name})" - else: - display_tool_name = raw_tool_name - if task_id: + title_text = f"**Executing Task:** {task_description} (`{raw_tool_name}`)" yield await self.event_service.emit( - self.event_service.factory.tool_call( + self.event_service.factory.reasoning( conversation_id=conversation_id, thread_id=_response_thread_id, task_id=root_task_id, - event=StreamResponseEvent.TOOL_CALL_STARTED, - tool_call_id=task_id, - tool_name=display_tool_name, + event=StreamResponseEvent.REASONING, + item_id=task_id, + content=title_text, agent_name="Executor", ) ) # --------------------------------------------------------- - # Case B: Executor COMPLETED + # Case B: Intermediate Progress (Tool Events) + # --------------------------------------------------------- + elif kind == "on_custom_event" and event.get("name") == "tool_event": + payload = data + if payload.get("type") == "progress": + progress_task_id = payload.get("task_id") + msg = payload.get("msg", "") + step = payload.get("step") + + # Format progress message + progress_parts = [] + if step: + progress_parts.append(f"[{step}]") + progress_parts.append(msg) + progress_text = f"> {' '.join(progress_parts)}\n" + + if progress_task_id: + yield await self.event_service.emit( + self.event_service.factory.reasoning( + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + event=StreamResponseEvent.REASONING, + item_id=progress_task_id, + content=progress_text, + agent_name="Executor", + ) + ) + + # --------------------------------------------------------- + # Case C: Executor COMPLETED # --------------------------------------------------------- elif kind == "on_chain_end" and node == "executor": if is_real_node_output(data): output = data.get("output", {}) if isinstance(output, dict) and "completed_tasks" in output: for task_id_key, res in output["completed_tasks"].items(): - # 1. Extract Result + # Extract result if isinstance(res, dict): - # Try to get 'result' field, fallback to full dict dump raw_result = res.get("result") or str(res) - - # Try to retrieve metadata preserved by executor - res_tool_name = ( - res.get("tool_name") or "completed_tool" - ) - res_description = res.get("description") else: raw_result = str(res) - res_tool_name = "completed_tool" - res_description = None - - # 2. Re-construct the display name to match STARTED event - # This ensures the UI updates the correct item instead of creating a new one - if res_description: - short_desc = ( - (res_description[:60] + "...") - if len(res_description) > 60 - else res_description - ) - display_tool_name = ( - f"{short_desc} ({res_tool_name})" + + # Truncate result if too long + result_preview = str(raw_result) + final_text = ( + f"\nāœ… **Result:**\n```\n{result_preview}\n```" + ) + yield await self.event_service.emit( + self.event_service.factory.reasoning( + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + event=StreamResponseEvent.REASONING, + item_id=task_id_key, + content=final_text, + agent_name="Executor", ) - else: - display_tool_name = res_tool_name + ) yield await self.event_service.emit( - self.event_service.factory.tool_call( + self.event_service.factory.reasoning( conversation_id=conversation_id, thread_id=_response_thread_id, task_id=root_task_id, - event=StreamResponseEvent.TOOL_CALL_COMPLETED, - tool_call_id=task_id_key, - tool_name=display_tool_name, - tool_result=raw_result, + event=StreamResponseEvent.REASONING_COMPLETED, + item_id=task_id_key, + content=None, agent_name="Executor", ) ) @@ -293,7 +317,6 @@ def is_real_node_output(d: dict) -> bool: f"\n\n**{icon} Critic Decision:** {reason}\n\n" ) - # TODO: Consider switching to event_service.reasoning() yield await self.event_service.emit( self.event_service.factory.message_response_general( event=StreamResponseEvent.MESSAGE_CHUNK, From 6c90c3edf3d20326eb743ac823299af2b9339294 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:51:47 +0800 Subject: [PATCH 47/50] refactor(orchestrator, buffer): update event handling to use reasoning events and enhance buffer key structure --- .../valuecell/core/coordinate/orchestrator.py | 75 +++++++++++++++++-- python/valuecell/core/event/buffer.py | 46 ++++++++---- 2 files changed, 100 insertions(+), 21 deletions(-) diff --git a/python/valuecell/core/coordinate/orchestrator.py b/python/valuecell/core/coordinate/orchestrator.py index 9125620c2..35a6f2005 100644 --- a/python/valuecell/core/coordinate/orchestrator.py +++ b/python/valuecell/core/coordinate/orchestrator.py @@ -159,7 +159,7 @@ def is_real_node_output(d: dict) -> bool: logger.debug(f"stream_react_agent: event received: {event}") # ================================================================= - # 1. PLANNER -> MESSAGE_CHUNK (TODO: Consider REASONING) + # 1. PLANNER -> REASONING # ================================================================= if kind == "on_chain_end" and node == "planner": if is_real_node_output(data): @@ -168,24 +168,54 @@ def is_real_node_output(d: dict) -> bool: plan = output.get("plan", []) reasoning = output.get("strategy_update") or "..." + # Generate stable item_id for this planner reasoning block + planner_item_id = generate_task_id() + + # REASONING_STARTED + yield await self.event_service.emit( + self.event_service.factory.reasoning( + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + event=StreamResponseEvent.REASONING_STARTED, + item_id=planner_item_id, + content=None, + agent_name="Planner", + ) + ) + # Format plan as markdown plan_md = f"\n\n**šŸ“… Plan Updated:**\n*{reasoning}*\n" for task in plan: desc = task.get("description", "No description") plan_md += f"- {desc}\n" - # TODO: Consider switching to event_service.reasoning() + # REASONING (content) yield await self.event_service.emit( - self.event_service.factory.message_response_general( - event=StreamResponseEvent.MESSAGE_CHUNK, + self.event_service.factory.reasoning( conversation_id=conversation_id, thread_id=_response_thread_id, task_id=root_task_id, + event=StreamResponseEvent.REASONING, + item_id=planner_item_id, content=plan_md, agent_name="Planner", ) ) + # REASONING_COMPLETED + yield await self.event_service.emit( + self.event_service.factory.reasoning( + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + event=StreamResponseEvent.REASONING_COMPLETED, + item_id=planner_item_id, + content=None, + agent_name="Planner", + ) + ) + # ================================================================= # 2. EXECUTOR -> TOOL_CALL (STARTED & COMPLETED) # ================================================================= @@ -299,7 +329,7 @@ def is_real_node_output(d: dict) -> bool: ) # ================================================================= - # 3. CRITIC -> MESSAGE_CHUNK (TODO: Consider REASONING) + # 3. CRITIC -> REASONING # ================================================================= elif kind == "on_chain_end" and node == "critic": if is_real_node_output(data): @@ -313,21 +343,52 @@ def is_real_node_output(d: dict) -> bool: "feedback", "" ) + # Generate stable item_id for this critic reasoning block + critic_item_id = generate_task_id() + + # REASONING_STARTED + yield await self.event_service.emit( + self.event_service.factory.reasoning( + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + event=StreamResponseEvent.REASONING_STARTED, + item_id=critic_item_id, + content=None, + agent_name="Critic", + ) + ) + critic_md = ( f"\n\n**{icon} Critic Decision:** {reason}\n\n" ) + # REASONING (content) yield await self.event_service.emit( - self.event_service.factory.message_response_general( - event=StreamResponseEvent.MESSAGE_CHUNK, + self.event_service.factory.reasoning( conversation_id=conversation_id, thread_id=_response_thread_id, task_id=root_task_id, + event=StreamResponseEvent.REASONING, + item_id=critic_item_id, content=critic_md, agent_name="Critic", ) ) + # REASONING_COMPLETED + yield await self.event_service.emit( + self.event_service.factory.reasoning( + conversation_id=conversation_id, + thread_id=_response_thread_id, + task_id=root_task_id, + event=StreamResponseEvent.REASONING_COMPLETED, + item_id=critic_item_id, + content=None, + agent_name="Critic", + ) + ) + # ================================================================= # 4. SUMMARIZER / INQUIRER -> MESSAGE_CHUNK # ================================================================= diff --git a/python/valuecell/core/event/buffer.py b/python/valuecell/core/event/buffer.py index 28c295eb0..50d9d295f 100644 --- a/python/valuecell/core/event/buffer.py +++ b/python/valuecell/core/event/buffer.py @@ -32,8 +32,10 @@ class SaveItem: metadata: Optional[ResponseMetadata] = None -# conversation_id, thread_id, task_id, event -BufferKey = Tuple[str, Optional[str], Optional[str], object] +# conversation_id, thread_id, task_id, event, optional item_id (for REASONING), agent_name +BufferKey = Tuple[ + str, Optional[str], Optional[str], object, Optional[str], Optional[str] +] class BufferEntry: @@ -114,26 +116,34 @@ def annotate(self, resp: BaseResponse) -> BaseResponse: For REASONING events, if the caller has already set an item_id, it is preserved to allow correlation of reasoning_started/reasoning/reasoning_completed. - MESSAGE_CHUNK events always use the buffer to get a stable paragraph item_id. + The item_id is also included in the BufferKey to separate parallel tasks. + MESSAGE_CHUNK events use agent_name in the BufferKey to separate messages + from different agents (Planner, Critic, Summarizer, etc.). """ data: UnifiedResponseData = resp.data ev = resp.event if ev in self._buffered_events: - # For REASONING events, trust the caller's item_id (set by orchestrator) - # and skip buffer-based id assignment. MESSAGE_CHUNK always uses buffer. - # TODO: consider when no item_id is set for REASONING, especially in remote agent calls + # For REASONING events with caller-provided item_id, include it in the key + # to ensure parallel tasks have separate buffers + buffer_item_id = None if ev == StreamResponseEvent.REASONING and data.item_id: - return resp + buffer_item_id = data.item_id + key: BufferKey = ( data.conversation_id, data.thread_id, data.task_id, ev, + buffer_item_id, + data.agent_name, # Include agent_name to separate different message sources ) entry = self._buffers.get(key) if not entry: - # Start a new paragraph buffer with a fresh paragraph item_id - entry = BufferEntry(role=data.role, agent_name=data.agent_name) + # Start a new paragraph buffer with caller's item_id or fresh one + entry_item_id = buffer_item_id if buffer_item_id else None + entry = BufferEntry( + item_id=entry_item_id, role=data.role, agent_name=data.agent_name + ) self._buffers[key] = entry if entry.agent_name is None and data.agent_name: entry.agent_name = data.agent_name @@ -173,13 +183,21 @@ def ingest(self, resp: BaseResponse) -> List[SaveItem]: out.append(self._make_save_item_from_response(resp)) return out - # Buffered: accumulate by (ctx + event) + # Buffered: accumulate by (ctx + event + optional item_id + agent_name) if ev in self._buffered_events: - key: BufferKey = (*ctx, ev) + # For REASONING events with item_id, include it in key to separate parallel tasks + buffer_item_id = None + if ev == StreamResponseEvent.REASONING and data.item_id: + buffer_item_id = data.item_id + + key: BufferKey = (*ctx, ev, buffer_item_id, data.agent_name) entry = self._buffers.get(key) if not entry: # If annotate() wasn't called, create an entry now. - entry = BufferEntry(role=data.role, agent_name=data.agent_name) + entry_item_id = buffer_item_id if buffer_item_id else None + entry = BufferEntry( + item_id=entry_item_id, role=data.role, agent_name=data.agent_name + ) self._buffers[key] = entry elif entry.agent_name is None and data.agent_name: entry.agent_name = data.agent_name @@ -225,7 +243,7 @@ def _collect_task_keys( ) -> List[BufferKey]: keys: List[BufferKey] = [] for key in list(self._buffers.keys()): - k_conv, k_thread, k_task, k_event = key + k_conv, k_thread, k_task, k_event, k_item_id, k_agent_name = key if ( k_conv == conversation_id and (thread_id is None or k_thread == thread_id) @@ -246,7 +264,7 @@ def _finalize_keys(self, keys: List[BufferKey]) -> List[SaveItem]: out.append( SaveItem( item_id=entry.item_id, - event=key[3], + event=key[3], # event is at index 3 conversation_id=key[0], thread_id=key[1], task_id=key[2], From 443da579c22f089316b238f6950050c2d6181e96 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:12:18 +0800 Subject: [PATCH 48/50] refactor: streamline context usage in market sentiment retrieval and enhance task execution formatting --- python/valuecell/agents/react_agent/context.py | 17 +---------------- .../agents/react_agent/tools/alphavantage.py | 17 ++++++++++++++++- .../valuecell/core/coordinate/orchestrator.py | 6 ++---- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/python/valuecell/agents/react_agent/context.py b/python/valuecell/agents/react_agent/context.py index 22bccf4af..f8d6c6da6 100644 --- a/python/valuecell/agents/react_agent/context.py +++ b/python/valuecell/agents/react_agent/context.py @@ -1,6 +1,6 @@ """Task execution context for tool runtime.""" -from typing import Any, Optional +from typing import Optional from langchain_core.callbacks import adispatch_custom_event from langchain_core.runnables import RunnableConfig @@ -54,18 +54,3 @@ async def emit_progress( "step": step, } await adispatch_custom_event("tool_event", payload, config=self._config) - - async def emit_artifact(self, artifact_type: str, content: Any) -> None: - """Emit an intermediate artifact (e.g., a chart or table). - - Args: - artifact_type: Type identifier for the artifact (e.g., "chart", "table") - content: Artifact content (JSON-serializable) - """ - payload = { - "type": "artifact", - "task_id": self.task_id, - "artifact_type": artifact_type, - "content": content, - } - await adispatch_custom_event("tool_event", payload, config=self._config) diff --git a/python/valuecell/agents/react_agent/tools/alphavantage.py b/python/valuecell/agents/react_agent/tools/alphavantage.py index 343c4596d..ee5acaf36 100644 --- a/python/valuecell/agents/react_agent/tools/alphavantage.py +++ b/python/valuecell/agents/react_agent/tools/alphavantage.py @@ -388,7 +388,11 @@ def fmt_num(val): return profile_md -async def get_market_sentiment(symbol: str, limit: int = 10) -> str: +async def get_market_sentiment( + symbol: str, + limit: int = 10, + context: Optional[TaskContext] = None, +) -> str: """ Retrieves and summarizes market sentiment and news for a specific stock symbol. Uses AlphaVantage News Sentiment API to get sentiment scores and summaries. @@ -416,6 +420,12 @@ async def get_market_sentiment(symbol: str, limit: int = 10) -> str: valid_count = 0 for item in feed: + if context: + await context.emit_progress( + f"Analyzing news: [{item.get('title', '')}]({item.get('url', '')})...", + step="processing_news", + ) + # Find sentiment for OUR symbol within the list of tickers mentioned in this article ticker_meta = next( (t for t in item.get("ticker_sentiment", []) if t["ticker"] == symbol), None @@ -461,6 +471,11 @@ async def get_market_sentiment(symbol: str, limit: int = 10) -> str: return f"Found news, but none were highly relevant to {symbol}." # 3. Calculate Aggregated Sentiment + if context: + await context.emit_progress( + "Calculating aggregate market sentiment...", step="aggregating_sentiment" + ) + avg_score = total_sentiment_score / valid_count if valid_count > 0 else 0 # Map score to label (based on AlphaVantage definition) diff --git a/python/valuecell/core/coordinate/orchestrator.py b/python/valuecell/core/coordinate/orchestrator.py index 35a6f2005..d621c1c71 100644 --- a/python/valuecell/core/coordinate/orchestrator.py +++ b/python/valuecell/core/coordinate/orchestrator.py @@ -242,7 +242,7 @@ def is_real_node_output(d: dict) -> bool: ) ) - title_text = f"**Executing Task:** {task_description} (`{raw_tool_name}`)" + title_text = f"**Executing Task:** {task_description} (`{raw_tool_name}`)\n\n" yield await self.event_service.emit( self.event_service.factory.reasoning( conversation_id=conversation_id, @@ -301,9 +301,7 @@ def is_real_node_output(d: dict) -> bool: # Truncate result if too long result_preview = str(raw_result) - final_text = ( - f"\nāœ… **Result:**\n```\n{result_preview}\n```" - ) + final_text = f"\nāœ… **Result:**\n\n{result_preview}\n" yield await self.event_service.emit( self.event_service.factory.reasoning( conversation_id=conversation_id, From 09371a6609d7e677919047078c2886379f8bd0c6 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:54:16 +0800 Subject: [PATCH 49/50] feat(orchestrator): implement checkpoint handling and message loading for conversation history --- .../valuecell/core/coordinate/orchestrator.py | 159 +++++++++++++++++- 1 file changed, 155 insertions(+), 4 deletions(-) diff --git a/python/valuecell/core/coordinate/orchestrator.py b/python/valuecell/core/coordinate/orchestrator.py index d621c1c71..8bcd0a318 100644 --- a/python/valuecell/core/coordinate/orchestrator.py +++ b/python/valuecell/core/coordinate/orchestrator.py @@ -1,7 +1,7 @@ import asyncio -from typing import AsyncGenerator, Dict, Optional +from typing import Any, AsyncGenerator, Dict, List, Optional -from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage from loguru import logger from valuecell.core.constants import ORIGINAL_USER_INPUT, PLANNING_TASK @@ -134,9 +134,34 @@ async def stream_react_agent( "language": i18n_utils.get_current_language(), "timezone": i18n_utils.get_current_timezone(), } - # TODO: append previous conversation history after restart + + # Check if LangGraph checkpoint exists for this conversation. + # MemorySaver is in-memory only, so checkpoint exists only within the same + # application session. After restart, we need to rebuild history from database. + checkpoint_exists = await self._check_langgraph_checkpoint( + graph, graph_thread_id + ) + + if checkpoint_exists: + # Checkpoint exists: only pass the new user message. + # LangGraph will restore previous state and append the new message via operator.add + messages = [HumanMessage(content=user_input.query)] + logger.info( + "stream_react_agent: checkpoint exists for conversation {}, using new message only", + conversation_id, + ) + else: + # No checkpoint: rebuild full message history from database. + # This happens after application restart when MemorySaver is empty. + messages = await self._load_conversation_messages(conversation_id) + logger.info( + "stream_react_agent: no checkpoint for conversation {}, loaded {} messages from database", + conversation_id, + len(messages), + ) + inputs = { - "messages": [HumanMessage(content=user_input.query)], + "messages": messages, "user_context": user_context, } config = {"configurable": {"thread_id": graph_thread_id}} @@ -797,6 +822,132 @@ async def _monitor_planning_task( async for response in self.task_executor.execute_plan(plan, thread_id): yield response + async def _check_langgraph_checkpoint(self, graph: Any, thread_id: str) -> bool: + """Check if a LangGraph checkpoint exists for the given thread_id. + + Args: + graph: The compiled LangGraph app + thread_id: The thread_id to check + + Returns: + True if checkpoint exists, False otherwise + """ + try: + # Access the checkpointer and try to get the latest checkpoint + checkpointer = graph.checkpointer + if not checkpointer: + return False + + # Get the latest checkpoint for this thread + config = {"configurable": {"thread_id": thread_id}} + checkpoint = await checkpointer.aget(config) + + # If checkpoint exists and has state, return True + return checkpoint is not None + except Exception as exc: + logger.warning( + "Failed to check LangGraph checkpoint for thread {}: {}", + thread_id, + exc, + ) + # On error, assume no checkpoint exists (safe fallback: load from DB) + return False + + async def _load_conversation_messages( + self, conversation_id: str + ) -> List[BaseMessage]: + """Load historical messages from database. + + Since LangGraph's MemorySaver is in-memory only, we need to reconstruct + message history from the database when the application restarts. + + Args: + conversation_id: The conversation ID to load messages for + + Returns: + List of BaseMessage objects (HumanMessage, AIMessage) with current query appended + """ + from valuecell.core.types import Role, StreamResponseEvent + + try: + # Fetch all conversation items + items = await self.conversation_service.get_conversation_items( + conversation_id=conversation_id + ) + + messages: List[BaseMessage] = [] + + # Convert stored items to LangChain messages. + # Strategy: + # - USER messages: Load from THREAD_STARTED events (user queries) + # - AGENT messages: Load only from Summarizer's MESSAGE_CHUNK events + # (final conversational responses, not Planner/Critic/Executor reasoning) + + def _extract_content(payload) -> str | None: + """Normalize payload into a plain text content string. + + Handles cases where `payload` is: + - a JSON string (extract `content` or `text`) + - an object with `.content` attribute + - None + - anything else (stringify as fallback) + """ + if isinstance(payload, str): + try: + import json + + parsed = json.loads(payload) + if isinstance(parsed, dict): + return parsed.get("content") or parsed.get("text") + return str(parsed) + except Exception: + return payload + + if payload is None: + return None + + if hasattr(payload, "content"): + try: + return payload.content + except Exception: + return None + + try: + return str(payload) + except Exception: + return None + + for item in items: + if item.role == Role.USER: + content = _extract_content(item.payload) + if content: + messages.append(HumanMessage(content=content)) + + elif item.role == Role.AGENT: + if ( + item.event == StreamResponseEvent.MESSAGE_CHUNK.value + and item.agent_name in {"Summarizer", "Inquirer"} + ): + content = _extract_content(item.payload) + if content: + messages.append(AIMessage(content=content)) + + logger.info( + "Loaded {} historical messages for conversation {}", + len(messages), + conversation_id, + ) + + except Exception as exc: + logger.warning( + "Failed to load conversation history for {}: {}. Starting with empty history.", + conversation_id, + exc, + ) + messages = [] + + return messages + def _validate_execution_context( self, context: ExecutionContext, user_id: str ) -> bool: From bd283d697176947384a2afc4649c415f441a1b50 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:03:33 +0800 Subject: [PATCH 50/50] refactor(inquirer): simplify decision logic and remove RESET status for clearer intent handling --- python/valuecell/agents/react_agent/models.py | 16 +-- .../agents/react_agent/nodes/inquirer.py | 110 ++++++------------ 2 files changed, 41 insertions(+), 85 deletions(-) diff --git a/python/valuecell/agents/react_agent/models.py b/python/valuecell/agents/react_agent/models.py index 9021a6710..c0a9a300a 100644 --- a/python/valuecell/agents/react_agent/models.py +++ b/python/valuecell/agents/react_agent/models.py @@ -89,15 +89,11 @@ class InquirerDecision(BaseModel): default=None, description="A single, comprehensive natural language sentence describing the user's immediate goal. " "Resolve all context and pronouns from conversation history. " - "Examples: 'Analyze Apple stock price and fundamentals', 'Compare Apple and Tesla 2024 performance', " - "'Find reasons for Apple's recent stock price drop'. " - "Be explicit and complete - this is the primary instruction for task planning.", + "Example: 'Compare Apple and Tesla performance'.", ) - status: Literal["PLAN", "CHAT", "RESET"] = Field( - description="PLAN: Ready for task execution. CHAT: Casual conversation/greeting. RESET: Explicit command to start over." - ) - reasoning: str = Field(description="Brief thought process explaining the decision") - response_to_user: str | None = Field( - default=None, - description="Direct response to user (for CHAT replies or clarifications).", + # Only PLAN and CHAT statuses; RESET removed to simplify flow + status: Literal["PLAN", "CHAT"] = Field( + description="PLAN: Need to execute tasks. CHAT: Casual conversation/greeting." ) + reasoning: str = Field(description="Brief thought process.") + response_to_user: str | None = Field(description="Direct response for CHAT status.") diff --git a/python/valuecell/agents/react_agent/nodes/inquirer.py b/python/valuecell/agents/react_agent/nodes/inquirer.py index 412482a76..1576de80a 100644 --- a/python/valuecell/agents/react_agent/nodes/inquirer.py +++ b/python/valuecell/agents/react_agent/nodes/inquirer.py @@ -11,6 +11,7 @@ # TODO: summarize with LLM +# TODO: add user memory def _compress_history(history: list[str]) -> str: """Compress long execution history to prevent token explosion. @@ -88,64 +89,37 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: system_prompt = ( "You are the **Intent Interpreter** for a Financial Advisor Assistant.\n" - "Your job is to produce a single, comprehensive natural language sentence describing the user's IMMEDIATE goal.\n\n" - f"# CURRENT CONTEXT:\n" - f"- **Active Intent**: {current_intent or 'None (Empty)'}\n" - f"- **Recent Execution Summary**:\n{history_context}\n\n" - "# YOUR TASK: Output the user's current goal as a natural language instruction\n\n" - "# DECISION LOGIC:\n\n" - "## 1. CHAT (Greeting/Acknowledgement)\n" - "- Pattern: 'Thanks', 'Hello', 'Got it'\n" - "- Output: status='CHAT', response_to_user=[polite reply]\n\n" - "## 2. RESET (Explicit Command)\n" - "- Pattern: 'Start over', 'Reset', 'Clear everything', 'Forget that'\n" - "- Output: status='RESET', current_intent=None\n\n" - "## 3. PLAN (Task Execution Needed)\n" - "### 3a. New Analysis Request\n" - "- Pattern: 'Analyze Apple'\n" - "- Output: status='PLAN', current_intent='Analyze Apple stock price and fundamentals'\n\n" - "### 3b. Comparison Request\n" - "- Pattern: 'Compare Apple and Tesla'\n" - "- Output: status='PLAN', current_intent='Compare Apple and Tesla 2024 financial performance'\n\n" - "### 3c. Adding to Comparison (Context-Aware)\n" - "- Current Intent: 'Analyze Apple stock'\n" - "- User: 'Compare with Microsoft'\n" - "- Output: status='PLAN', current_intent='Compare Apple and Microsoft stock performance'\n" - "- **CRITICAL**: Merge context! Don't just output 'Microsoft'.\n\n" - "### 3d. Follow-up Questions (Reference Resolution)\n" - "- Current Intent: 'Analyze Apple stock'\n" - "- Recent Execution: 'AAPL price $150, down 5%'\n" - "- User: 'Why did it drop?'\n" - "- Output: status='PLAN', current_intent='Find reasons for Apple stock price drop'\n" - "- **CRITICAL**: Resolve pronouns using context! 'it' → 'Apple stock'.\n\n" - "### 3e. Specific Follow-up (Drill-Down)\n" - "- Current Intent: 'Analyze Apple stock'\n" - "- Assistant mentioned: 'consistent revenue growth'\n" - "- User: 'Tell me more about the revenue growth'\n" - "- Output: status='PLAN', current_intent='Analyze Apple revenue growth trends and details'\n" - "- **CRITICAL**: Extract the specific phrase and make it explicit!\n\n" - "### 3f. Switching Assets\n" - "- Current Intent: 'Analyze Apple stock'\n" - "- User: 'Forget Apple, look at Tesla'\n" - "- Output: status='RESET', current_intent='Analyze Tesla stock'\n\n" - "# EXAMPLES:\n\n" - "**Example 1: Adding Asset**\n" - "Current: 'Analyze Apple stock'\n" - "User: 'Compare with Microsoft'\n" - "→ {status: 'PLAN', current_intent: 'Compare Apple and Microsoft stock performance'}\n\n" - "**Example 2: Reference Resolution**\n" - "Current: 'Analyze Apple stock'\n" - "Recent: 'AAPL down 5%'\n" - "User: 'Why did it drop?'\n" - "→ {status: 'PLAN', current_intent: 'Find reasons for Apple stock price drop'}\n\n" - "**Example 3: Drill-Down**\n" - "Current: 'Analyze Apple stock'\n" - "Assistant: 'strong revenue growth'\n" - "User: 'Tell me more about revenue growth'\n" - "→ {status: 'PLAN', current_intent: 'Analyze Apple revenue growth details'}\n\n" - "**Example 4: Greeting**\n" - "User: 'Thanks!'\n" - "→ {status: 'CHAT', response_to_user: 'You're welcome!'}\n" + "Your job is to translate the conversation history into a single, unambiguous instruction for the Planner.\n\n" + f"# CONTEXT SNAPSHOT:\n" + f"- **Last Active Intent**: {current_intent or 'None'}\n" + f"- **Recent Actions**: {history_context}\n\n" + "# OUTPUT INSTRUCTIONS:\n" + "1. **current_intent**: A standalone natural language sentence describing exactly what to do next. MUST resolve all pronouns (it, they, that) using context.\n" + "2. **status**: 'PLAN' (if analysis needed) or 'CHAT' (if casual greeting).\n\n" + "# DECISION PATTERNS:\n\n" + "## 1. CHAT (No Analysis Needed)\n" + "- **Input**: 'Hello', 'Thanks', 'Okay'.\n" + "- **Output**: status='CHAT', response_to_user='[Polite Reply]'\n\n" + "## 2. PLAN (Analysis Needed) -> Output `current_intent`\n\n" + "### Case A: Starting Fresh / Switching Topic\n" + "- Input: 'Analyze Apple', 'Forget Apple, look at Tesla'.\n" + "- Action: Output the new intent directly.\n" + "- Example: 'Analyze Apple stock price and fundamentals'\n\n" + "### Case B: Refining / Comparing (Context Merging)\n" + "- **Context**: Analyzing Apple\n" + "- Input: 'Compare with Microsoft'\n" + "- **Rule**: Combine old + new. Do NOT drop the old asset unless told to.\n" + "- Example: 'Compare Apple and Microsoft stock performance'\n\n" + "### Case C: Follow-up Questions (Pronoun Resolution)\n" + "- **Context**: Analyzing Apple\n" + "- Input: 'Why did **it** drop?'\n" + "- **Rule**: Replace 'it' with the context subject.\n" + "- Example: 'Find reasons for Apple stock price drop'\n\n" + "### Case D: Deep Dive (Specifics)\n" + "- **Context**: Apple revenue is up 10%\n" + "- Input: 'Tell me more about **that revenue growth**'\n" + "- **Rule**: Be specific. Don't just say 'Analyze Apple'.\n" + "- Example: 'Analyze details and drivers of Apple's recent revenue growth'\n" ) # Build user message from conversation history @@ -198,23 +172,9 @@ async def inquirer_node(state: dict[str, Any]) -> dict[str, Any]: "inquirer_turns": 0, } - # CASE 2: RESET - Clear everything and start fresh - if decision.status == "RESET": - logger.info("Inquirer: RESET - Clearing all context") - return { - "current_intent": decision.current_intent, - "plan": [], - "completed_tasks": {}, - "execution_history": [], - "is_final": False, - "critique_feedback": None, - "messages": [ - AIMessage( - content="Starting fresh session. What would you like to analyze?" - ) - ], - "inquirer_turns": 0, - } + # NOTE: RESET status removed. Intent switches are represented as PLAN + # with a new `current_intent`. The Planner will decide whether to reuse + # history or re-fetch data as appropriate. # CASE 3: PLAN - Apply the current intent directly if decision.current_intent: