diff --git a/Manifest.toml b/Manifest.toml index 0bbbd2e..c58287b 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -1,8 +1,20 @@ # This file is machine-generated - editing it directly is not advised -julia_version = "1.12.6" +julia_version = "1.12.7" manifest_format = "2.0" -project_hash = "aa163e2bf572632825162936e107be18384fd40f" +project_hash = "43252cf86e6030e836ede74c563b58ab2f0f0548" + +[[deps.AWS]] +deps = ["Base64", "Compat", "Dates", "Downloads", "GitHub", "HTTP", "IniFile", "JSON", "MD5", "Mocking", "OrderedCollections", "Random", "SHA", "ScopedValues", "Sockets", "URIs", "UUIDs", "XMLDict"] +git-tree-sha1 = "842075b12a7f763c5f815a04037a99b6ac346df9" +uuid = "fbe9abb3-538b-5e4e-ba9e-bc94f4f92ebc" +version = "1.99.0" + +[[deps.AWSS3]] +deps = ["AWS", "ArrowTypes", "Base64", "Compat", "Dates", "EzXML", "FilePathsBase", "HTTP", "MbedTLS", "Mocking", "OrderedCollections", "Retry", "SymDict", "URIs", "UUIDs", "XMLDict"] +git-tree-sha1 = "af99cea71364c8c4483405a6b03697d197ab4d3b" +uuid = "1c724243-ef5b-51ab-93f4-b0a88ac62a95" +version = "0.11.6" [[deps.Accessors]] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] @@ -86,9 +98,9 @@ version = "1.11.0" [[deps.CSV]] deps = ["CodecZlib", "Dates", "FilePathsBase", "InlineStrings", "Mmap", "Parsers", "PooledArrays", "PrecompileTools", "SentinelArrays", "Tables", "Unicode", "WeakRefStrings", "WorkerUtilities"] -git-tree-sha1 = "8d8e0b0f350b8e1c91420b5e64e5de774c2f0f4d" +git-tree-sha1 = "abed1e735dd4152f48c90cf0767e1790e25f332f" uuid = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" -version = "0.10.16" +version = "0.10.17" [[deps.CodeTracking]] deps = ["InteractiveUtils", "REPL", "UUIDs"] @@ -110,9 +122,9 @@ version = "0.4.6" [[deps.CodecZlib]] deps = ["TranscodingStreams", "Zlib_jll"] -git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9" +git-tree-sha1 = "970758a3d591a2a5c2a907c53f2e2f8c1b1d3537" uuid = "944b1d66-785c-5afd-91f1-9de20f533193" -version = "0.7.8" +version = "0.7.9" [[deps.CodecZstd]] deps = ["TranscodingStreams", "Zstd_jll"] @@ -121,9 +133,10 @@ uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2" version = "0.8.7" [[deps.CommonSolve]] -git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1" +deps = ["PrecompileTools"] +git-tree-sha1 = "6c389fa857f6ca5a95474b52a52023fd77f24cb7" uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" -version = "0.2.13" +version = "0.2.14" [[deps.Compat]] deps = ["TOML", "UUIDs"] @@ -143,7 +156,7 @@ version = "0.1.1" [[deps.CompilerSupportLibraries_jll]] deps = ["Artifacts", "Libdl"] uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" -version = "1.3.0+1" +version = "1.3.1+2" [[deps.CompositionsBase]] git-tree-sha1 = "802bb88cd69dfd1509f6670416bd4434015693ad" @@ -181,9 +194,9 @@ uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" version = "4.2.0" [[deps.DBInterface]] -git-tree-sha1 = "a444404b3f94deaa43ca2a58e18153a82695282b" +git-tree-sha1 = "265d7f87f329c063cbde41920f9d00713a43cedd" uuid = "a10d1c49-ce27-4219-8d33-6db1a4562965" -version = "2.6.1" +version = "2.7.0" [[deps.DataAPI]] git-tree-sha1 = "abe83f3a2f1b857aac70ef8b269080af17764bbe" @@ -238,9 +251,9 @@ version = "1.11.0" [[deps.Distributions]] deps = ["AliasTables", "FillArrays", "LinearAlgebra", "PDMats", "Printf", "QuadGK", "Random", "Roots", "SpecialFunctions", "Statistics", "StatsAPI", "StatsBase", "StatsFuns"] -git-tree-sha1 = "d2facc77c08c1c2bfb1a77c148edd05b3db5410b" +git-tree-sha1 = "a958ab3a40c755563f5e1405c0846cb0446bf19d" uuid = "31c24e10-a181-5473-b8eb-7969acd0382f" -version = "0.25.130" +version = "0.25.131" [deps.Distributions.extensions] DistributionsChainRulesCoreExt = "ChainRulesCore" @@ -274,15 +287,11 @@ git-tree-sha1 = "d2e49e7efd29719d6f28b891b0e0e159daa9d2b4" uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" version = "0.1.11" -[[deps.FileIO]] -deps = ["Pkg", "Requires", "UUIDs"] -git-tree-sha1 = "6621fef488e496356c9c9625d0562c12a6070819" -uuid = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" -version = "1.20.0" -weakdeps = ["HTTP"] - - [deps.FileIO.extensions] - HTTPExt = "HTTP" +[[deps.EzXML]] +deps = ["Printf", "XML2_jll"] +git-tree-sha1 = "7ea1aa5869e2626ccae84480e4f37185bc6f41d3" +uuid = "8f5d6c58-4d21-5cfd-889c-e3ad7ee6a615" +version = "1.2.3" [[deps.FilePathsBase]] deps = ["Compat", "Dates"] @@ -318,17 +327,24 @@ uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820" version = "1.11.0" [[deps.Gamma]] -git-tree-sha1 = "86f86b6168a016ed88e4ae4e64577b98c3b59e8e" +deps = ["LogExpFunctions"] +git-tree-sha1 = "becc397f7cfb06e343496ae6ffb04818a851da51" uuid = "a0844989-3bd2-4988-8bea-c9407ab0941b" -version = "1.1.0" +version = "1.2.0" [[deps.GeneralUtils]] -deps = ["CSV", "DataFrames", "DataStructures", "Dates", "Distributions", "Graphs", "HTTP", "JSON", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SHA", "StringDistances", "UUIDs"] -git-tree-sha1 = "129b8fa1bf3bf6d8c0a080f39db09b9b986ef8de" +deps = ["AWS", "AWSS3", "CSV", "DataFrames", "DataStructures", "Dates", "Distributions", "Graphs", "HTTP", "JSON", "LibPQ", "NATS", "OrderedCollections", "PrettyPrinting", "Random", "Revise", "SHA", "StringDistances", "URIs", "UUIDs"] +git-tree-sha1 = "4d7ff3b02a9cbd6d4f65fea43551b0bca6a83a22" repo-rev = "main" repo-url = "https://git.yiem.cc/ton/GeneralUtils" uuid = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" -version = "0.5.11" +version = "0.6.9" + +[[deps.GitHub]] +deps = ["Base64", "Dates", "HTTP", "JSON", "MbedTLS", "Sockets", "SodiumSeal", "URIs"] +git-tree-sha1 = "33de1bc5665494f0a9669fd6027e28180709d874" +uuid = "bc5e4493-9b4d-5f90-b8aa-2b2bcaad7a26" +version = "5.13.0" [[deps.Graphs]] deps = ["ArnoldiMethod", "DataStructures", "Inflate", "LinearAlgebra", "Random", "SimpleTraits", "SparseArrays", "Statistics"] @@ -345,9 +361,9 @@ version = "1.14.0" [[deps.HTTP]] deps = ["Base64", "CodecZlib", "Dates", "EnumX", "PrecompileTools", "Random", "Reseau", "SHA", "URIs", "UUIDs", "Zlib_jll"] -git-tree-sha1 = "0a58fbbdee93d132a2fb1159b7f5e1b5c2465e71" +git-tree-sha1 = "49a90cd540681d5b79bf5c8ca13c92677e37005e" uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" -version = "2.6.4" +version = "2.6.6" [[deps.HashArrayMappedTries]] git-tree-sha1 = "2eaa69a7cab70a52b9687c8bf950a5a93ec895ae" @@ -377,6 +393,11 @@ git-tree-sha1 = "d1b1b796e47d94588b3757fe84fbf65a5ec4a80d" uuid = "d25df0c9-e2be-5dd7-82c8-3ad0b3e990b9" version = "0.1.5" +[[deps.IniFile]] +git-tree-sha1 = "f550e6e32074c939295eb5ea6de31849ac2c9625" +uuid = "83e8ac13-25f8-5344-8a64-a9f2b223428f" +version = "0.5.1" + [[deps.InlineStrings]] git-tree-sha1 = "8f3d257792a522b4601c24a577954b0a8cd7334d" uuid = "842dd82b-1e85-43dc-bf29-5d0ee9dffc48" @@ -436,9 +457,9 @@ version = "1.8.0" [[deps.JSON]] deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"] -git-tree-sha1 = "65979512c25a0727f050e6e4be40f0fd9ec893f7" +git-tree-sha1 = "c7345ab1a7ca4dc8a02c9f6510da0d9857bbe513" uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -version = "1.7.0" +version = "1.7.1" weakdeps = ["ArrowTypes"] [deps.JSON.extensions] @@ -471,18 +492,10 @@ git-tree-sha1 = "0f2899fdadaab4b8f57db558ba21bdb4fb52f1f0" uuid = "b39eb1a6-c29a-53d7-8c32-632cd16f18da" version = "1.21.3+0" -[[deps.LLMMCTS]] -deps = ["GeneralUtils", "JSON", "PrettyPrinting"] -git-tree-sha1 = "3dff98131dfa79be8c9bd84fc51cb0ba1832c472" -repo-rev = "main" -repo-url = "https://git.yiem.cc/ton/LLMMCTS" -uuid = "d76c5a4d-449e-4835-8cc4-dd86ec44f241" -version = "0.1.5" - [[deps.LaTeXStrings]] -git-tree-sha1 = "dda21b8cbd6a6c40d9d02a73230f9d70fed6918c" +git-tree-sha1 = "f88f3ccef05a6a72a0cf0ed417c8fd68530f4ab2" uuid = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f" -version = "1.4.0" +version = "1.4.1" [[deps.LayerDicts]] git-tree-sha1 = "6087ad3521d6278ebe5c27ae55e7bbb15ca312cb" @@ -530,6 +543,12 @@ version = "1.11.3+1" uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" version = "1.11.0" +[[deps.Libiconv_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "be484f5c92fad0bd8acfef35fe017900b0b73809" +uuid = "94ce4f54-9a6c-5748-9c1c-f9c7231a4531" +version = "1.18.0+0" + [[deps.LinearAlgebra]] deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" @@ -567,6 +586,12 @@ git-tree-sha1 = "191686b1ac1ea9c89fc52e996ad15d1d241d1e33" uuid = "5ced341a-0733-55b8-9ab6-a4889d929147" version = "1.10.1+0" +[[deps.MD5]] +deps = ["Random", "SHA"] +git-tree-sha1 = "1576f756617d31eb397a4a517b68562fd28dc2b4" +uuid = "6ac74813-4b46-53a4-afec-0b5dc9d7885c" +version = "0.2.3" + [[deps.MacroTools]] git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" @@ -655,7 +680,7 @@ version = "0.8.7+0" [[deps.OpenSSL_jll]] deps = ["Artifacts", "Libdl"] uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95" -version = "3.5.4+0" +version = "3.5.6+0" [[deps.OpenSpecFun_jll]] deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl"] @@ -684,15 +709,6 @@ git-tree-sha1 = "3de8f5e6e90ebfa8d6d1f86997d6cdcd6a912ff3" uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" version = "2.8.7" -[[deps.Pkg]] -deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "Random", "SHA", "TOML", "Tar", "UUIDs", "p7zip_jll"] -uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" -version = "1.12.1" -weakdeps = ["REPL"] - - [deps.Pkg.extensions] - REPLExt = "REPL" - [[deps.PooledArrays]] deps = ["DataAPI", "Future"] git-tree-sha1 = "36d8b4b899628fb92c2749eb488d884a926614d3" @@ -718,9 +734,9 @@ version = "0.4.3" [[deps.PrettyTables]] deps = ["Crayons", "LaTeXStrings", "Markdown", "PrecompileTools", "Printf", "REPL", "Reexport", "StringManipulation", "Tables"] -git-tree-sha1 = "4ac881f5432bd93463a41767a814a45245be22b6" +git-tree-sha1 = "1b8aa19f229b1cea7fc93874a52e49db6a854450" uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" -version = "3.4.6" +version = "3.4.8" [deps.PrettyTables.extensions] PrettyTablesExcelExt = "XLSX" @@ -781,15 +797,20 @@ version = "1.3.1" [[deps.Reseau]] deps = ["NetworkOptions", "OpenSSL_jll", "PrecompileTools", "Random", "SHA"] -git-tree-sha1 = "701ef63506992668c0069a4d1ac1540b77258e6c" +git-tree-sha1 = "aaee74fab545e9af4b21d10c2203c93c636c4ae0" uuid = "802f3686-a58f-41ce-bb0c-3c43c75bba36" -version = "1.3.6" +version = "1.4.1" + +[[deps.Retry]] +git-tree-sha1 = "41ac127cd281bb33e42aba46a9d3b25cd35fc6d5" +uuid = "20febd7b-183b-5ae2-ac4a-720e7ce64774" +version = "0.4.1" [[deps.Revise]] deps = ["CRC32c", "CodeTracking", "FileWatching", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"] -git-tree-sha1 = "ec46aed6a3a8cc6b67839ca361e7b4aa32eaeee1" +git-tree-sha1 = "ab0f5630e37968bc49b8c4cedd3279a7d30b9486" uuid = "295af30f-e4ad-537b-8983-00126c2a3abe" -version = "3.16.3" +version = "3.17.0" weakdeps = ["Distributed"] [deps.Revise.extensions] @@ -809,9 +830,9 @@ version = "0.5.2+0" [[deps.Roots]] deps = ["Accessors", "CommonSolve", "Printf"] -git-tree-sha1 = "7fb25a964849d90a0446366cdefca822e0e84900" +git-tree-sha1 = "13a9e0164267bb9ebc55c1c5d72fceea8f09555b" uuid = "f2b01f46-fcfa-551c-844a-d8ac1e96c665" -version = "3.0.6" +version = "3.0.7" [deps.Roots.extensions] RootsChainRulesCoreExt = "ChainRulesCore" @@ -833,14 +854,6 @@ version = "3.0.6" uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" version = "0.7.0" -[[deps.SQLLLM]] -deps = ["CSV", "DataFrames", "DataStructures", "Dates", "FileIO", "GeneralUtils", "HTTP", "JSON", "LLMMCTS", "LibPQ", "PrettyPrinting", "Random", "Revise", "StatsBase", "Tables", "URIs", "UUIDs"] -git-tree-sha1 = "bae2fd2e2b087753fbb3415896be41df1ae0eb90" -repo-rev = "main" -repo-url = "https://git.yiem.cc/ton/SQLLLM" -uuid = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3" -version = "0.2.8" - [[deps.SQLStrings]] git-tree-sha1 = "55de0530689832b1d3d43491ee6b67bd54d3323c" uuid = "af517c2e-c243-48fa-aab8-efac3db270f5" @@ -884,6 +897,12 @@ git-tree-sha1 = "907703e0d50846f300650d7225bdcab145b7bca9" uuid = "4f5b5e99-b0ad-42cd-b47a-334e172ec8bd" version = "1.1.2" +[[deps.SodiumSeal]] +deps = ["Base64", "Libdl", "libsodium_jll"] +git-tree-sha1 = "80cef67d2953e33935b41c6ab0a178b9987b1c99" +uuid = "2133526b-2bfb-4018-ac12-889fb3908a75" +version = "0.1.1" + [[deps.SortingAlgorithms]] deps = ["DataStructures"] git-tree-sha1 = "13cd91cc9be159e3f4d95b857fa2aa383b53772a" @@ -897,9 +916,9 @@ version = "1.12.0" [[deps.SpecialFunctions]] deps = ["IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"] -git-tree-sha1 = "c3ac026e735264e9bdc6a9bcbd1b1e781b36e3bc" +git-tree-sha1 = "429071b23f4c9a13fb6582f807cc2ef454082408" uuid = "276daf66-3868-5448-9aa4-cd146d93841b" -version = "2.8.3" +version = "2.9.0" [deps.SpecialFunctions.extensions] SpecialFunctionsChainRulesCoreExt = "ChainRulesCore" @@ -909,9 +928,9 @@ version = "2.8.3" [[deps.StaticArrays]] deps = ["LinearAlgebra", "PrecompileTools", "Random", "StaticArraysCore"] -git-tree-sha1 = "246a8bb2e6667f832eea063c3a56aef96429a3db" +git-tree-sha1 = "fac51faf3bb96e8bc0bf6f9f39ca4955652776bb" uuid = "90137ffa-7385-5640-81b9-e52037218182" -version = "1.9.18" +version = "1.9.19" [deps.StaticArrays.extensions] StaticArraysChainRulesCoreExt = "ChainRulesCore" @@ -928,9 +947,9 @@ version = "1.4.4" [[deps.Statistics]] deps = ["LinearAlgebra"] -git-tree-sha1 = "ae3bb1eb3bba077cd276bc5cfc337cc65c3075c0" +git-tree-sha1 = "389592b8592e5fb48f498ff60dc6acb4a0e62953" uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" -version = "1.11.1" +version = "1.11.4" weakdeps = ["SparseArrays"] [deps.Statistics.extensions] @@ -944,9 +963,9 @@ version = "1.8.0" [[deps.StatsBase]] deps = ["AliasTables", "DataAPI", "DataStructures", "IrrationalConstants", "LinearAlgebra", "LogExpFunctions", "Missings", "Printf", "Random", "SortingAlgorithms", "SparseArrays", "Statistics", "StatsAPI"] -git-tree-sha1 = "e4d7a1a0edc20af42689ea6f4f3587a2175d50ee" +git-tree-sha1 = "adb9da019510162e67a4493fc235c23203d8b09e" uuid = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" -version = "0.34.12" +version = "0.34.13" [[deps.StatsFuns]] deps = ["HypergeometricFunctions", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"] @@ -964,15 +983,15 @@ version = "2.2.1" [[deps.StringDistances]] deps = ["Distances", "StatsAPI"] -git-tree-sha1 = "cd83a04baf746e3b43b83c61b7de77ab0409b80a" +git-tree-sha1 = "456f343c7a6c8f52d8b501c0da2a05538ab1b907" uuid = "88034a9c-02f8-509d-84a9-84ec65e18404" -version = "1.0.0" +version = "1.0.1" [[deps.StringManipulation]] deps = ["PrecompileTools"] -git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484" +git-tree-sha1 = "773065c6e0e903924a9d838259be74338422aef2" uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e" -version = "0.4.7" +version = "0.5.0" [[deps.StringViews]] git-tree-sha1 = "f2dcb92855b31ad92fe8f079d4f75ac57c93e4b8" @@ -987,9 +1006,9 @@ version = "1.11.0" [[deps.StructUtils]] deps = ["Dates", "UUIDs"] -git-tree-sha1 = "c65ae4aa47e543c278aea0a3468786d33021a3ff" +git-tree-sha1 = "2d0fc55c61321ba245c47be599570d11bac50303" uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" -version = "2.8.4" +version = "2.8.5" [deps.StructUtils.extensions] StructUtilsMeasurementsExt = ["Measurements"] @@ -1014,6 +1033,12 @@ deps = ["Artifacts", "Libdl", "libblastrampoline_jll"] uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" version = "7.8.3+2" +[[deps.SymDict]] +deps = ["Test"] +git-tree-sha1 = "0108ccdaea3ef69d9680eeafc8d5ad198b896ec8" +uuid = "2da68c74-98d7-5633-99d6-8493888d7b1e" +version = "0.3.0" + [[deps.TOML]] deps = ["Dates"] uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" @@ -1021,9 +1046,9 @@ version = "1.0.3" [[deps.TZJData]] deps = ["Artifacts"] -git-tree-sha1 = "72df96b3a595b7aab1e101eb07d2a435963a97e2" +git-tree-sha1 = "d3b30a9f29a898e21fb4bdf280101b7a12e1f39c" uuid = "dc5dba14-91b3-4cab-a142-028a31da12f7" -version = "1.5.0+2025b" +version = "1.5.1+2025b" [[deps.TableTraits]] deps = ["IteratorInterfaceExtensions"] @@ -1033,14 +1058,9 @@ version = "1.0.1" [[deps.Tables]] deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "OrderedCollections", "TableTraits"] -git-tree-sha1 = "0f38a06c83f0007bbab3cf911262841c9a0f07e0" +git-tree-sha1 = "a94d9bdda1b7bed0046cea645639ab3f62196fac" uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" -version = "1.13.0" - -[[deps.Tar]] -deps = ["ArgTools", "SHA"] -uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" -version = "1.10.0" +version = "1.14.0" [[deps.Test]] deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] @@ -1063,9 +1083,9 @@ uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" version = "0.11.3" [[deps.URIs]] -git-tree-sha1 = "3b0738bd7c5645641845da25cbd99800b8718689" +git-tree-sha1 = "908fec9df6c5de98548ead82a468c95ccf6cd263" uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" -version = "1.6.2" +version = "1.7.0" [[deps.UTCDateTimes]] deps = ["Dates", "TimeZones"] @@ -1093,8 +1113,20 @@ git-tree-sha1 = "cd1659ba0d57b71a464a29e64dbc67cfe83d54e7" uuid = "76eceee3-57b5-4d4a-8e66-0e911cebbf60" version = "1.6.1" +[[deps.XML2_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Libiconv_jll", "Zlib_jll"] +git-tree-sha1 = "3f3315d89fc954a28f5b471bce698ed6e27481be" +uuid = "02c8fc9c-b97f-50b9-bbe4-9be30ff0a78a" +version = "2.15.3+0" + +[[deps.XMLDict]] +deps = ["EzXML", "IterTools", "OrderedCollections"] +git-tree-sha1 = "8e8cdc9c93e1f442eba471b56db6f86ffeb1a33a" +uuid = "228000da-037f-5747-90a9-8195ccbf91a5" +version = "0.4.2" + [[deps.YiemAgent]] -deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "GeneralUtils", "HTTP", "JSON", "LLMMCTS", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SQLLLM", "Serialization", "URIs", "UUIDs"] +deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "HTTP", "JSON", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "Serialization", "URIs", "UUIDs", "msghandler"] path = "." uuid = "e012c34b-7f78-48e0-971c-7abb83b6f0a2" version = "0.8.0" @@ -1122,12 +1154,12 @@ uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8" version = "1.0.21+0" [[deps.msghandler]] -deps = ["Arrow", "Base64", "DataFrames", "Dates", "GeneralUtils", "HTTP", "JSON", "NATS", "PrettyPrinting", "Revise", "UUIDs"] -git-tree-sha1 = "e82a79cf6602541ea25409aded57b2ace4a7c29f" +deps = ["Arrow", "Base64", "DataFrames", "Dates", "GeneralUtils", "HTTP", "JSON", "NATS", "PrettyPrinting", "UUIDs"] +git-tree-sha1 = "e7889a120327db84cdf1b1acf4a2c7caba6e8ef7" repo-rev = "main" repo-url = "https://git.yiem.cc/ton/msghandler" uuid = "f2724d33-f338-4a57-b9f8-1be882570d10" -version = "1.2.1" +version = "1.4.0" [[deps.nghttp2_jll]] deps = ["Artifacts", "Libdl"] diff --git a/Project.toml b/Project.toml index 7fea2af..3f8466f 100644 --- a/Project.toml +++ b/Project.toml @@ -12,13 +12,11 @@ Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -LLMMCTS = "d76c5a4d-449e-4835-8cc4-dd86ec44f241" LibPQ = "194296ae-ab2e-5f79-8cd4-7183a0a5a0d1" NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a" PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" -SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" @@ -28,10 +26,8 @@ msghandler = "f2724d33-f338-4a57-b9f8-1be882570d10" Base64 = "1.11.0" CSV = "0.10.15" DataFrames = "1.7.0" -GeneralUtils = "0.5.10" +GeneralUtils = "0.6.9" HTTP = "2.4.0" JSON = "1.6.1" -LLMMCTS = "0.1.5" NATS = "0.1.0" -SQLLLM = "0.2.8" -msghandler = "1.2.1" +msghandler = "1.4.0" diff --git a/README.md b/README.md index ed1558c..841f3ad 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,311 @@ # YiemAgent -Julia framework for building agents with tool use. +Julia framework for building agents with tool use and MCP (Model Context Protocol) support. -## Getting Started +## Quick Start -1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...` -2. Create a `yiemAgent` with `loadTools("src/tools")` -3. Call `runAgent(agent, "message")` then `takeResponse(agent)` +```julia +using YiemAgent + +# 1. Create an LLM callable (see "Custom LLM Callable" below) +llmCall = ... + +# 2. Create a callable MCP server (see "MCP Server Integration" below) +mcpServer = ... + +# 3. Create the agent +agent = yiemAgent(llmCall; mcpServer=mcpServer, eventSink=println) + +# 4. Interact +promptAgent(agent, "What tools do you have?") +response = takeResponse(agent) + +# 5. Stop when done +stopAgent(agent) +``` + +## Agent API + +| Function | Description | +|----------|-------------| +| `promptAgent(agent, text)` | Send plain text to the agent's input channel | +| `promptAgent(agent, msg)` | Send a structured message (OpenAI format dict) to the input channel | +| `takeResponse(agent)` | Block and take the agent's response from output channel | +| `followUp(agent, text)` | Send plain text follow-up while agent is still processing | +| `followUp(agent, msg)` | Send a structured follow-up (OpenAI format dict) while agent is processing | +| `stopAgent(agent)` | Gracefully stop the agent and close channels | + +## Creating a yiemAgent + +```julia +yiemAgent(llmCall; + systemPrompt::String = "You are a helpful assistant.", + model = nothing, + messages::Vector{agentMessage} = agentMessage[], + prepareContext::Function = prepareContext, + formatMsgForLLM::Function = formatMsgForLLM, + beforeToolCall::Function = beforeToolCall, + afterToolCall::Function = afterToolCall, + sessionId::Union{String, Nothing} = nothing, + maxRetryDelayMs::Union{Int64, Nothing} = nothing, + parallelToolExecute::Bool = false, + eventSink::Function = eventSink, + mcpServer = nothing +) +``` + +| Parameter | Description | +|-----------|-------------| +| `llmCall` | **(required)** Function that receives formatted messages and returns an LLM response | +| `systemPrompt` | System prompt for the agent | +| `model` | LLM model configuration (`llmModel` struct) | +| `messages` | Initial conversation messages | +| `prepareContext` | Hook to transform messages/context before each LLM call | +| `formatMsgForLLM` | Hook to convert agent context to LLM API format | +| `beforeToolCall` | Hook to block/allow tool execution | +| `afterToolCall` | Hook to post-process tool results | +| `sessionId` | Optional session identifier for MCP server | +| `maxRetryDelayMs` | Maximum retry delay in milliseconds | +| `parallelToolExecute` | Run multiple tool calls in parallel | +| `eventSink` | Callback for agent events/debug messages | +| `mcpServer` | Callable struct for MCP server communication | + +## Usage Examples + +### Text Messages + +```julia +# Send plain text +promptAgent(agent, "What tools do you have?") +response = takeResponse(agent) + +# Follow-up while agent is processing +promptAgent(agent, "Tell me about wine") +followUp(agent, "Also include prices") +response = takeResponse(agent) +``` + +### Structured Messages (OpenAI Format) + +```julia +promptAgent(agent, Dict("role" => "user", "content" => "Hello")) +response = takeResponse(agent) +``` + +### Output Format + +`takeResponse()` returns a `Dict{String, Any}` in OpenAI message format: + +```julia +Dict( + "role" => "assistant", + "content" => [ + Dict("type" => "text", "text" => "The weather in Tokyo is Sunny, 22°C."), + # ... more content blocks + ] +) +``` + +Content block types: + +| Block Type | Fields | Description | +|------------|--------|-------------| +| `text` | `"type" => "text"`, `"text" => "..."` | Plain text response from the LLM | +| `image_url` | `"type" => "image_url"`, `"image_url" => Dict("url" => "...")` | Image content (rare, used by multimodal models) | + +Example — extracting the text response: + +```julia +response = takeResponse(agent) +text_response = first(c["text"] for c in response["content"] if c["type"] == "text") +``` + +### Multimodal Messages (Text + Image) + +```julia +# Encode images to data URIs +image_path = "test/image.png" +image_bytes = read(image_path) +base64_string = base64encode(image_bytes) +data_uri = "data:image/png;base64,$(base64_string)" + +# Build message with text and image content blocks +message = Dict( + "role" => "user", + "content" => [ + Dict("type" => "text", "text" => "Do you have Brunello di Montalcino?"), + Dict("type" => "image_url", "image_url" => Dict("url" => data_uri)) + ] +) +promptAgent(agent, message) +response = takeResponse(agent) +``` + +### Custom Hooks + +```julia +# beforeToolCall — request user approval for destructive tools +function my_before_tool_call(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult + if context.toolCall.name == "deleteFile" + return beforeToolCallResult(true, "User must approve file deletion") + end + return beforeToolCallResult(false, "N/A") +end + +# afterToolCall — mask sensitive data from results +function my_after_tool_call(context::afterToolCallContext, signal::abortSignal)::Union{agentToolResult, Nothing} + if context.isError + return agentToolResult( + [textContent("The tool encountered an error. Please try again.")], + context.result.details, context.result.usage, context.result.terminate + ) + end + return nothing +end + +agent = yiemAgent(llmCall; + mcpServer=mcpServer, + beforeToolCall=my_before_tool_call, + afterToolCall=my_after_tool_call, + eventSink=println +) +``` + +### Custom LLM Callable + +```julia +function my_llm_call(messages_dict::Dict{String, Any}) + # messages_dict contains "messages" key with OpenAI-format message array, + # plus optional "tools", "temperature", etc. + + # Call your LLM provider (OpenAI, Anthropic, local model, etc.) + raw_response = call_my_llm_provider(messages_dict) + + # Return a dict with these fields: + return Dict( + "message" => Dict("role" => "assistant", "content" => raw_response["content"]), + "finish_reason" => raw_response["finish_reason"], + "api" => "my_provider", + "provider" => "my_provider", + "model" => "my-model-name", + "usage" => llmUsage(100, 50) + ) +end +``` + +### Custom Context Preparation + +```julia +function my_prepare_context(state::agentState, eventSink)::agentContext + sysPrompt = state.systemPrompt * "\nCurrent time: $(now())" + msgs = deepcopy(state.messages) + + # Prune old messages to fit context window + max_messages = 20 + if length(msgs) > max_messages + msgs = msgs[end-max_messages+1:end] + end + + return agentContext(sysPrompt, msgs, state.tools) +end + +agent = yiemAgent(llmCall; prepareContext=my_prepare_context, ...) +``` + +## MCP Server Integration + +YiemAgent uses JSON-RPC 2.0 for MCP communication. The `mcpServer` callable struct must implement `tools/list` and `tools/call` methods. + +### Protocol + +```julia +# tools/list — returns tool definitions +mcpServer("tools/list") +# → Dict("jsonrpc"=>"2.0", "id"=>1, "result"=>Dict("tools"=>[...], "nextCursor"=>...)) + +# tools/call — executes a tool +mcpServer("tools/call", toolName, arguments) +# → Dict("jsonrpc"=>"2.0", "id"=>2, "result"=>Dict("content"=>[...], "isError"=>...)) + +# Protocol error responses include "error" instead of "result": +Dict("jsonrpc"=>"2.0", "id"=>2, "error"=>Dict("code"=>-32602, "message"=>"...")) +``` + +### Example: NATS-based MCP Server Client + +```julia +struct mcpServer + natsConn::NATS.Connection + topic::String + senderID::String + fileserver_url::String +end + +# tools/list implementation +function (c::mcpServer)(method::String) + payload = Dict("jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => Dict{String, Any}()) + payloads = [("payload", payload, "dictionary")] + _, msg_envelope = msghandler.smartpack(c.topic, payloads; + sender_id=c.senderID, msg_purpose="mcp_tools_list", fileserver_url=c.fileserver_url) + reply = NATS.request(c.natsConn, c.topic, msg_envelope, timeout=180) + incoming = msghandler.smartunpack(String(reply.payload)) + return incoming["payloads"][1][2] +end + +# tools/call implementation +function (c::mcpServer)(method::String, toolName::String, arguments::Dict{String, Any}) + payload = Dict("jsonrpc" => "2.0", "id" => 2, "method" => method, + "params" => Dict("name" => toolName, "arguments" => arguments)) + payloads = [("payload", payload, "dictionary")] + _, msg_envelope = msghandler.smartpack(c.topic, payloads; + sender_id=c.senderID, msg_purpose="mcp_tool_call", fileserver_url=c.fileserver_url) + reply = NATS.request(c.natsConn, c.topic, msg_envelope, timeout=180) + incoming = msghandler.smartunpack(String(reply.payload)) + return incoming["payloads"][1][2] +end +``` + +## Tool Discovery + +Tools are discovered dynamically via the MCP server: + +1. `listTools` is the only pre-registered tool at agent creation +2. When the LLM calls `listTools()`, tools are discovered from the MCP server and registered at runtime +3. Supports pagination via `nextCursor` for large tool sets +4. New tools become immediately available without restarting the agent + +## Hooks + +Hooks allow customization of the agent's behavior at key points in the execution pipeline. + +| Hook | Signature | Purpose | +|------|-----------|---------| +| `prepareContext` | `(state::agentState, eventSink) -> agentContext` | Transform messages/context before LLM call (filter tools, inject context, prune messages) | +| `formatMsgForLLM` | `(ctx::agentContext, eventSink) -> Dict` | Convert agent context to LLM API message format | +| `beforeToolCall` | `(context::beforeToolCallContext, signal::abortSignal) -> beforeToolCallResult` | Block/allow tool execution; request user approval | +| `afterToolCall` | `(context::afterToolCallContext, signal::abortSignal) -> Union{agentToolResult, Nothing}` | Post-process tool results; mask sensitive data; set `terminate` flag | +| `eventSink` | `(msg) -> nothing` | Callback for agent events/debug messages throughout the pipeline | + +## Agent Lifecycle + +1. **Creation** — `yiemAgent()` spawns a background task (`_agentLoop`) listening on `inputChannel` and `followUpChannel` +2. **Input** — User messages enter via `promptAgent()` (immediate) or `followUp()` (queued while agent is busy) +3. **Processing** — `_processMessage()` handles LLM calls, tool execution, and conversation history in a loop +4. **Tool Execution Pipeline** — each tool call follows three phases: + - `prepareToolCall` — resolve tool, validate arguments, run `beforeToolCall` hook + - `executePreparedToolCall` — invoke `tool.execute()` + - `finalizeExecutedToolCall` — run `afterToolCall` hook, construct `toolResultMessage` +5. **Termination** — tool `execute` functions can set `terminate=true` to stop the agent loop +6. **Shutdown** — `stopAgent()` sends a `:shutdown` signal, drains channels, and closes the agent ## Architecture ``` src/ ├── YiemAgent.jl # Module entry point -├── type.jl # Core types (messages, tools, agent state) -├── utils.jl # Message formatting, validation -├── agentCore.jl # Agent loop, tool execution pipeline -├── api.jl # Public API (runAgent, takeResponse, etc.) -└── tools/ - ├── registry.jl # Tool registry (loadTools, registerTool, listTools) - ├── getWeather.jl # Weather lookup tool - ├── getTime.jl # Time lookup tool - ├── writeTool.jl # Create new tool files (self-modifying) - └── README.md # Tool development guide +├── type.jl # Core types (messages, tools, agent state, events) +├── utils.jl # Message formatting, context preparation, validation +├── toolRegistry.jl # Tool store, MCP discovery, listTools registration +├── agentCore.jl # Agent loop, tool execution pipeline, yiemAgent struct +└── api.jl # Public API (promptAgent, takeResponse, followUp, stopAgent) ``` - -## Tool Development - -See `src/tools/README.md` for: -- Tool anatomy (schema, execute, getTool) -- Validation hooks -- Agent loop lifecycle -- Self-modifying tools (`writeTool`) diff --git a/README_tools.md b/README_tools.md deleted file mode 100644 index 9560f36..0000000 --- a/README_tools.md +++ /dev/null @@ -1,1599 +0,0 @@ -# Tools — Complete End-to-End Lifecycle - -This document describes the complete tool lifecycle in the YiemAgent framework, from definition through execution, for framework authors and maintainers who need a thorough understanding of the architecture. - ---- - -## Table of Contents - -1. [Quick Start: Tool Lifecycle](#1-quick-start-tool-lifecycle) -2. [Overview](#2-overview) -3. [Tool Definition — The `agentTool` Struct](#3-tool-definition--the-agenttool-struct) -4. [Tool Registration — Static Registration](#4-tool-registration--static-registration) -5. [The Agent Loop — High-Level Flow](#5-the-agent-loop--high-level-flow) -6. [Message Processing Pipeline](#6-message-processing-pipeline) -7. [Tool Call Extraction from LLM Response](#7-tool-call-extraction-from-llm-response) -8. [The Per-Call Pipeline — Prepare, Execute, Finalize](#8-the-per-call-pipeline--prepare-execute-finalize) -9. [Execution Modes — Sequential vs Parallel](#9-execution-modes--sequential-vs-parallel) -10. [Tool Call Batches & Termination Logic](#10-tool-call-batches--termination-logic) -11. [Tool Result Message Creation](#11-tool-result-message-creation) -12. [Error Handling & Recovery Pattern](#12-error-handling--recovery-pattern) -13. [Event System — Tool Lifecycle Events](#13-event-system--tool-lifecycle-events) -14. [Agent Lifecycle Hooks](#14-agent-lifecycle-hooks) -15. [Self-Modifying Tools](#15-self-modifying-tools) -16. [Complete End-to-End Example](#16-complete-end-to-end-example) -17. [Tool File Contract](#17-tool-file-contract) -18. [Adding New Tools](#18-adding-new-tools) -19. [Appendix: Type Reference](#19-appendix-type-reference) - ---- - -## 1. Quick Start: Tool Lifecycle - -This section shows the complete lifecycle from tool registration through execution and result extraction. Each step maps to the detailed sections below. - -### Step 1: Discover — `listTools` - -The agent calls the `listTools` tool to see available tools and detect name collisions before creating new ones. - -```julia -# The listTools tool is auto-injected via listTool(store) — no manual registration needed -tool = listTool(store) # Returns an agentTool that, when executed, lists all tools in the store -``` - -**Result extraction:** -```julia -result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x) -# result.content[1].text => "Available tools:\n- getWeather: Weather Lookup — Fetch current weather...\n- getTime: Time Lookup — Get current local time..." -``` - -**Source:** `toolRegistry.jl:43-98` - ---- - -### Step 2: Register — `register_all_tools()` - -Tools are statically defined in `src/tools/` and registered at module initialization via `register_all_tools()`. Each tool function (e.g., `getWeatherTool()`, `getTimeTool()`, `writeToolTool()`) is called to create the `agentTool` struct. `listTool` is auto-registered so the LLM can discover available tools. - -```julia -using YiemAgent, YiemAgent.toolRegistry - -store = toolStore(name="myAgent") -tools = register_all_tools(store) -# Calls getWeatherTool(), getTimeTool(), writeToolTool() to create agentTool structs -# Also auto-registers listTools for runtime discovery -``` - -**Result extraction:** -```julia -all_tools = getTools(store) # OrderedDict{String, agentTool} -# Keys: "getWeather", "getTime", "writeTool", "listTools" -getWeather_tool = all_tools["getWeather"] - -# Manual registration (alternative to register_all_tools) -registerTool(store, my_tool) -clearTools(store) # Clear all tools from store -``` - -**Source:** `toolRegistry.jl:38-40, 127-143` - ---- - -### Step 2.5: Create Agent with Tools - -Wire the registered tools into a new `yiemAgent` instance. The `yiemAgent` constructor calls `register_all_tools()` automatically. - -```julia -using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry - -# Create agent — tools are registered automatically via register_all_tools() -agent = yiemAgent( - llmCall = my_llm_call, # Function that calls the LLM API - agentEventSink = my_event_sink, # Function for TUI/logging -) -``` - -**Key constructor parameters:** - -| Parameter | Type | Required | Purpose | -|-----------|------|----------|---------| -| `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM | -| `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events | -| `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text | -| `model` | `llmModel` | No | LLM model config | -| `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history | - -Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`. - -**Source:** `type.jl:609-657`, `toolRegistry.jl:127-143` - ---- - -### Step 3: Use — Tool Execution - -Tools can be used in two ways: - -**Direct execution (testing / standalone):** -```julia -using YiemAgent.type - -sig = nothing -op = x -> x # no-op partial result callback - -# Execute a loaded tool directly -result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) -``` - -**Via agent loop (production):** -``` -user message → runAgent(agent, Dict("role"=>"user", "content"=>...)) - → _agentLoop detects message → @spawn _processMessage(agent) - → prepareContext → formatMsgForLLM → llmCall - → LLM returns tool_calls - → executeToolCalls(context, response, tool_call_list, config, signal, emit) - → prepareToolCall → executePreparedToolCall → finalizeExecutedToolCall - → createToolResultMessage → batch.messages (toolResultMessage[]) -``` - -**Source:** Direct: `test/toolTest.jl:81-99` | Agent: `agentCore.jl:35-311` - ---- - -### Step 4: Extract Result - -**`agentToolResult`** (raw tool output, `type.jl:429-434`): -```julia -result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x) - -result.content[1] # textContent("Weather in Tokyo: Sunny, 22°C") -result.content[1].text # "Weather in Tokyo: Sunny, 22°C" -result.details # Dict{Any,Any}() — tool-specific metadata -result.usage # nothing — llmUsage tracking (optional) -result.terminate # false — signals loop termination -``` - -**`toolResultMessage`** (wrapped for conversation history, `type.jl:152-191`): -```julia -msg = batch.messages[1] # toolResultMessage - -msg.toolCallId # "call-1" -msg.toolName # "getWeather" -msg.content # Vector{messageContent} -msg.isError # false -msg.details # tool-specific metadata -msg.timestamp # DateTime -``` - ---- - -## 2. Overview - -The tool system follows a **three-phase pipeline** per tool call: - -``` -PREPARE → EXECUTE → FINALIZE -``` - -Each phase has a single responsibility and produces an intermediate result: - -| Phase | Function | Input | Output | Purpose | -|-------|----------|-------|--------|---------| -| Prepare | `prepareToolCall()` | `agentContext`, `assistantMessage`, `agentToolCall`, `agentLoopConfig`, `abortSignal` | `preparedToolCall` or `immediateOutcome` | Resolve tool, validate args, run pre-hook | -| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `agentEventSink` | `executedOutcome` | Call `tool.execute()`, stream partial results | -| Finalize | `finalizeExecutedToolCall()` | `agentContext`, `assistantMessage`, `preparedToolCall`, `executedOutcome`, `agentLoopConfig`, `abortSignal` | `finalizedOutcome` | Run post-hook, emit end event | - -The pipeline ensures that **every tool call produces a result**, even on failure. Errors are captured as `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` with `isError=true`, then converted to `toolResultMessage` objects that are fed back to the LLM conversation history. - ---- - -## 3. Tool Definition — The `agentTool` Struct - -**Source:** `type.jl:261-281` - -Every tool is an `agentTool` struct with the following fields: - -```julia -struct agentTool - name::String # Unique identifier (e.g. "getWeather") - label::String # Human-readable name (e.g. "Weather Lookup") - description::String # What the tool does (shown to the LLM for selection) - inputSchema::Any # JSON Schema (MCP format) describing parameters - execute::Function # Core execution: (toolCallId, args, signal, onPartialResult) -> agentToolResult - prepareArguments::Union{Function, Nothing} # Optional: (args) -> modified_args (before validation) - validateRequiredArgs::Union{Function, Nothing} # Optional: (args) -> Union{Nothing, String} error - parallelToolExecute::Bool # Override: run in parallel with other tools -end -``` - -### Field Details - -| Field | Required | Signature | Purpose | -|-------|----------|-----------|---------| -| `name` | Yes | `String` | Unique key for tool lookup in `context.tools[name]` | -| `label` | Yes | `String` | Human-readable name for display | -| `description` | Yes | `String` | Shown to LLM for tool selection decisions | -| `inputSchema` | Yes | `Dict{String,Any}` | JSON Schema (MCP format) with `type`, `properties`, `required` | -| `execute` | Yes | `Function` | The actual tool logic (see signature below) | -| `prepareArguments` | No | `Function` | Transforms args before validation; `(args::Dict) -> Dict` | -| `validateRequiredArgs` | No | `Function` | Custom validation; `(args::Dict) -> Union{Nothing, String}` | -| `parallelToolExecute` | No | `Bool` | Default `false`. When `true`, allows parallel execution | - -### Execute Function Signature - -```julia -execute(toolCallId::String, - args::Dict{String,Any}, - signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult -``` - -| Parameter | Description | -|-----------|-------------| -| `toolCallId` | Unique ID from the LLM's tool call (e.g., `"call_abc123"`) | -| `args` | Validated arguments from the LLM, already passed through `prepareArguments` and `validateRequiredArgs` | -| `signal` | Optional `abortSignal` for cancellable operations. Check `signal.aborted` to abort early. | -| `onPartialResult` | Callback for streaming progress: `onPartialResult(partial_data)` emits `toolExecUpdateEvent` | - -### Returns — `agentToolResult` - -**Source:** `type.jl:429-434` - -```julia -struct agentToolResult - content::Vector{messageContent} # Output content (textContent, etc.) - details::Dict{Any,Any} # Tool-specific metadata (e.g., counts, IDs) - usage::Union{llmUsage, Nothing} # Token usage tracking (optional) - terminate::Bool # If true, signals the tool requested loop termination -end -``` - -The `terminate` flag is checked at the batch level. See [Section 10](#10-tool-call-batches--termination-logic) for details. - ---- - -## 4. Tool Registration — Static Registration - -**Source:** `toolRegistry.jl`, `YiemAgent.jl` - -### How `toolStore` Works - -The registry uses **per-agent isolated storage** via the `toolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. - -```julia -struct toolStore - tools::OrderedDict{String, agentTool} # keyed by name for O(1) lookup + ordered iteration - name::String # identifier for debugging/logs -end -``` - -`store.tools` is an `OrderedDict` — it provides O(1) lookup by tool name and preserves insertion order for iteration. `getTools(store)` returns this `OrderedDict` directly (not a copy), so mutations on the returned value affect the store. - -### Static Registration — `register_all_tools()` - -```julia -function register_all_tools(store::toolStore)::OrderedDict{String, agentTool} -``` - -**Source:** `YiemAgent.jl:20-29` - -1. **Calls each tool's definition function** — `getWeatherTool()`, `getTimeTool()`, `writeToolTool()` — which return `agentTool` structs -2. **Registers each tool** via `registerTool(store, tool)` -3. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime - -### Why Static? - -Tools are **statically included** in `YiemAgent.jl` via `include()`. This means: -- Tool functions live in the `YiemAgent` module, not in dynamically created submodules -- No world-age issues when calling `tool.execute()` (Julia compiles dispatch in the same world) -- Simpler tool definition — no need to wrap in a `module ... end` block -- Better compiler optimization (inlining, type inference) - -### Registration API - -```julia -# Create per-agent stores -store1 = toolStore(name="agent1") -store2 = toolStore(name="agent2") - -# Load all tools (auto-registers listTools) -tools1 = register_all_tools(store1) # all agents get the same tools -tools2 = register_all_tools(store2) - -# Manual registration (per-store) -registerTool(store1, my_tool) - -# Query (returns OrderedDict keyed by tool name, in registration order) -all_tools = getTools(store1) # OrderedDict{String, agentTool} — O(1) lookup + deterministic order - -# Clear (per-store) -clearTools(store1) # only clears store1 -``` - -`getTools(store)` returns the internal `OrderedDict` directly, giving callers: -- O(1) lookup by tool name -- Deterministic iteration order (registration order) -- Consistency with `agentState.tools` (also `OrderedDict{String, agentTool}`) -- No copy overhead — mutations on the returned value affect the store - -### Per-Agent Isolation - -Each `toolStore` is completely independent — tools registered in one store do not appear in another: - -```julia -storeA = toolStore(name="A") -storeB = toolStore(name="B") - -registerTool(storeA, getTimeTool()) -registerTool(storeB, getWeatherTool()) - -getTools(storeA) # only contains getTime -getTools(storeB) # only contains getWeather - -clearTools(storeA) # storeB is unaffected -``` - -This ensures that `yiemAgent` instances with different `tool_store` references operate with completely isolated tool sets. - ---- - -## 5. The Agent Loop — High-Level Flow - -**Source:** `agentCore.jl:35-145` - -The `_agentLoop()` function runs as a background `@spawn` task, created when `yiemAgent` is constructed. - -### Channel Architecture - -``` -yiemAgent struct contains: - - inputChannel (Channel, capacity 16) ← user sends messages here via runAgent() - - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp() - - outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse() -``` - -### Loop States - -The loop tracks 6 states (documented at `agentCore.jl:39-75`): - -| State | `processingTask` | `activeRun` | `inputChannel` | `followUpChannel` | Behavior | -|-------|-----------------|-------------|----------------|-------------------|----------| -| 1 | `nothing` | `false` | empty | empty | Idle, waiting | -| 2 | `nothing` | `false` | has msg | empty | New message → spawn `_processMessage` | -| 3 | running | `true` | empty | empty | Processing, no new input | -| 4 | running | `true` | has msg | empty | New message while busy → queued | -| 5 | running | `true` | empty | has msg | Follow-up while busy → queued | -| 6 | done | `false` | empty | empty | Task completed → send result, reset | - -### Loop Logic (simplified) - -```julia -function _agentLoop(agent::yiemAgent) - while true - # 1. Wait for message from inputChannel (blocking poll) - msg = fetch!(agent.inputChannel) # agentCore.jl:84 - - # 2. Handle shutdown signal - if msg === :shutdown - drain both channels, break loop - end - - # 3. If agent is idle, spawn _processMessage - if agent._state.activeRun == false - processingTask = Threads.@spawn _processMessage(agent) - agent._state.activeRun = true - end - - # 4. While processing: check for followUp messages - if istaskdone(processingTask) == false && isready(agent.followUpChannel) - drain followUpChannel → push to inputChannel - continue # wait for current processing to finish - end - - # 5. When processing completes - if istaskdone(processingTask) == true - result = fetch(processingTask) - put!(agent.outputChannel, result) - agent._state.activeRun = false - processingTask = nothing - end - end -end -``` - -**Key design:** The loop always checks `inputChannel` before `followUpChannel`. Follow-up messages are merged into `inputChannel` only when the current processing task is active, ensuring they are processed after the primary message completes but before new input arrives. - ---- - -## 6. Message Processing Pipeline - -**Source:** `agentCore.jl:175-311` - -`_processMessage(agent)` is the core function that processes a batch of user messages through the LLM pipeline. - -### Pipeline Steps - -```julia -function _processMessage(agent::yiemAgent)::assistantMessage - final_response = nothing - - while true # Loop until LLM returns response without tool calls - # ── Step 1: Drain inputChannel ────────────────────────────── - while isready(agent.inputChannel) - raw_msg = take!(agent.inputChannel) - if raw_msg === :shutdown - put!(agent.inputChannel, :shutdown) - break - end - user_msg = OpenAiToUserMessage(raw_msg) # Convert Dict → userMessage - push!(agent._state.messages, user_msg) - end - - # ── Step 2: Prepare context ───────────────────────────────── - state = agentState(systemPrompt, nothing, tools, messages) - preparedContext = prepareContext(state, agentEventSink) - # Default: deep copies systemPrompt, messages, tools from agentState → agentContext - # Override point: filter tools, inject context, modify system prompt - - # ── Step 3: Format for LLM ────────────────────────────────── - formattedMessages = formatMsgForLLM(preparedContext, agentEventSink) - # Converts agentContext → Dict("messages" => [...]) in OpenAI format - # Wraps systemPrompt as system role, converts each messageContent block - - # ── Step 4: Call LLM ──────────────────────────────────────── - response = llmCall(formattedMessages) - # Returns assistantMessage with content::Vector{messageContent} - # Each content block has a type: "text", "thinking", or "tool_call" - - # ── Step 5: Extract tool calls ────────────────────────────── - hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) - # Inspects content blocks for "tool_calls" or "tool_call" Dict entries - - # ── Step 6: Execute tool calls or return ──────────────────── - if hasToolCalls && !isempty(toolCallList) - # Build context and config - context = agentContext(systemPrompt, messages, tools) - config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential") - signal = abortSignal(false) - - # Execute tool calls (sequential or parallel) - batch = executeToolCalls(context, assistant_msg, toolCallList, config, signal, agentEventSink) - - # Save results to conversation history - for tool_result in batch.messages - push!(messages, tool_result) - end - - # Check termination - if batch.terminate - final_response = build_final_response(batch) - break - end - # Otherwise, loop back to Step 1 (drain any new input) and call LLM again - else - # No tool calls — this is the final response - final_response = response - break - end - end - - return final_response -end -``` - ---- - -## 7. Tool Call Extraction from LLM Response - -**Source:** `agentCore.jl:217-245` - -After the LLM call, the agent inspects `response.content` (a `Vector{messageContent}`) for tool call blocks. Two formats are supported: - -### Format 1: OpenAI `tool_calls` array - -```julia -# Response content block: -Dict( - :type => "tool_calls", - :tool_calls => [ - Dict(:id => "call_1", :name => "getWeather", :arguments => Dict("city" => "Tokyo")), - Dict(:id => "call_2", :name => "getTime", :arguments => Dict("timezone" => "Asia/Tokyo")), - ] -) -``` - -### Format 2: Single `tool_call` block - -```julia -Dict( - :type => "tool_call", - :id => "call_1", - :name => "getWeather", - :arguments => Dict("city" => "Tokyo") -) -``` - -### Extraction Logic - -```julia -tool_call_list = agentToolCall[] - -for content_block in response.content - if content_block isa Dict - # OpenAI format: array of tool calls - if get(content_block, :type, "") == "tool_calls" - for tc_data in get(content_block, :tool_calls, []) - tc = agentToolCall( - type = "function", - id = get(tc_data, :id, string(uuid4())), # fallback UUID - name = get(tc_data, :function, Dict())[:name], - arguments = get(tc_data, :function, Dict())[:arguments], - ) - push!(tool_call_list, tc) - end - # Alternative format: single tool call - elseif get(content_block, :type, "") == "tool_call" - tc_data = content_block - tc = agentToolCall( - type = "function", - id = get(tc_data, :id, string(uuid4())), - name = get(tc_data, :name, ""), - arguments = get(tc_data, :arguments, Dict{String,Any}()), - ) - push!(tool_call_list, tc) - end - end -end -``` - -The `agentToolCall` struct is defined at `type.jl:362-367`: - -```julia -struct agentToolCall - type::String # Always "function" - id::String # Unique tool call identifier - name::String # Tool name (matches context.tools keys) - arguments::Dict{String, Any} # Parsed tool arguments -end -``` - ---- - -## 8. The Per-Call Pipeline — Prepare, Execute, Finalize - -This is the core of the tool execution system. Each tool call (whether part of a batch or standalone) goes through exactly three phases. - -### 8.1 Phase 1: Prepare — `prepareToolCall()` - -**Source:** `agentCore.jl:511-547` - -```julia -function prepareToolCall( - context::agentContext, - assistantMsg::assistantMessage, - toolCall::agentToolCall, - config::agentLoopConfig, - signal::Union{Nothing, abortSignal}, -)::Union{preparedToolCall, immediateOutcome} -``` - -**Steps:** - -1. **Resolve tool by name** — `get(context.tools, toolCall.name, nothing)` - - If `nothing` → `immediateOutcome(createErrorToolResult("Tool X not found"), true)` - -2. **Prepare arguments** — `prepareToolCallArguments(tool, toolCall)` - - Calls `tool.prepareArguments(toolCall.arguments)` if defined - - Returns the toolCall with transformed arguments - - If no hook or no change, returns original `toolCall` - -3. **Validate arguments** — `validateToolArguments(tool, prepared)` - - Calls `tool.validateRequiredArgs(prepared.arguments)` if defined, otherwise uses default `validateRequiredArgs(prepared.arguments, tool.inputSchema)` - - Default validator checks `inputSchema["required"]` array - - On failure: throws `ArgumentError(error_string)`, caught by the try-catch below - -4. **Run `beforeToolCall` hook** — if `config.beforeToolCall !== nothing` - - Passes `beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context)` and `signal` - - Hook can return `nothing` (proceed), or `Dict(:block => true, :reason => "...")` (reject) - - If `signal.aborted == true` → `immediateOutcome(createErrorToolResult("Operation aborted"), true)` - - If `before.block == true` → `immediateOutcome(createErrorToolResult(get(before, :reason, "blocked")), true)` - -5. **Return success** → `preparedToolCall(tool, toolCall, validatedArgs)` - -**Source:** `type.jl:681-685` — `preparedToolCall` holds the resolved tool, original call metadata, and validated arguments together. - -**Key design principle:** Preparation **never throws**. Every failure path returns an `immediateOutcome` with `isError=true`, ensuring the agent loop always has a valid result to feed back to the LLM. - -### 8.2 Phase 2: Execute — `executePreparedToolCall()` - -**Source:** `agentCore.jl:589-617` - -```julia -function executePreparedToolCall( - prep::preparedToolCall, - signal::Union{Nothing, abortSignal}, - agentEventSink, -)::executedOutcome -``` - -**Steps:** - -1. **Call `tool.execute()`:** - ```julia - result = prep.tool.execute( - prep.toolCall.id, - prep.args, - signal, - agentEventSink # serves as onPartialResult callback - ) - return executedOutcome(result, false) - ``` - -2. **On error:** - ```julia - catch err - return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) - end - ``` - -### 8.3 Phase 3: Finalize — `finalizeExecutedToolCall()` - -**Source:** `agentCore.jl:675-706` - -```julia -function finalizeExecutedToolCall( - context::agentContext, - assistantMsg::assistantMessage, - prep::preparedToolCall, - executed::executedOutcome, - config::agentLoopConfig, - signal::Union{Nothing,abortSignal}, -)::finalizedOutcome -``` - -**Steps:** - -1. **Extract execution result:** - ```julia - result = executed.result - isError = executed.isError - ``` - -2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`: - - Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` - - Hook can mutate the result: - ```julia - after = config.afterToolCall(afterToolCallContext(...)) - if after !== nothing - result = merge(result, dict( - :content => get(after, :content, result.content), - :details => get(after, :details, result.details), - :usage => get(after, :usage, result.usage), - :terminate => get(after, :terminate, result.terminate) - )) - isError = get(after, :isError, isError) - end - ``` - - Common use cases: mask sensitive data, normalize usage, flip `terminate` based on business logic - - On error: `result = createErrorToolResult(sprint(showerror, err)); isError = true` - -3. **Return:** - ```julia - return finalizedOutcome(prep.toolCall, result, isError) - ``` - -**Source:** `type.jl:787-791` — `finalizedOutcome` holds the original tool call reference, final result (post-hook), and error status. - ---- - -## 9. Execution Modes — Sequential vs Parallel - -**Source:** `agentCore.jl:795-936, 988-1011` - -### Dispatcher — `executeToolCalls()` - -```julia -function executeToolCalls( - context::agentContext, - assistantMsg::assistantMessage, - toolCalls::Vector{agentToolCall}, - config::agentLoopConfig, - signal::Union{Nothing, abortSignal}, - agentEventSink, -)::agentToolCallBatch -``` - -**Decision logic** (`agentCore.jl:997-1010`): - -```julia -hasSequential = false -for tc in toolCalls - t = get(context.tools, tc.name, nothing) - if t !== nothing && !t.parallelToolExecute - hasSequential = true - break - end -end - -if config.toolExecution == "sequential" || hasSequential - return executeToolCallsSequential(...) -else - return executeToolCallsParallel(...) -end -``` - -**Rule:** If the global config is `"sequential"` **OR** any tool in the batch has `parallelToolExecute = false`, the entire batch runs sequentially. Sequential is the safe default. - -### Sequential Execution — `executeToolCallsSequential()` - -**Source:** `agentCore.jl:795-829` - -```julia -function executeToolCallsSequential(...)::agentToolCallBatch - finalizedCalls = finalizedOutcome[] - messages = toolResultMessage[] - - for tc in toolCalls - prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) - - if prep isa immediateOutcome - finalized = finalizedOutcome(tc, prep.result, prep.isError) - else - executed = executePreparedToolCall(prep, signal, agentEventSink) - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - end - - push!(messages, createToolResultMessage(finalized)) - push!(finalizedCalls, finalized) - - if signal !== nothing && signal.aborted - break # abort: skip remaining calls - end - end - - return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) -end -``` - -### Parallel Execution — `executeToolCallsParallel()` - -**Source:** `agentCore.jl:888-936` - -```julia -function executeToolCallsParallel(...)::agentToolCallBatch - entries = union{finalizedOutcome, task{finalizedOutcome}}[] - - for tc in toolCalls - prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) - - if prep isa immediateOutcome - finalized = finalizedOutcome(tc, prep.result, prep.isError) - push!(entries, finalized) # immediate outcome — no task - else - task = task() do - executed = executePreparedToolCall(prep, signal, agentEventSink) - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - return finalized - end - schedule(task) - push!(entries, task) # pending task - end - - if signal !== nothing && signal.aborted - break # abort during preparation — skip remaining - end - end - - # Collect results in original order - finalizedCalls = finalizedOutcome[] - for entry in entries - outcome = entry isa task ? fetch(entry) : entry - push!(finalizedCalls, outcome) - end - - messages = toolResultMessage[] - for f in finalizedCalls - push!(messages, createToolResultMessage(f)) - end - - return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) -end -``` - -**Key design:** All tool calls are **prepared** sequentially (validation, hooks), then prepared calls are **executed** concurrently as separate tasks. Results are collected in the original call order via `fetch()`. - -**Trade-off:** Parallel execution reduces wall-clock time for independent tools but can overwhelm external resources (rate limits, connection pools, disk I/O). - ---- - -## 10. Tool Call Batches & Termination Logic - -**Source:** `type.jl:793-838` - -### `agentToolCallBatch` - -```julia -struct agentToolCallBatch - messages::Vector{toolResultMessage} # Tool result messages for this batch - terminate::Bool # Whether the batch should terminate the loop -end -``` - -### Termination Logic — `shouldTerminate()` - -**Source:** `agentCore.jl:409-411` - -```julia -function shouldTerminate(batches::Vector{finalizedOutcome})::Bool - return !isempty(batches) && all(b -> b.result.terminate, batches) -end -``` - -**Rule:** `terminate` is `true` **only when every tool in the batch** has `result.terminate == true`. This prevents a single tool that sets `terminate: true` (e.g., for metadata purposes) from accidentally stopping the agent when other tools did not intend to terminate. - -### When to Set `terminate = true` - -From the type documentation (`type.jl:803-815`): - -| Use Case | Description | -|----------|-------------| -| Task completion | A tool like `deploy` or `submit` finishes its work and signals the agent to stop | -| Unrecoverable error | A tool hits a fatal condition (auth token expired, database connection lost) | -| Async handoff | A tool triggers a long-running external operation; the external system will later resume via `continue()` | - -### Batch Processing in `_processMessage()` - -**Source:** `agentCore.jl:266-307` - -```julia -batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink) - -# Save results to conversation history -for tool_result in batch.messages - push!(agent._state.messages, tool_result) -end - -if batch.terminate - # Build final response from tool results - final_content = [textContent("Tool execution completed.")] - for tool_result in batch.messages - for content_block in tool_result.content - if content_block isa textContent - append!(final_content, [content_block]) - elseif content_block isa Dict - if haskey(content_block, :text) - push!(final_content, textContent(content_block[:text])) - end - end - end - end - final_response = assistantMessage( - role = "assistant", - content = final_content, - api = response.api, - model = response.model, - usage = response.usage, - stopReason = "tool_use_terminated", - errorMessage = if any(x -> x.isError, batch.messages) - "One or more tool calls failed" - else - nothing - end, - timestamp = now(), - ) - break # exit the while loop -end -# If batch.terminate == false, loop back to call LLM again with tool results -``` - ---- - -## 11. Tool Result Message Creation - -**Source:** `agentCore.jl:373-379` - -### `createToolResultMessage()` - -```julia -function createToolResultMessage(f::finalizedOutcome)::toolResultMessage - return toolResultMessage( - "toolResult", # role - f.toolCall.id, # toolCallId - f.toolCall.name, # toolName - f.result.content, # content (Vector{messageContent}) - f.result.details, # details - f.result.usage, # usage - nothing, # addedToolNames (for dynamic tools) - f.isError, # isError - now(), # timestamp - ) -end -``` - -### `toolResultMessage` Struct - -**Source:** `type.jl:152-191` - -```julia -struct toolResultMessage <: agentMessage - role::String # Always "tool" - toolCallId::String # ID matching the original tool call - toolName::String # Name of the executed tool - content::Vector{messageContent} # Tool output content - details::Any # Additional tool-specific details - usage::Union{llmUsage, Nothing} # Token usage if applicable - addedToolNames::Union{Vector{String}, Nothing} # Tools added during execution - isError::Bool # Whether the tool call resulted in an error - timestamp::Timestamp # When the result was recorded -end -``` - -### Conversation History After Tool Execution - -``` -[system] "You are a helpful assistant." -[user] "What's the weather in Tokyo?" -[assistant] {tool_calls: [getWeather(city="Tokyo")]} -[tool] tool_call_id="call_1", tool_name="getWeather", content="Weather in Tokyo: Sunny, 22°C" -``` - -On the next loop iteration, `formatMsgForLLM()` converts `toolResultMessage` to OpenAI format: - -```julia -Dict( - "role" => "tool", - "tool_call_id" => "call_1", - "content" => [Dict("type" => "text", "text" => "Weather in Tokyo: Sunny, 22°C")] -) -``` - ---- - -## 12. Error Handling & Recovery Pattern - -The framework uses a **result-based error handling** pattern instead of exceptions for tool call failures. This ensures the LLM always receives a tool result message, giving it the information to recover. - -### Error Flow - -``` -Tool call fails at any phase - │ - ▼ -┌─────────────────────────┐ -│ Phase: Prepare │ → immediateOutcome(error_result, true) -│ Phase: Execute │ → executedOutcome(error_result, true) -│ Phase: Finalize (hook) │ → finalizedOutcome(tc, error_result, true) -└─────────────────────────┘ - │ - ▼ -createToolResultMessage(finalized) - │ - ▼ -push!(agent._state.messages, toolResultMessage) - │ - ▼ -formatMsgForLLM() → LLM receives error as tool result - │ - ▼ -LLM can: retry with corrected args, report failure, or ask user for clarification -``` - -### `createErrorToolResult()` - -**Source:** `agentCore.jl:339-341` - -```julia -function createErrorToolResult(msg::String)::agentToolResult - return agentToolResult([textContent("text", msg)], Dict{Any,Any}()) -end -``` - -Returns an `agentToolResult` with a single `textContent` block containing the error message. This is wrapped in an `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` depending on where the error occurred, then converted to `toolResultMessage` for the LLM. - -**Key design:** Returning a result instead of throwing allows the LLM to see the error and decide whether to retry, re-issue the call with different arguments, or report failure to the user. - ---- - -## 13. Event System — Tool Lifecycle Events - -**Source:** `type.jl:480-516` - -Each tool call emits a three-event lifecycle: - -``` -toolExecStartEvent → [zero or more toolExecUpdateEvent] → toolExecEndEvent -``` - -### Event Types - -```julia -struct toolExecStartEvent - toolCallId::String # ID of the tool call - toolName::String # Name of the tool - arguments::Dict{String, Any} # Tool arguments -end - -struct toolExecUpdateEvent - toolCallId::String # ID of the tool call - toolName::String # Name of the tool - arguments::Dict{String, Any} # Tool arguments - partialResult::Any # The partial result data -end - -struct toolExecEndEvent - toolCallId::String # ID of the tool call - toolName::String # Name of the tool - result::agentToolResult # The final tool result - isError::Bool # Whether execution resulted in an error -end -``` - -### Event Emission Points - -| Event | Emitted From | When | -|-------|-------------|------| -| `toolExecStartEvent` | `executeToolCalls*()` loop | Before `prepareToolCall()` for each tool call | -| `toolExecUpdateEvent` | `executePreparedToolCall()` | Inside `onPartialResult` callback during `tool.execute()` | -| `toolExecEndEvent` | `finalizeExecutedToolCall()` | After finalization for each tool call | - -### Event Sink - -The `agentEventSink` function is passed through the entire call chain: - -```julia -agentEventSink = agent.agentEventSink # set during yiemAgent construction -``` - -The `agentEventSink` function is a user-provided callback that receives all events. This is typically used by: -- **TUI (Terminal UI):** Display real-time progress, tool names, results -- **Logging systems:** Record tool execution history -- **Monitoring:** Track tool usage, execution times, error rates -- **Audit trails:** Log all tool calls with arguments and results - ---- - -## 14. Agent Lifecycle Hooks - -### Hook Types - -| Hook | Signature | Called | Purpose | -|------|-----------|--------|---------| -| `prepareContext` | `(state::agentState, agentEventSink) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | -| `formatMsgForLLM` | `(ctx::agentContext, agentEventSink) -> Dict` | After `prepareContext` | Convert to LLM-specific format | -| `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API | -| `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | -| `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | -| `agentEventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring | - -### `beforeToolCall` Hook - -**Source:** `agentCore.jl:530-541` - -```julia -if config.beforeToolCall !== nothing - before = config.beforeToolCall( - beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal - ) - if signal !== nothing && signal.aborted - return immediateOutcome(createErrorToolResult("Operation aborted"), true) - end - if before !== nothing && before.block - return immediateOutcome( - createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) - end -end -``` - -**Return values:** -- `nothing` — proceed with execution -- `Dict(:block => true, :reason => "...")` — block execution, error fed back to LLM -- `Dict(:block => false)` — proceed (explicit allow) - -### `afterToolCall` Hook - -**Source:** `agentCore.jl:687-703` - -```julia -if config.afterToolCall !== nothing - try - after = config.afterToolCall( - afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal - ) - if after !== nothing - result = merge(result, dict( - :content => get(after, :content, result.content), - :details => get(after, :details, result.details), - :usage => get(after, :usage, result.usage), - :terminate => get(after, :terminate, result.terminate) - )) - isError = get(after, :isError, isError) - end - catch err - result = createErrorToolResult(sprint(showerror, err)) - isError = true - end -end -``` - -**Common use cases:** -- Mask sensitive data from result content before the LLM sees it (e.g., removing API keys from error messages) -- Normalize usage tracking data into a consistent format -- Inspect the result and decide to flip `terminate: true` based on business logic -- Wrap an error result in a friendlier message for the LLM to understand - -### `prepareContext` Hook - -**Source:** `utils.jl:111-125` - -```julia -function prepareContext(state::agentState, agentEventSink)::agentContext - # TODO: filter tools from state.tools based on user intent - filteredTools = state.tools - - # TODO: add filtered tools to the current system prompt / modify systemPrompt - preparedSystemPrompt = state.systemPrompt - - # TODO: add system prompt, adjust/modify and inject additional context into messages - preparedMessages = deepcopy(state.messages) - - return agentContext(preparedSystemPrompt, preparedMessages, filteredTools) -end -``` - -**Override points for customization:** -- Filter tools based on user intent (e.g., only show "wine" tools when user asks about wine) -- Modify the system prompt dynamically (e.g., inject current time, user preferences) -- Inject additional context (e.g., retrieved documents, current user state) -- Prune or reorder messages before formatting for the LLM - -### `formatMsgForLLM` Hook - -**Source:** `utils.jl:158-219` - -Default implementation converts `agentContext` to OpenAI-compatible format: - -```julia -function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} - messages = Vector{Dict{String, Any}}() - - # System prompt as system message - if !isempty(ctx.systemPrompt) - push!(messages, Dict( - "role" => "system", - "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] - )) - end - - # Conversation messages - for msg in ctx.messages - if msg isa userMessage - push!(messages, _userMessageToOpenAI(msg)) - elseif msg isa assistantMessage - push!(messages, _assistantMessageToOpenAI(msg)) - elseif msg isa toolResultMessage - push!(messages, _toolResultMessageToOpenAI(msg)) - end - end - - return Dict("messages" => messages) -end -``` - -Override this to produce custom LLM message formats for different APIs/providers (e.g., Anthropic, Google, Ollama). - ---- - -## 15. Self-Modifying Tools - -The framework supports tools that modify the tool system itself at runtime. - -### `writeTool` — Create New Tool Files - -**Source:** `tools/writeTool.jl` - -`writeTool` is a **file writer**, not a code generator. The LLM provides the tool logic as `executeCode` (Julia code body), and `writeTool` wraps it in Julia boilerplate: -1. Converts `inputSchema` Dict into `Dict{String,Any}(...)` string literal -2. Indents `executeCode` with 4 spaces -3. Wraps it inside `function executeTool(...)::agentToolResult ... end` -4. Appends `writeToolTool()` returning an `agentTool` struct -5. Writes the combined string to `src/tools/.jl` - -### `listTool` — Discover Available Tools - -**Source:** `toolRegistry.jl:43-98` - -Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `register_all_tools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`. - -### Self-Tooling Workflow - -``` -1. Agent detects no existing tool handles the user's request -2. Agent calls writeTool with: - - name: "searchWine" - - label: "Wine Search" - - description: "Search a wine database..." - - inputSchema: { ... } - - executeCode: "query = args[\"query\"]\nresult = search(query)\n..." - - (optional) validateCode, prepareCode -3. writeTool generates src/tools/searchWine.jl -4. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl -5. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() in YiemAgent.jl -6. Developer restarts Julia — new tool is loaded -7. Agent calls searchWine(query="cabernet") -8. Result: "Found 5 cabernet wines..." -``` - -### `writeTool` Input Schema - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `name` | `String` | Yes | Valid Julia identifier (letters, digits, underscores) | -| `label` | `String` | Yes | Human-readable tool name | -| `description` | `String` | Yes | What the tool does | -| `inputSchema` | `Dict` | Yes | JSON Schema in MCP format | -| `executeCode` | `String` | Yes | Julia code for `executeTool` body (NOT wrapped in function) | -| `validateCode` | `String` | No | Custom validation Julia code | -| `prepareCode` | `String` | No | Argument preparation code | -| `parallel` | `Bool` | No | Whether the tool can run in parallel (default: `false`) | - ---- - -## 16. Complete End-to-End Example - -### Full Lifecycle: User Message to Tool Result - -``` -USER SENDS MESSAGE - └─> runAgent(agent, "What's the weather in Tokyo?") - └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) - - -LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL - └─> _agentLoop: detects msg in inputChannel - └─> @spawn _processMessage(agent) - - ── _processMessage ────────────────────────────────────────────── - │ - │ Step 1: Drain inputChannel - │ raw_msg = Dict("role" => "user", "content" => [...]) - │ user_msg = OpenAiToUserMessage(raw_msg) - │ push!(agent._state.messages, user_msg) - │ - │ Step 2: prepareContext - │ ctx = agent.prepareContext(agent._state) - │ → agentContext(systemPrompt, messages, tools) - │ - │ Step 3: formatMsgForLLM - │ formatted = agent.formatMsgForLLM(ctx) - │ → Dict("messages" => [ - │ Dict("role" => "system", "content" => [...]), - │ Dict("role" => "user", "content" => [...]), - │ ]) - │ - │ Step 4: llmCall - │ response = agent.llmCall(formatted) - │ → assistantMessage(content = [ - │ Dict(:type => "tool_calls", :tool_calls => [ - │ Dict(:id => "call_1", :name => "getWeather", - │ :arguments => Dict("city" => "Tokyo")) - │ ]) - │ ]) - │ - │ Step 5: Extract tool calls - │ tool_call_list = [agentToolCall("function", "call_1", "getWeather", ...)] - │ - │ Step 6: Execute tool calls - │ context = agentContext(systemPrompt, messages, tools) - │ config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential") - │ batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink) - - -LOOP ITERATION 1 — executeToolCallsSequential - │ - │ For tc = agentToolCall("call_1", "getWeather", ...): - │ - │ PREPARE: - │ tool = context.tools["getWeather"] → found! - │ validatedArgs = validateToolArguments(tool, tc) - │ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes - │ beforeToolCall_hook(...) → nothing (skipped) - │ → preparedToolCall(tool, tc, {"city" => "Tokyo"}) - │ - │ EXECUTE: - │ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, agentEventSink) - │ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false) - │ → executedOutcome(result, false) - │ - │ FINALIZE: - │ afterToolCall_hook(...) → nothing (skipped) - │ → finalizedOutcome(tc, result, false) - │ - │ msg = createToolResultMessage(finalized) - │ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts) - │ - └─> agentToolCallBatch([msg], false) - │ - │ Save results: - │ for tool_result in batch.messages - │ push!(agent._state.messages, tool_result) - │ end - │ - │ batch.terminate == false → loop back to Step 1 - │ - - -LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE - ── _processMessage (second iteration) ─────────────────────────── - │ - │ Step 1: Drain inputChannel → empty - │ - │ Step 2-3: prepareContext → formatMsgForLLM - │ → messages now include: - │ [system] "You are a helpful assistant." - │ [user] "What's the weather in Tokyo?" - │ [tool] tool_call_id="call_1", content="Weather in Tokyo: Sunny, 22°C" - │ - │ Step 4: llmCall - │ → assistantMessage(content = [Dict(:type => "text", :text => "The weather in Tokyo is sunny, 22°C.")]) - │ - │ Step 5: Extract tool calls → none - │ - │ Step 6: has_tool_calls == false → break, return final_response - │ - └─> return final_response - - -AGENT LOOP: SEND RESPONSE TO USER - └─> put!(agent.outputChannel, final_response) - └─> takeResponse(agent) → assistantMessage("The weather in Tokyo is sunny, 22°C.") -``` - ---- - -## 17. Tool File Contract - -Each `.jl` file in `src/tools/` follows a flat, static structure: - -### Required Function - -```julia -function Tool()::agentTool - # Must return an agentTool instance -end -``` - -### Optional Functions - -```julia -# Argument preparation (before validation) -function PrepareArguments(args::Dict{String,Any})::Dict{String,Any} - # Return modified args, or args unchanged - return args -end - -# Custom validation (before execution) -function ValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} - # Return nothing to pass, or error string to fail - return nothing -end - -# Core execution -function Execute(toolCallId::String, - args::Dict{String,Any}, - signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult - # Return agentToolResult with content, details, usage, terminate - return agentToolResult([textContent("result")], Dict{Any,Any}(), nothing, false) -end -``` - -### File Structure - -```julia -# src/tools/myTool.jl - -using .type # ← provides agentTool, textContent, agentToolResult, etc. -using Dates # ← tool's own dependencies - -# Optional: helper functions -function helper_function(...) - ... -end - -# Optional: prepareArguments -function myToolPrepareArguments(args::Dict{String,Any})::Dict{String,Any} - return args -end - -# Optional: validateRequiredArgs -function myToolValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} - return nothing -end - -# Required: execute function -function myToolExecute(toolCallId::String, args::Dict{String,Any}, - signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult - ... -end - -# Required: getTool function -function myToolTool()::agentTool - return agentTool( - name = "myTool", - label = "My Tool", - description = "What this tool does", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict(...), - "required" => [...] - ), - execute = myToolExecute, - prepareArguments = myToolPrepareArguments, - validateRequiredArgs = myToolValidateRequiredArgs, - parallelToolExecute = false - ) -end -``` - -### Dependencies - -Each tool file declares its own dependencies via `using` statements: - -```julia -# src/tools/getTime.jl -using .type -using Dates - -function getTimeExecute(...) - now() # Dates.now requires `using Dates` -end -``` - -```julia -# src/tools/myApiTool.jl -using .type -using HTTP, JSON - -function myApiToolExecute(...) - response = HTTP.get("https://api.example.com") - data = JSON.parse(String(response.body)) - ... -end -``` - -### Why Flat Modules? - -All tool files are **statically included** in `YiemAgent.jl` via `include()`. This means: -- All functions live in the `YiemAgent` module, avoiding world-age issues -- `using .type` makes core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`) available -- Functions are named with a `` prefix to avoid name collisions (e.g., `getWeatherExecute`, `getTimeExecute`) -- The `...Tool()` function (e.g., `getWeatherTool()`) returns the `agentTool` struct for registration - ---- - -## 18. Adding New Tools - -To add a new tool (e.g., `searchWine.jl`): - -### Step 1: Create `src/tools/searchWine.jl` - -```julia -using .type -# using AdditionalPkg # add if needed - -function searchWineExecute(toolCallId::String, args::Dict{String,Any}, - signal::Union{Nothing,abortSignal}, onPartialResult) - query = get(args, "query", "") - result = search_wine_db(query) - return agentToolResult( - [textContent("Found $(length(result)) wines")], - Dict{Any,Any}("count" => length(result)), - nothing, false - ) -end - -function searchWineTool()::agentTool - return agentTool( - name = "searchWine", - label = "Search Wine", - description = "Search wine database...", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "query" => Dict("type" => "string", "description" => "Search query") - ), - "required" => ["query"] - ), - execute = searchWineExecute, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -``` - -### Step 2: Include in `src/YiemAgent.jl` (before `toolRegistry.jl`) - -```julia -include("tools/getWeather.jl") -include("tools/getTime.jl") -include("tools/searchWine.jl") # ← add here -include("tools/writeTool.jl") -``` - -### Step 3: Register in `register_all_tools()` in `YiemAgent.jl` - -```julia -function register_all_tools(store::toolRegistry.toolStore) - registerTool(store, getWeatherTool()) - registerTool(store, getTimeTool()) - registerTool(store, searchWineTool()) # ← add here - registerTool(store, writeToolTool()) - registerTool(store, listTool(store)) - return store.tools -end -``` - -### Step 4: Restart Julia - -The module recompiles on next load. The new tool is available immediately. - ---- - -## 19. Appendix: Type Reference - -### Message Types - -| Type | Source | Description | -|------|--------|-------------| -| `messageContent` | `type.jl:67` | Abstract base for message content | -| `textContent` | `type.jl:69` | Plain text content (`text::String`) | -| `imageContent` | `type.jl:73` | Image content (`data::String`, `mimeType::String`) | -| `agentMessage` | `type.jl:82` | Abstract base for all messages | -| `userMessage` | `type.jl:84` | User message (`role`, `content`, `timestamp`) | -| `assistantMessage` | `type.jl:111` | LLM response (`role`, `content`, `api`, `provider`, `model`, `usage`, `stopReason`, `errorMessage`, `timestamp`) | -| `toolResultMessage` | `type.jl:152` | Tool result (`role`, `toolCallId`, `toolName`, `content`, `details`, `usage`, `addedToolNames`, `isError`, `timestamp`) | - -### Tool Types - -| Type | Source | Description | -|------|--------|-------------| -| `agentTool` | `type.jl:261` | Tool definition (name, label, description, schema, execute, hooks) | -| `agentToolCall` | `type.jl:362` | Tool call from LLM (type, id, name, arguments) | -| `agentToolResult` | `type.jl:429` | Tool execution result (content, details, usage, terminate) | -| `agentToolCallBatch` | `type.jl:835` | Batch of tool results (messages, terminate) | - -### Lifecycle Outcome Types - -| Type | Source | Description | -|------|--------|-------------| -| `preparedToolCall` | `type.jl:681` | After prepare: (tool, toolCall, args) | -| `immediateOutcome` | `type.jl:719` | Failed before execution: (result, isError) | -| `executedOutcome` | `type.jl:753` | After execute, before finalize: (result, isError) | -| `finalizedOutcome` | `type.jl:787` | After all phases: (toolCall, result, isError) | - -### Context & Config Types - -| Type | Source | Description | -|------|--------|-------------| -| `agentContext` | `type.jl:299` | Conversation snapshot (systemPrompt, messages, tools) | -| `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) | -| `agentLoopConfig` | `type.jl:403` | Loop config (beforeToolCall, afterToolCall, toolExecution) | -| `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) | -| `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | -| `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | - -### Event Types - -| Type | Source | Description | -|------|--------|-------------| -| `toolExecStartEvent` | `type.jl:480` | (toolCallId, toolName, arguments) | -| `toolExecUpdateEvent` | `type.jl:495` | (toolCallId, toolName, arguments, partialResult) | -| `toolExecEndEvent` | `type.jl:511` | (toolCallId, toolName, result, isError) | - -### Agent Types - -| Type | Source | Description | -|------|--------|-------------| -| `agent` | `type.jl:522` | Abstract base type | -| `yiemAgent` | `type.jl:527` | High-level agent wrapper (state, channels, callbacks, task) | diff --git a/appconfig.json b/appconfig.json deleted file mode 100644 index 60ef6f3..0000000 --- a/appconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "nats_server_info": { - "description": "nats server", - "url": "nats.yiem.cc" - }, - "testingOrProduction": "testing", - "agentId": "2b74b87a-5413-4fe2-a4d3-405891051680", - "agentCentralConfigSubject": "/yiem/hq/agent/sommelier/backend/config/api/v1.1", - "this_service_name": "agent_backend", - "this_service_input_channel": { - "mqtt": [ - "/yiem/hq/agent/sommpanion/backend/db/api_v1" - ], - "nats": [ - "sommpanion.backend.agentbackend.v1.inbox" - ] - }, - "agentRole": "sommelier", - "organization": "yiem_hq", - "externalservice": { - "servicesloadbalancer": { - "nats": "sommpanion.backend.servicesloadbalancer.v1.inbox" - }, - "textembedding": { - "url": "textembedding.api.v1" - }, - "textimage_to_text_llm": { - "url": "https://llmcoder.yiem.cc/v1/chat/completions", - "modelname": "Qwen3.6-35B-A3B-UD-Q4_K_M" - }, - "virtualWineCustomer_1": { - "serviceSubject": "", - "modelName": "qwen3:8b" - }, - "sommpanion_db" : { - "description": "A database connection info for LibPQ client", - "url": "192.168.88.106:5432", - "dbname": "winedb", - "user": "admin", - "password": "admin@Sommpanion_0.0" - }, - "sommpanion_vectordb" : { - "description": "A wine database connection info for LibPQ client", - "url": "192.168.88.106:5433", - "dbname": "vectordb", - "user": "admin", - "password": "admin@Sommpanion_0.0" - }, - "fileserver": { - "description": "temporary file server", - "url": "https://fileserver.yiem.cc" - } - } -} \ No newline at end of file diff --git a/etc.jl b/etc.jl deleted file mode 100644 index be4b0e2..0000000 --- a/etc.jl +++ /dev/null @@ -1,5 +0,0 @@ -check my understand: -1) if LLM didn't use tool calls, assistantMessage get pushed into agent._state.messages and -it will be the latest message in agent._state.messages. then _agentLoop() can pick it as -the output to outputChannel -2) if LLM use tool calls but toolResultBatch.terminate is false, assistantMessageToolCall \ No newline at end of file diff --git a/etc.md b/etc.md index 4664add..9b55263 100644 --- a/etc.md +++ b/etc.md @@ -6,3 +6,6 @@ check my understanding Is my understanding correct? +the user can provide NATS connection to MCP server by adding agent.mcpserver (a callable struct) for communication with MCP server just like agent.llmCall (also a callable struct). I think communicating with MCP server is just send/receive JSON text right? +Moreover, for simplicity I want to all tools into an MCP server so an agent can be instantiated with only listTools() in tool store then populate tools from MCP server later. +what do you think? \ No newline at end of file diff --git a/my_AI_prompt.md b/my_AI_prompt.md new file mode 100644 index 0000000..2ad6765 --- /dev/null +++ b/my_AI_prompt.md @@ -0,0 +1,94 @@ +can you make sure yiemAgent use correct format when contacting MCP server. you can check MCP server doc at /home/ton/docker-apps/sommpanion/mcp/docs/specification.md? + +smartpack() and smartunpack() doc is at /home/ton/docker-apps/sommpanion/msghandler/README.md + +user code that use yiemAgent is here /home/ton/docker-apps/sommpanion/agent-backend/test/runtest.jl + +this is log folder that logs when I ran yiemAgent. you can check /home/ton/docker-apps/sommpanion/agent-backend/log/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 11de2b9..ca882bc 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -1,64 +1,30 @@ -module YiemAgent - - export register_all_tools - - """ Order by dependencies of each file. The 1st included file must not depend on any other - files and each file can only depend on the file included before it. - """ - - include("type.jl") - using .type - - include("utils.jl") - using .utils - - include("tools/getWeather.jl") - include("tools/getTime.jl") - include("tools/writeTool.jl") - - include("toolRegistry.jl") - using .toolRegistry - - function register_all_tools(store::toolRegistry.toolStore) - registerTool(store, getWeatherTool()) - registerTool(store, getTimeTool()) - registerTool(store, writeToolTool()) - registerTool(store, listTool(store)) - return store.tools - end - - # include("llmfunction.jl") - # using .llmfunction - - include("agentCore.jl") - using .agentCore - - include("api.jl") - using .api - - -# ---------------------------------------------- 100 --------------------------------------------- # - - - - - - - - - - - - - - - - - - - - - - - -end # module YiemAgent_v1 +module YiemAgent + + """Order by dependencies of each file. The 1st included file must not depend on any other + files and each file can only depend on the file included before it.""" + + include("type.jl") + using .type + + include("utils.jl") + using .utils + + include("toolRegistry.jl") + using .toolRegistry + + include("agentCore.jl") + using .agentCore + + include("api.jl") + using .api + + export promptAgent, takeResponse, followUp, stopAgent + + + +# ---------------------------------------------- 100 --------------------------------------------- # + + + + +end # end module diff --git a/src/agentCore.jl b/src/agentCore.jl index 58670f6..57c00e1 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -5,15 +5,10 @@ export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls, executeToolCallsParallel, executeToolCalls using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Base.Threads, NATS + DataFrames, Base.Threads, NATS, LibPQ using GeneralUtils using ..type, ..utils, ..toolRegistry -function register_all_tools(store::toolRegistry.toolStore) - # Call parent module's version which has access to tool functions - parentmodule(@__MODULE__).register_all_tools(store) -end - # ---------------------------------------------- 100 --------------------------------------------- # """ @@ -24,14 +19,46 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # user sends prompt message to agent. if agent is idle, it process user message right away. # if agent is running, it process user message after the current tool call finished. + # Expect each message in OpenAI chat format: + # image1_path = "test/large_image.png" + # image1_bytes = read(image1_path) + # image1_base64_string = base64encode(image1_bytes) + # mime_type = "image/png" + # data1_uri = "data:$(mime_type);base64,$(image1_base64_string)" + + # # 1. Read local file and encode to base64 string + # image2_path = "test/small_image.png" + # image2_bytes = read(image2_path) + # image2_base64_string = base64encode(image2_bytes) + # mime_type = "image/png" + # data2_uri = "data:$(mime_type);base64,$(image2_base64_string)" + + # # 3. Construct payload with the Data URI + # message = Dict( + # "role" => "user", + # "content" => [ + # Dict("type" => "text", "text" => + # " + # Do you have Brunello di Montalcino from Tenuta CastelGiocon? + # "), + # Dict( + # "type" => "image_url", + # "image_url" => Dict("url" => data1_uri) + # ) + # ] + # ) inputChannel::Channel - # Buffers messages the user sends while the agent is busy. Processed after all inputChannel - # messages are handled and the agent is idle (not using a tool call). + # Buffers follow-up messages the user sends while the agent is busy. Uses the same + # OpenAI chat format as inputChannel. Processed after all inputChannel messages are + # handled and the agent is idle (not in the middle of a tool call). followUpChannel::Channel - # agent sends response message to user after processing all user messages in inputChannel - # and all followUp messages. + # agent sends response message to user after processing all user messages in inputChannel + # and all followUp messages. Response is in OpenAI message format: + # Dict("role" => "assistant", "content" => [ + # Dict("type" => "text", "text" => "Response text here") + # ]) outputChannel::Channel _agentLoop::Union{Task, Nothing} # agent loop running in the background @@ -49,7 +76,10 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # Each block has a type — "text", "thinking", or "toolCall". # The code filters for type === "toolCall" blocks, then passes them to executeToolCalls(). llmCall - + + # check input and output format at MCP server doc + mcpServer + # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) beforeToolCall::Union{Function, Nothing} @@ -61,7 +91,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper sessionId::Union{String, Nothing} # Optional session identifier maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) parallelToolExecute::Bool # Default: false - agentEventSink # agent emits its status via this function + eventSink # agent emits its status via this function end """ @@ -85,7 +115,10 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`) - `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) - `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) -- `agentEventSink::Function`: Callback to receive agent events +- `eventSink::Function`: Callback to receive agent events +- `mcpServer`: Callable struct for MCP server communication. Called as + `mcpServer("tools/list")` to discover tools, or `mcpServer("tools/call", args)` + to execute a tool. Returns parsed JSON dicts. (default: `nothing`) # Returns - A new `yiemAgent` instance with an active background task @@ -102,23 +135,45 @@ function yiemAgent( afterToolCall::Function=afterToolCall, # prepareNextTurn::Union{Function, Nothing}=nothing, # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, - sessionId::Union{String, Nothing}=nothing, + sessionId::Union{String, Nothing}="", maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, - agentEventSink=agentEventSink, + eventSink=eventSink, + mcpServer=nothing, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) followUp = Channel(32) outputChannel = Channel(16) - # load tools (statically registered at module init) - toolStore1 = toolStore(name="myagent") - register_all_tools(toolStore1) + if mcpServer === nothing + error(""" please implement the following MCP server interface + struct mcpServer + natsConn::NATS.Connection + topic::String + senderID::String + end + + # MCP protocol: tools/list — returns tool definitions (JSON-RPC 2.0) + # check input and output format from MCP server doc + function (mcp::mcpServer)(method::String, sessionId::String; eventSink=nothing)::AbstractDict{String, <:Any} + error("please implement MCP server tools/list interface") + end + + # MCP protocol: tools/call — executes a tool (JSON-RPC 2.0) + function (mcp::mcpServer)(method::String, toolName::String, arguments::Dict{String, Any}, + sessionId::String; eventSink=nothing)::AbstractDict{String, <:Any} + error("please implement MCP server interface") + end + """) + end + + tools = OrderedDict{String, agentTool}() + registerAllTools(tools, mcpServer; eventSink=eventSink, sessionId=sessionId) # Create struct with a placeholder task, then spawn and replace it agent = yiemAgent( - agentState(systemPrompt, model, getTools(toolStore1), messages), + agentState(systemPrompt, model, tools, messages), inputChannel, followUp, outputChannel, @@ -126,6 +181,7 @@ function yiemAgent( prepareContext, formatMsgForLLM, llmCall, + mcpServer, beforeToolCall, afterToolCall, # prepareNextTurn, @@ -133,7 +189,7 @@ function yiemAgent( sessionId, maxRetryDelayMs, parallelToolExecute, - agentEventSink, + eventSink, ) # Spawn the background loop and attach it @@ -208,18 +264,18 @@ function _agentLoop(agent::yiemAgent) while true while newUserMsg === nothing if isready(agent.inputChannel) - agent.agentEventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) # agent process new user msg immediately after the current tool call finished. newUserMsg = take!(agent.inputChannel) - agent.agentEventSink("new user msg") + agent.eventSink("new user msg\n$newUserMsg"; publish=false, log=true) else # check followUp message after _processMessage() is done if typeof(processingTask) == Task && istaskdone(processingTask) == true - agent.agentEventSink("_agentLoop 2 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 2 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) # if agent runs is done but followUpChannel has messages, # put new message in inputChannel instead if isready(agent.followUpChannel) - agent.agentEventSink("_agentLoop 3 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 3 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) while isready(agent.followUpChannel) followUpMsg = take!(agent.followUpChannel) put!(agent.inputChannel, followUpMsg) @@ -227,24 +283,24 @@ function _agentLoop(agent::yiemAgent) processingTask = nothing # reset result = nothing # reset else # _processMessage() done and no followUp message. - agent.agentEventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) result = deepcopy(agent._state.messages[end]) - + agent.eventSink("_agentLoop 4-1 "; publish=false, log=true) # filter out reasoningContent in-place filter!(c -> !(c isa reasoningContent), result.content) - + agent.eventSink("_agentLoop 5 "; publish=false, log=true) # format output respondToUI = _assistantMessageToOpenAI(result) - + agent.eventSink("_agentLoop 6 "; publish=false, log=true) put!(agent.outputChannel, respondToUI) if !isempty(result.content) && result.content[1] isa textContent - agent.agentEventSink(result.content[1].text) + agent.eventSink(result.content[1].text; publish=false, log=true) end processingTask = nothing # reset result = nothing # reset end end - yield() + sleep(0.1) end end @@ -269,7 +325,7 @@ function _agentLoop(agent::yiemAgent) else # spawn new _processMessage() if it is not already running. if processingTask === nothing - agent.agentEventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))") + agent.eventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))"; publish=false, log=true) # discard all messages in followUpChannel while isready(agent.followUpChannel) _ = take!(agent.followUpChannel) @@ -278,7 +334,7 @@ function _agentLoop(agent::yiemAgent) # Dispatch message through the processing pipeline processingTask = @spawn _processMessage( processMessageInputCh, - agent.agentEventSink, + agent.eventSink, agent._state.messages, agent._state.systemPrompt, agent._state.tools, @@ -295,6 +351,13 @@ function _agentLoop(agent::yiemAgent) end end catch e + bt = catch_backtrace() + errMsg = sprint() do io + showerror(io, e, bt) + println(io) + end + eventSink(errMsg; publish=false, log=true) + # On any error, send error response and exit the loop @error "Agent loop failed" error=e end @@ -330,7 +393,7 @@ julia> # Currently returns a placeholder echo response """ function _processMessage( inputChannel::Channel, - agentEventSink, + eventSink, agentMsgHistory::Vector{agentMessage}, systemPrompt::String, tools::OrderedDict{String, agentTool}, @@ -341,7 +404,7 @@ function _processMessage( afterToolCall::Union{Function, Nothing}, parallelToolExecute::Bool, )::Nothing - agentEventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))") + eventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) # loop until llmCall() response didn't use tool calls final_response = nothing @@ -359,112 +422,124 @@ function _processMessage( """ while true - # Drain inputChannel and convert OpenAI-format messages to userMessage type - while isready(inputChannel) - agentEventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))") - newUserMsg_openai = take!(inputChannel) - agentEventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))") - if newUserMsg_openai === :shutdown - agentEventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))") - # Re-emit shutdown signal for the loop to handle - put!(inputChannel, :shutdown) - break + try + # Drain inputChannel and convert OpenAI-format messages to userMessage type + while isready(inputChannel) + eventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + newUserMsg_openai = take!(inputChannel) + eventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + if newUserMsg_openai === :shutdown + eventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + # Re-emit shutdown signal for the loop to handle + put!(inputChannel, :shutdown) + break + end + eventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + newUserMsg = OpenAiToUserMessage(newUserMsg_openai) + push!(agentMsgHistory, newUserMsg) + eventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) end - agentEventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))") - newUserMsg = OpenAiToUserMessage(newUserMsg_openai) - push!(agentMsgHistory, newUserMsg) - agentEventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))") - end - agentEventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))") - # call prepareContext() - state = agentState(systemPrompt, nothing, tools, agentMsgHistory) - agentEventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))") - preparedContext = prepareContext(state, agentEventSink) - agentEventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))") - # Call formatMessagesForLLM() to format for LLM - formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink) - - agentEventSink("_processMessage 10 formattedMessages $formattedMessages") + eventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + # call prepareContext() + state = agentState(systemPrompt, nothing, tools, agentMsgHistory) + eventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + preparedContext = prepareContext(state, eventSink) + eventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + # Call formatMessagesForLLM() to format for LLM + formattedMessages = formatMessagesForLLM(preparedContext, eventSink) + + eventSink("_processMessage 10 formattedMessages $formattedMessages"; publish=false, log=true) - """ response example - response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) - """ - response = llmCall(formattedMessages) - agentEventSink(" llmCall " * string(response)) + """ response example + response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) + """ + response = llmCall(formattedMessages, eventSink=eventSink) + # eventSink("LLM response " * string(response); publish=false, log=true) - agentEventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))") - # Extract tool calls from LLM response content blocks - hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) - agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))") - agentEventSink("assistant_msg " * string(assistant_msg)) - agentEventSink("_processMessage 11-1") + eventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + # Extract tool calls from LLM response content blocks + hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) + eventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + eventSink("assistant_msg " * string(assistant_msg); publish=false, log=true) + eventSink("_processMessage 11-1"; publish=false, log=true) - # Add assistant message (tool calls or text) to history for next LLM turn - push!(agentMsgHistory, assistant_msg) + # Add assistant message (tool calls or text) to history for next LLM turn + push!(agentMsgHistory, assistant_msg) - if hasToolCalls && length(toolCallList) > 0 - # Build context and config for executeToolCalls + if hasToolCalls && length(toolCallList) > 0 + # Build context and config for executeToolCalls - config = agentLoopConfig( - beforeToolCall, - afterToolCall, - parallelToolExecute ? "parallel" : "sequential", - ) + config = agentLoopConfig( + beforeToolCall, + afterToolCall, + parallelToolExecute ? "parallel" : "sequential", + ) - signal = abortSignal(false) - agentEventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))") + signal = abortSignal(false) + eventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) - # call executeToolCalls() - toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, - signal, agentEventSink) - agentEventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))") + # call executeToolCalls() + toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, + signal, eventSink) + eventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) - # save toolResults to messages - for toolResult in toolResultBatch.messages - agentEventSink("toolResult " * string(toolResult)) - push!(agentMsgHistory, toolResult) - end - agentEventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))") - if toolResultBatch.terminate - agentEventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))") - # If toolResultBatch requested termination, build a final response - final_content = [textContent("Tool execution completed.")] + # save toolResults to messages for toolResult in toolResultBatch.messages - for content_block in toolResult.content - if content_block isa textContent - append!(final_content, [content_block]) - elseif content_block isa Dict - if haskey(content_block, :text) - push!(final_content, textContent(content_block[:text])) + # eventSink("toolResult " * string(toolResult); publish=false, log=true) + push!(agentMsgHistory, toolResult) + end + eventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + if toolResultBatch.terminate + eventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + # If toolResultBatch requested termination, build a final response + final_content = [textContent("Tool execution completed.")] + for toolResult in toolResultBatch.messages + for content_block in toolResult.content + if content_block isa textContent + append!(final_content, [content_block]) + elseif content_block isa Dict + if haskey(content_block, :text) + push!(final_content, textContent(content_block[:text])) + end end end end + eventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + final_response = assistantMessage( + role="assistant", + content=final_content, + api=assistant_msg.api, + model=assistant_msg.model, + usage=assistant_msg.usage, + stopReason="tool_use_terminated", + errorMessage=if any(x -> x.isError, toolResultBatch.messages) + "One or more tool calls failed" + else + nothing + end, + timestamp=now(), + ) + push!(agentMsgHistory, final_response) + break end - agentEventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))") - final_response = assistantMessage( - role="assistant", - content=final_content, - api=assistant_msg.api, - model=assistant_msg.model, - usage=assistant_msg.usage, - stopReason="tool_use_terminated", - errorMessage=if any(x -> x.isError, toolResultBatch.messages) - "One or more tool calls failed" - else - nothing - end, - timestamp=now(), - ) - push!(agentMsgHistory, final_response) + else + eventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) + # LLM did not use tool calls — break end - else - agentEventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))") - # LLM did not use tool calls — - break + catch e + bt = catch_backtrace() + err_msg = sprint() do io + showerror(io, e, bt) + println(io) + end + + eventSink(err_msg; publish=false, log=true) end end - agentEventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))") + + + eventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))"; publish=false, log=true) return nothing end @@ -495,7 +570,7 @@ agentToolResult([textContent("text", "Tool not found")], Dict{Any,Any}()) ``` """ function createErrorToolResult(msg::String)::agentToolResult - return agentToolResult([textContent("text", msg)], dict{any,any}()) + return agentToolResult([textContent("text", msg)], Dict{Any,Any}()) end """ @@ -787,10 +862,10 @@ stopping the agent when other tools in the batch did not intend to terminate. julia> shouldTerminate(finalizedOutcome[]) false -julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], dict{any,any}(), dict{any,any}(), false), false) for _ in 1:2]) +julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], Dict{Any,Any}(), Dict{Any,Any}(), false), false) for _ in 1:2]) false -julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], dict{any,any}(), dict{any,any}(), true), false) for _ in 1:2]) +julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], Dict{Any,Any}(), Dict{Any,Any}(), true), false) for _ in 1:2]) true ``` """ @@ -902,45 +977,49 @@ function prepareToolCall( toolCall::agentToolCall, config::agentLoopConfig, signal::abortSignal, - agentEventSink + eventSink )::Union{preparedToolCall,immediateOutcome} - agentEventSink("prepareToolCall 1") - tool = get(context.tools, toolCall.name, nothing) # pick a called tool from tool store + eventSink("prepareToolCall 1"; publish=false, log=true) + + # pick a called tool from tool store + tool = get(context.tools, toolCall.name, nothing) + if tool === nothing - agentEventSink("prepareToolCall 2") + eventSink("prepareToolCall 2"; publish=false, log=true) return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) end try - agentEventSink("prepareToolCall 3") + eventSink("prepareToolCall 3"; publish=false, log=true) # 1. prepare arguments (tool-specific transform) prepared = prepareToolCallArguments(tool, toolCall) - agentEventSink("prepared " * string(prepared.arguments)) - agentEventSink("prepareToolCall 4") + eventSink("prepared " * string(prepared.arguments); publish=false, log=true) + eventSink("prepareToolCall 4"; publish=false, log=true) validatedArgs = validateToolArguments(tool, prepared) - agentEventSink("validatedArgs " * string(validatedArgs)) - agentEventSink("prepareToolCall 5") + eventSink("validatedArgs " * string(validatedArgs); publish=false, log=true) + eventSink("prepareToolCall 5"; publish=false, log=true) # 2. beforeToolCall hook — can block if config.beforeToolCall !== nothing - agentEventSink("prepareToolCall 6") + eventSink("prepareToolCall 6"; publish=false, log=true) before = config.beforeToolCall( beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), - signal + signal, + eventSink ) - agentEventSink("prepareToolCall 7") + eventSink("prepareToolCall 7"; publish=false, log=true) if signal.aborted - agentEventSink("prepareToolCall 8") + eventSink("prepareToolCall 8"; publish=false, log=true) return immediateOutcome(createErrorToolResult("Operation aborted"), true) end if before !== nothing && before.block - agentEventSink("prepareToolCall 9") + eventSink("prepareToolCall 9"; publish=false, log=true) return immediateOutcome( createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) end end - agentEventSink("prepareToolCall 10") + eventSink("prepareToolCall 10"; publish=false, log=true) return preparedToolCall(tool, toolCall, validatedArgs) catch e bt = catch_backtrace() @@ -949,7 +1028,7 @@ function prepareToolCall( println(io) end - agentEventSink(errMsg) + eventSink(errMsg; publish=false, log=true) return immediateOutcome(createErrorToolResult(sprint(showerror, e)), true) end @@ -988,7 +1067,7 @@ emitted after the result has already been captured. ```julia # Successful execution executePreparedToolCall(prep, nothing, emit) -# => executedOutcome(agentToolResult([textContent("text", "Found 3 wines")], dict{any,any}(), dict{any,any}()), false) +# => executedOutcome(agentToolResult([textContent("text", "Found 3 wines")], Dict{Any,Any}(), Dict{Any,Any}()), false) # Execution error executePreparedToolCall(prep, nothing, emit) @@ -999,16 +1078,18 @@ executePreparedToolCall(prep, nothing, emit) function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing,abortSignal}, - agentEventSink, + eventSink, )::executedOutcome - agentEventSink("executePreparedToolCall 1") - agentEventSink("executePreparedToolCall 2") - agentEventSink("executePreparedToolCall 3") + eventSink("executePreparedToolCall 1"; publish=false, log=true) try - result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink) - agentEventSink(result.content[1].text) - agentEventSink("executePreparedToolCall 4") + + result = prep.tool.execute(prep.toolCall.id, prep.args, signal, eventSink) + + + eventSink("executePreparedToolCall 2"; publish=false, log=true) + eventSink("tool execute raw result: " * sprint(show, result); publish=false, log=true) + eventSink("executePreparedToolCall 3"; publish=false, log=true) return executedOutcome(result, false) catch e bt = catch_backtrace() @@ -1016,10 +1097,11 @@ function executePreparedToolCall( showerror(io, e, bt) println(io) end - agentEventSink(errMsg) + eventSink(errMsg; publish=false, log=true) return executedOutcome(createErrorToolResult(sprint(showerror, e)), true) end + end @@ -1086,25 +1168,23 @@ function finalizeExecutedToolCall( executed::executedOutcome, config::agentLoopConfig, signal::Union{Nothing,abortSignal}, - agentEventSink + eventSink )::finalizedOutcome - agentEventSink("finalizeExecutedToolCall 1") + eventSink("finalizeExecutedToolCall 1"; publish=false, log=true) result = executed.result isError = executed.isError - agentEventSink("finalizeExecutedToolCall 2") + eventSink("finalizeExecutedToolCall result before afterToolCall\n$result"; publish=false, log=true) if config.afterToolCall !== nothing try after = config.afterToolCall( afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), - signal + signal, + eventSink ) - agentEventSink("finalizeExecutedToolCall 3") + eventSink("finalizeExecutedToolCall 3"; publish=false, log=true) if after !== nothing - result = merge(result, dict(:content=>get(after,:content,result.content), - :details=>get(after,:details,result.details), - :usage=>get(after,:usage,result.usage), - :terminate=>get(after,:terminate,result.terminate))) - isError = get(after, :isError, isError) + result = after + isError = false end catch e bt = catch_backtrace() @@ -1112,13 +1192,13 @@ function finalizeExecutedToolCall( showerror(io, e, bt) println(io) end - agentEventSink(errMsg) + eventSink(errMsg; publish=false, log=true) result = createErrorToolResult(sprint(showerror, e)) isError = true end end - agentEventSink("finalizeExecutedToolCall 4") + eventSink("finalizeExecutedToolCall 4"; publish=false, log=true) return finalizedOutcome(prep.toolCall, result, isError) end @@ -1182,41 +1262,41 @@ function executeToolCallsSequential( toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::abortSignal, - agentEventSink, + eventSink, )::agentToolCallBatch - agentEventSink("executeToolCallsSequential 1") + eventSink("executeToolCallsSequential 1"; publish=false, log=true) finalizedCalls = finalizedOutcome[] messages = toolResultMessage[] for tc in toolCalls - agentEventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)") - prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) - agentEventSink("executeToolCallsSequential " * string(prep.args)) + eventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)"; publish=false, log=true) + prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink) + eventSink("executeToolCallsSequential " * string(prep.args); publish=false, log=true) if prep isa immediateOutcome - agentEventSink("executeToolCallsSequential 2-1") + eventSink("executeToolCallsSequential 2-1"; publish=false, log=true) finalized = finalizedOutcome(tc, prep.result, prep.isError) - agentEventSink("executeToolCallsSequential 2-2") + eventSink("executeToolCallsSequential 2-2"; publish=false, log=true) else - agentEventSink("executeToolCallsSequential 3") - #XXX - executed = executePreparedToolCall(prep, signal, agentEventSink) - agentEventSink("executeToolCallsSequential 3-1") - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, - signal, agentEventSink) - agentEventSink("executeToolCallsSequential 3-2") + eventSink("executeToolCallsSequential 3"; publish=false, log=true) + executed = executePreparedToolCall(prep, signal, eventSink) + + eventSink("executeToolCallsSequential 3-1"; publish=false, log=true) + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, + signal, eventSink) + eventSink("executeToolCallsSequential 3-2"; publish=false, log=true) end - agentEventSink("executeToolCallsSequential 4") - agentEventSink("finalized $(finalized.toolCall.id), $(finalized.toolCall.name), - $(finalized.result), $(finalized.isError)") + eventSink("executeToolCallsSequential 4"; publish=false, log=true) + # eventSink("finalized $(finalized.toolCall.id), $(finalized.toolCall.name), + # $(finalized.result), $(finalized.isError)"; publish=false, log=true) push!(messages, createToolResultMessage(finalized)) push!(finalizedCalls, finalized) - agentEventSink("executeToolCallsSequential 5") + eventSink("executeToolCallsSequential 5"; publish=false, log=true) if signal !== nothing && signal.aborted break end end - agentEventSink("executeToolCallsSequential 6") + eventSink("executeToolCallsSequential 6"; publish=false, log=true) return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) end @@ -1283,26 +1363,26 @@ function executeToolCallsParallel( toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::abortSignal, - agentEventSink, + eventSink, )::agentToolCallBatch entries = Union{finalizedOutcome,Task}[] for tc in toolCalls - agentEventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments)) + eventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments); publish=false, log=true) - prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) + prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink) if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) - agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + eventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) push!(entries, finalized) else t = Task() do - executed = executePreparedToolCall(prep, signal, agentEventSink) + executed = executePreparedToolCall(prep, signal, eventSink) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + eventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) return finalized end @@ -1385,10 +1465,10 @@ function executeToolCalls( toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::abortSignal, - agentEventSink, + eventSink, )::agentToolCallBatch - agentEventSink("_executeToolCalls 1") + eventSink("_executeToolCalls 1"; publish=false, log=true) hasSequential = false for tc in toolCalls t = get(context.tools, tc.name, nothing) @@ -1397,15 +1477,15 @@ function executeToolCalls( break end end - agentEventSink("_executeToolCalls 2") + eventSink("_executeToolCalls 2"; publish=false, log=true) if config.toolExecution == "sequential" || hasSequential - agentEventSink("_executeToolCalls 3") - return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, - agentEventSink) + eventSink("_executeToolCalls 3"; publish=false, log=true) + return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, + eventSink) else - agentEventSink("_executeToolCalls 4") - return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, - agentEventSink) + eventSink("_executeToolCalls 4"; publish=false, log=true) + return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, + eventSink) end end @@ -1444,13 +1524,13 @@ OpenAiToUserMessage(msg) # => userMessage("user", [textContent("Hello")], DateTime(...)) ``` """ -function OpenAiToUserMessage(msg::Dict)::userMessage +function OpenAiToUserMessage(msg::AbstractDict)::userMessage content_blocks = Vector{messageContent}() raw_content = get(msg, "content", Any[]) if raw_content isa Vector for block in raw_content - if block isa Dict + if typeof(block) <: AbstractDict block_type = get(block, "type", "") if block_type == "text" text = get(block, "text", "") @@ -1539,11 +1619,6 @@ end - - - - - diff --git a/src/api.jl b/src/api.jl index 50d6dbd..13d6f4e 100644 --- a/src/api.jl +++ b/src/api.jl @@ -1,6 +1,6 @@ module api -export prompt +export promptAgent, takeResponse, followUp, stopAgent using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames @@ -18,7 +18,7 @@ The agent processes messages from `inputChannel` in the background task. # Arguments - `agent::yiemAgent`: The agent instance to send a message to -- `msg`: The message to send (any type accepted by the agent's processing pipeline) +- `msg`: The message to send, in OpenAI message format (e.g. `Dict("role" => "user", "content" => "Hello!")`) # Returns - The same `agent` instance for chaining @@ -29,15 +29,20 @@ The agent processes messages from `inputChannel` in the background task. # Examples ```jldoctest -julia> runAgent(agent, "Hello!") +julia> promptAgent(agent, Dict("role" => "user", "content" => "Hello!")) yiemAgent(...) ``` """ -function runAgent(agent::yiemAgent, msg) +function promptAgent(agent::yiemAgent, msg::AbstractDict{String, Any}) put!(agent.inputChannel, msg) return agent end +function promptAgent(agent::yiemAgent, text::String) + put!(agent.inputChannel, Dict("role" => "user", "content" => [Dict("type" => "text", "text" => text)])) + return agent +end + """ Take a response from the agent's output channel. @@ -50,7 +55,7 @@ Blocks until the agent sends a response. - An `assistantMessage` instance representing the agent's response # Notes -- Use `runAgent(agent, msg)` to send a message before calling this function. +- Use `promptAgent(agent, msg)` to send a message before calling this function. # Examples ```jldoctest @@ -70,27 +75,32 @@ and before any tool call results are sent. # Arguments - `agent::yiemAgent`: The agent instance to send a follow-up message to -- `msg`: The follow-up message to send +- `msg`: The follow-up message to send, in OpenAI message format (e.g. `Dict("role" => "user", "content" => "Also consider red wines")`) # Returns - The same `agent` instance for chaining # Notes -- Use `runAgent(agent, msg)` for the primary message and `followUp(agent, msg)` for additional +- Use `promptAgent(agent, msg)` for the primary message and `followUp(agent, msg)` for additional messages while the agent is processing. - Follow-up messages are buffered in a separate channel (capacity 32 by default). # Examples ```jldoctest -julia> followUp(agent, "Also consider red wines") +julia> followUp(agent, Dict("role" => "user", "content" => "Also consider red wines")) yiemAgent(...) ``` """ -function followUp(agent::yiemAgent, msg) +function followUp(agent::yiemAgent, msg::AbstractDict{String, Any}) put!(agent.followUpChannel, msg) return agent end +function followUp(agent::yiemAgent, text::String) + put!(agent.followUpChannel, Dict("role" => "user", "content" => [Dict("type" => "text", "text" => text)])) + return agent +end + """ Gracefully stop the agent. diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 1d6bd52..cceb5c1 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -1,179 +1,291 @@ module toolRegistry -export toolStore, registerTool, getTools, clearTools, listTool +export registerTool, registerAllTools, clearTools, listTool using Dates using JSON, DataStructures using ..type -""" -Per-agent isolated tool storage. -Each agent gets its own `toolStore` so tool registration is independent — -`registerTool(store, tool)` only affects that agent's tool set. -# Fields -- `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration -- `name::String` — identifier for debugging/logs +# ── MCP helper functions ──────────────────────────────────────────── + """ -struct toolStore - tools::OrderedDict{String, agentTool} - name::String +Extract text from MCP tool result content array. + +Handles JSON-RPC 2.0 result content format: + {"content": [{"type": "text", "text": "..."}], "isError": false} +""" +function _extract_text_content(result::AbstractDict)::String + content = get(result, "content", Any[]) + if content isa Vector && !isempty(content) + lines = String[] + for block in content + if block isa Dict && get(block, "type", "") == "text" + push!(lines, string(get(block, "text", ""))) + end + end + if !isempty(lines) + return join(lines, "\n") + end + end + return JSON.json(result) end """ - toolStore(; name="default") -> toolStore +Wrap an MCP tool definition as an `agentTool`. -Create a new empty tool store. - -# Keyword Arguments -- `name::String`: Display name for logging (default: `"default"`) - -# Example -```julia -julia> store = toolStore(name="agent1") -toolStore(OrderedDict{String, agentTool}(), "agent1") -``` +The returned tool's `execute` function calls the MCP server's "tools/call" +method with the validated arguments. """ -function toolStore(; name::String="default")::toolStore - toolStore(OrderedDict{String, agentTool}(), name) +function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSink=nothing, sessionId=nothing)::agentTool + name = tool_def["name"] + title = get(tool_def, "title", get(tool_def, "label", name)) + desc = get(tool_def, "description", "") + input_schema = get(tool_def, "inputSchema", Dict{String,Any}()) + + # Normalize inputSchema to OpenAI function format + if haskey(input_schema, "properties") && input_schema["type"] == "object" + params = Dict( + "type" => "object", + "properties" => input_schema["properties"], + "required" => get(input_schema, "required", Any[]), + ) + else + params = Dict( + "type" => "object", + "properties" => Dict{String,Any}(), + "required" => Any[], + ) + end + + return agentTool( + name=name, + label=title, + description=desc, + inputSchema=params, + execute=(toolCallId::String, args::AbstractDict{String, Any}, + signal::Union{Nothing,abortSignal}, + eventSink) -> begin + try + response = mcpserver("tools/call", name, args, sessionId; eventSink=eventSink) + + # Parse JSON-RPC 2.0 response envelope + if haskey(response, "error") + rpc_error = response["error"] + err_msg = get(rpc_error, "message", "Unknown MCP error") + return agentToolResult( + [textContent("MCP error: $err_msg")], + Dict{Any,Any}("error" => err_msg), + nothing, false + ) + end + + result_data = get(response, "result", response) + content_text = _extract_text_content(result_data) + is_error = get(result_data, "isError", false) + + return agentToolResult( + [textContent(content_text)], + Dict{Any,Any}("isError" => is_error), + nothing, false + ) + catch e + errMsg = sprint(showerror, e) + return agentToolResult( + [textContent("MCP call error: $errMsg")], + Dict{Any,Any}("error" => errMsg), + nothing, false + ) + end + end, + prepareArguments=nothing, + validateRequiredArgs=nothing, + parallelToolExecute=false, + ) end """ - listTool(store::toolStore) -> agentTool +Discover and register MCP tools into `store.tools`. -Return an `agentTool` definition for listing registered tools. +Queries the MCP server via `mcpserver("tools/list")` (JSON-RPC 2.0 format), +parses the response envelope, and registers each discovered tool. +Handles pagination via `nextCursor`. Skips tools already registered. -Each call produces a **new** tool object that captures (closes over) -`store`. `register_all_tools` auto-registers one so the LLM can discover tools -at runtime. +# Returns +- `Int`: number of new tools registered +""" +function register_mcp_tools(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing, sessionId=nothing)::Int + if mcpserver === nothing + return 0 + end + new_count = 0 + try + eventSink("register_mcp_tools 1") + response = mcpserver("tools/list", sessionId; eventSink=eventSink) + eventSink("register_mcp_tools 2") + # Parse JSON-RPC 2.0 response envelope + if haskey(response, "error") + rpc_error = response["error"] + err_msg = get(rpc_error, "message", "Unknown MCP error") + println("[toolRegistry] MCP tools/list failed: $err_msg") + return 0 + end + if haskey(response, "result") + response = response["result"] + end + + tools_array = response["tools"] + cursor = get(response, "nextCursor", nothing) + + for tool_def in tools_array + name = tool_def["name"] + if haskey(tools, name) + continue + end + + wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink, sessionId=sessionId) + tools[name] = wrapped + new_count += 1 + end + + # Paginate: fetch remaining tools if nextCursor is present + while cursor !== nothing && cursor !== "" + response = sessionId !== nothing ? mcpserver("tools/list", sessionId; eventSink=eventSink) : mcpserver("tools/list"; eventSink=eventSink) + if haskey(response, "result") + response = response["result"] + end + tools_array = get(response, "tools", Any[]) + cursor = get(response, "nextCursor", nothing) + for tool_def in tools_array + name = tool_def["name"] + if haskey(tools, name) + continue + end + wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink, sessionId=sessionId) + tools[name] = wrapped + new_count += 1 + end + end + + println("[toolRegistry] Discovered $new_count MCP tools. Total: $(length(tools))") + catch e + bt = catch_backtrace() + err_msg = sprint() do io + showerror(io, e, bt) + println(io) + end + + eventSink(err_msg) + + errMsg = sprint(showerror, e) + println("[toolRegistry] MCP tools/list failed: $errMsg") + end + + return new_count +end + +""" + registerTool(tools::OrderedDict{String, agentTool}, tool::agentTool) -> OrderedDict{String, agentTool} + +Add `tool` to `tools`, overwriting any existing tool with the same name. # Arguments -- `store`: The tool store whose tools will be listed when the tool runs +- `tools`: Tool dict to modify +- `tool`: The `agentTool` to register -# Example -```julia -julia> store = toolStore(name="agent1"); - -julia> register_all_tools(store) # auto-registers listTools -[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup) -[toolRegistry:agent1] Registered tool: listTools - -julia> tools = getTools(store) -OrderedDict{String, agentTool} with 4 entries: - "getWeather" => agentTool(...) - "getTime" => agentTool(...) - "writeTool" => agentTool(...) - "listTools" => agentTool(...) -``` +# Returns +- The same `tools` dict (modified in place) """ -function listTool(store::toolStore)::agentTool +function registerTool(tools::OrderedDict{String, agentTool}, tool::agentTool; eventSink=nothing + )::OrderedDict{String, agentTool} + tools[tool.name] = tool + println("[toolRegistry] Registered tool: $(tool.name)") + return tools +end + +""" +Remove all tools from the dict. + +# Arguments +- `tools`: Tool dict to clear + +# Returns +- `nothing` +""" +function clearTools(tools::OrderedDict{String, agentTool})::Nothing + empty!(tools) + println("[toolRegistry] Registry cleared") + return nothing +end + +# ── listTools tool (auto-discover new MCP tools at runtime) ───────── + +""" + listTool(tools::OrderedDict{String, agentTool}, mcpserver) -> agentTool + +MCP-aware listTools tool (JSON-RPC 2.0 protocol). + +First call: queries the MCP server via `mcpserver("tools/list")`, registers +all discovered tools into `tools` (in-place mutation, with +pagination via nextCursor), then returns the full tool list. + +Subsequent calls: returns the current list (tools remain registered). +""" +function listTool(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing, sessionId=nothing)::agentTool return agentTool( - name = "listTools", - label = "List Tools", - description = "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.", - inputSchema = Dict{String,Any}( + name="listTools", + label="List Tools", + description="List all available tools. First call discovers and registers all tools from the MCP server. After discovery, new tools become immediately available for use.", + inputSchema=Dict{String,Any}( "type" => "object", "properties" => Dict{String,Any}(), "required" => Any[] ), - execute = (toolCallId, args, signal, onPartialResult) -> begin - tools = getTools(store) - if isempty(tools) - result_text = "No tools registered." - else - lines = String["- $(t.name): $(t.label) — $(t.description)" for (k, t) in tools] - result_text = "Available tools:\n" * join(lines, "\n") - end + execute=(toolCallId::String, args::AbstractDict{String, Any}, + signal::Union{Nothing,abortSignal}, + eventSink) -> begin + # Discover and register MCP tools (idempotent — skips already registered) + new_count = register_mcp_tools(tools, mcpserver; eventSink=eventSink, sessionId=sessionId) + eventSink("tools dump: " * sprint(show, tools)) + + # Always include listTools itself in the count + total = length(tools) + + lines = String[ + "- $(t.name): $(t.label) — $(t.description)" + for (k, t) in tools + ] + result_text = "Available tools ($total):\n" * join(lines, "\n") + return agentToolResult( [textContent(result_text)], - Dict{Any,Any}("count" => length(tools)), + Dict{Any,Any}("count" => total), nothing, false ) end, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false + prepareArguments=nothing, + validateRequiredArgs=nothing, + parallelToolExecute=false, ) end -# Note: register_all_tools is defined in YiemAgent.jl where tool functions are in scope +# ── High-level API ────────────────────────────────────────────────── """ - registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool} + registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver) -Add `tool` to `store`, overwriting any existing tool with the same name. +Register all tools for an agent: + 1. Auto-discover existing tools from the MCP server + 2. Register the `listTools` tool so the agent can discover new tools at runtime # Arguments -- `store`: Tool store to modify -- `tool`: The `agentTool` to register - -# Returns -- The same `store.tools` dict (modified in place) - -# Example -```julia -julia> store = toolStore(name="agent1"); - -julia> registerTool(store, listTool(store)) -[toolRegistry:agent1] Registered tool: listTools -OrderedDict{String, agentTool} with 1 entry: - "listTools" => agentTool(...) -``` +- `tools`: The tool dict to populate +- `mcpserver`: A callable struct that communicates with the MCP server """ -function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, agentTool} - store.tools[tool.name] = tool - println("[$(store.name)] Registered tool: $(tool.name)") - return store.tools -end - -""" -Return the tools registered in `store`. - -The returned dict is the **same object** stored inside `store` — mutations -to it (e.g. via `registerTool`) are visible through subsequent calls. - -# Arguments -- `store`: Tool store to query - -# Returns -- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order - -# Example -```julia -julia> tools = getTools(store) -OrderedDict{String, agentTool} with 2 entries: - "getWeather" => agentTool(...) - "getTime" => agentTool(...) -``` -""" -function getTools(store::toolStore)::OrderedDict{String, agentTool} - return store.tools -end - -""" -Remove all tools from `store`. - -# Arguments -- `store`: Tool store to clear - -# Returns -- `nothing` - -# Example -```julia -julia> clearTools(store) -[toolRegistry:agent1] Registry cleared -nothing - -julia> getTools(store) -OrderedDict{String, agentTool} with 0 entries -``` -""" -function clearTools(store::toolStore)::Nothing - empty!(store.tools) - println("[$(store.name)] Registry cleared") - return nothing +function registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver=nothing; eventSink=nothing, sessionId=nothing) + list_t = listTool(tools, mcpserver; eventSink=eventSink, sessionId=sessionId) + registerTool(tools, list_t; eventSink=eventSink) end end # module diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl deleted file mode 100644 index 748d5e5..0000000 --- a/src/tools/getTime.jl +++ /dev/null @@ -1,83 +0,0 @@ -using .type -using Dates - -""" -Validate required arguments for the getTime tool. - -Demonstrates custom validation beyond simple required-field checking: -- Ensures at least one time source (timezone or city) is provided -- Validates timezone is in IANA format if specified -- Validates city name is not empty if specified - -# Arguments -- `args::Dict{String,Any}`: Arguments from the LLM - -# Returns -- `nothing` if validation passes -- `String` error message if validation fails -""" -function getTimeValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} - tz = get(args, "timezone", nothing) - city = get(args, "city", "") - - hasTz = tz !== nothing && !isempty(tz) - hasCity = !isempty(city) - - # At least one of timezone or city is required - if !hasTz && !hasCity - return "Missing required argument: provide at least one of 'timezone' or 'city'" - end - - # Validate timezone format (IANA tz database: "Continent/City" or "Continent/City/SubCity") - if hasTz - tz_str = string(tz) - if !occursin(r"^[A-Za-z]+\/[A-Za-z]+(/[A-Za-z]+)*$", tz_str) - return "Invalid timezone format: '$tz_str'. Use IANA format, e.g. 'America/New_York' or 'Asia/Tokyo'" - end - end - - return nothing -end - -""" -Execute the getTime tool. - -Returns mock time data for the given timezone or city. -""" -function getTimeExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, - onPartialResult) - tz = get(args, "timezone", nothing) - city = get(args, "city", "") - if tz !== nothing - result = "Current time in $(tz): $(now())" - else - result = "Current time in $(city): $(now())" - end - return agentToolResult( - [textContent(result)], - Dict{Any,Any}(), nothing, false - ) -end - -""" -Define and return the getTime agentTool. -""" -function getTimeTool()::agentTool - return agentTool( - name = "getTime", - label = "Time Lookup", - description = "Get current local time for a timezone or city.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "timezone" => Dict("type" => "string", "description" => "IANA timezone, e.g. 'America/New_York'"), - "city" => Dict("type" => "string", "description" => "City name as fallback") - ), - "required" => [] - ), - execute = getTimeExecute, - prepareArguments = nothing, - validateRequiredArgs = getTimeValidateRequiredArgs, - parallelToolExecute = false - ) -end diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl deleted file mode 100644 index 5cca9cd..0000000 --- a/src/tools/getWeather.jl +++ /dev/null @@ -1,48 +0,0 @@ -using msghandler -using .type - -""" -Execute the getWeather tool. - -Returns mock weather data for the given city and temperature units. -""" -function getWeatherExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, - agentEventSink) - - agentEventSink("Getting weather...") - - city = get(args, "city", "") - units = get(args, "units", "celsius") - temp = units == "fahrenheit" ? "72" : "22" - unit_symbol = units == "celsius" ? "°C" : "°F" - - return agentToolResult( - [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], - Dict{Any,Any}(), - nothing, - false - ) -end - -""" -Define and return the getWeather agentTool. -""" -function getWeatherTool()::agentTool - return agentTool( - name = "getWeather", - label = "Weather Lookup", - description = "Fetch current weather and forecast for a given city.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "city" => Dict("type" => "string", "description" => "City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'"), - "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius", "description" => "Temperature scale") - ), - "required" => ["city"] - ), - execute = getWeatherExecute, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl deleted file mode 100644 index 8524772..0000000 --- a/src/tools/writeTool.jl +++ /dev/null @@ -1,276 +0,0 @@ -using .type -using JSON - -""" -Tool that writes new Julia tool module files to disk. - -The agent can use this tool when it encounters a task that no existing tool -can handle. Provide the tool's name, label, description, inputSchema, and -execute logic as Julia code. The tool is written to `src/tools/.jl`. - -After calling this tool, add the new file to `YiemAgent.jl` with an `include()` -statement (after `include("toolRegistry.jl")`), then restart the agent. -The new tool must be registered in `register_all_tools()` in `toolRegistry.jl`. - -# Example - -1. Agent calls writeTool with a spec for a "searchWine" tool -2. writeTool generates src/tools/searchWine.jl -3. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl -4. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() -5. Restart agent — new tool is available - -# How It Works - -writeTool is a **file writer**, not a code generator. The LLM provides the -tool logic as `executeCode`, and writeTool wraps it in Julia boilerplate: - - Converts `inputSchema` Dict into Julia `Dict{String,Any}(...)` string - - Indents `executeCode` with 4 spaces - - Wraps it inside `function executeTool(...)::agentToolResult ... end` - - Appends `writeToolTool()` returning an `agentTool` struct - - Writes the combined string to `src/tools/.jl` - -# Important Notes - -- The `executeCode` string is embedded literally into the generated tool. - Use `args["param_name"]` to access input parameters. -- The code string should be the function body (NOT wrapped in a function). - Lines will be indented with 4 spaces inside the execute function. -- Tool names must be valid Julia identifiers (lowercase letters, digits, underscores, - no leading digits or special characters). -""" - -""" -Validate that a tool name is a valid Julia identifier. -""" -function validateToolName(name::String)::Union{Nothing,String} - if !occursin(r"^[a-zA-Z_][a-zA-Z0-9_!]*$", name) - return "Invalid tool name: '$name'. Tool names must be valid Julia identifiers (letters, digits, underscores, starting with a letter or underscore)." - end - return nothing -end - -""" -Indent a multi-line code string by the specified number of spaces. -""" -function indent_code(code::String, n::Int)::String - prefix = " "^n - lines = split(code, '\n') - result_lines = String[prefix * line for line in lines] - return join(result_lines, "\n") -end - -""" -Convert a Julia Dict to a valid Julia Dict{String,Any}(...) literal string. -""" -function dict_to_julia_literal(d)::String - if d isa Dict - items = String[] - for (k, v) in d - key_str = json_string(k) - val_str = value_to_julia(v) - push!(items, "$key_str => $val_str") - end - return "Dict{String,Any}(" * join(items, ", ") * ")" - else - return value_to_julia(d) - end -end - -function value_to_julia(v)::String - if v isa Dict - return dict_to_julia_literal(v) - elseif v isa Vector - items = [value_to_julia(x) for x in v] - return "[" * join(items, ", ") * "]" - elseif v isa String - escaped = replace(v, "\\" => "\\\\") - escaped = replace(escaped, "\"" => "\\\"") - return "\"$escaped\"" - elseif v isa Number - return string(v) - elseif v isa Bool - return string(v) - elseif v === nothing - return "nothing" - else - return "\"$(v)\"" - end -end - -""" -Convert any Julia value to a JSON string. -""" -function json_string(v)::String - return JSON.json(v) -end - -""" -Define and return the writeTool agentTool. -""" -function writeToolTool()::agentTool - return agentTool( - name = "writeTool", - label = "Create Tool", - description = "Write a new Julia tool module file to src/tools/.jl. The LLM provides the tool logic as executeCode; writeTool wraps it in Julia boilerplate and writes the file. Restart the agent to load the new tool.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "name" => Dict("type" => "string", "description" => "Unique tool name (valid Julia identifier, no spaces or special chars)"), - "label" => Dict("type" => "string", "description" => "Human-readable tool name shown in tool descriptions"), - "description" => Dict("type" => "string", "description" => "What the tool does (shown to LLM for tool selection decisions)"), - "inputSchema" => Dict( - "type" => "object", - "description" => "JSON Schema describing tool parameters in MCP format" - ), - "executeCode" => Dict("type" => "string", "description" => "Julia code for the execute function body. Use args[\"key\"] to access parameters. Do NOT wrap in a function definition."), - "validateCode" => Dict("type" => "string", "optional" => true, "description" => "Optional custom validation Julia code (runs before execute). Use args[\"key\"] to access parameters. Return nothing to pass, or a string error message to fail."), - "prepareCode" => Dict("type" => "string", "optional" => true, "description" => "Optional argument preparation code (runs before validation). Return modified args dict."), - "parallel" => Dict("type" => "boolean", "default" => false, "description" => "Whether this tool can run in parallel with other tools") - ), - "required" => ["name", "label", "description", "inputSchema", "executeCode"] - ), - execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult) -> begin - tool_name = get(args, "name", "")::String - tool_label = get(args, "label", tool_name)::String - tool_description = get(args, "description", "")::String - tool_schema = get(args, "inputSchema", Dict{String,Any}())::Dict{String,Any} - execute_code = get(args, "executeCode", "")::String - validate_code = get(args, "validateCode", nothing)::Union{String,Nothing} - prepare_code = get(args, "prepareCode", nothing)::Union{String,Nothing} - parallel = get(args, "parallel", false)::Bool - - # Validate tool name - name_err = validateToolName(tool_name) - if name_err !== nothing - return agentToolResult( - [textContent(name_err)], - Dict{Any,Any}(), nothing, false - ) - end - - # Validate required fields - if isempty(tool_name) - return agentToolResult( - [textContent("Missing required field: 'name'")], - Dict{Any,Any}(), nothing, false - ) - end - if isempty(tool_description) - return agentToolResult( - [textContent("Missing required field: 'description'")], - Dict{Any,Any}(), nothing, false - ) - end - if isempty(execute_code) - return agentToolResult( - [textContent("Missing required field: 'executeCode'")], - Dict{Any,Any}(), nothing, false - ) - end - - onPartialResult(Dict("status" => "Generating tool: $tool_name")) - - # Build the tool file path - script_dir = dirname(@__FILE__) - tools_dir = dirname(script_dir) - filepath = joinpath(tools_dir, "$(tool_name).jl") - - # Check for naming conflicts - if isfile(filepath) - return agentToolResult( - [textContent("Tool file already exists: $filepath. Rename the tool or delete the existing file first.")], - Dict{Any,Any}(), nothing, false - ) - end - - onPartialResult(Dict("status" => "Writing file: $(basename(filepath))")) - - # Convert schema Dict to a Julia Dict literal string - schema_literal = dict_to_julia_literal(tool_schema) - - # Build optional validation function - validate_section = if validate_code !== nothing && !isempty(validate_code) - indented = indent_code(validate_code, 4) - "function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}\n$indented\n return nothing\nend\n" - else - "" - end - - # Build optional prepare function - prepare_section = if prepare_code !== nothing && !isempty(prepare_code) - indented = indent_code(prepare_code, 4) - "function prepareArguments(args::Dict{String,Any})::Dict{String,Any}\n$indented\n return args\nend\n" - else - "" - end - - # Indent user's execute code for embedding inside execute function body - indented_exec = indent_code(execute_code, 4) - - # Escape description for Julia string literal - escaped_desc = replace(tool_description, "\\" => "\\\\") - escaped_desc = replace(escaped_desc, "\"" => "\\\"") - - # Build the complete tool file content - parts = String[] - push!(parts, "# Auto-generated tool: $tool_name\n") - push!(parts, "# Generated by writeTool at $(now())\n\n") - if !isempty(validate_section) - push!(parts, validate_section) - push!(parts, "\n") - end - if !isempty(prepare_section) - push!(parts, prepare_section) - push!(parts, "\n") - end - push!(parts, "\n") - push!(parts, "# Execute function\n") - push!(parts, "function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult\n") - push!(parts, "$indented_exec\n") - push!(parts, "end\n\n") - push!(parts, "# Tool definition\n") - push!(parts, "function getTool()::agentTool\n") - push!(parts, " return agentTool(\n") - push!(parts, " name = \"$(tool_name)\",\n") - push!(parts, " label = \"$(tool_label)\",\n") - push!(parts, " description = \"$(escaped_desc)\",\n") - push!(parts, " inputSchema = $schema_literal,\n") - push!(parts, " execute = executeTool,\n") - if validate_code !== nothing && !isempty(validate_code) - push!(parts, " validateRequiredArgs = validateRequiredArgs,\n") - else - push!(parts, " validateRequiredArgs = nothing,\n") - end - if prepare_code !== nothing && !isempty(prepare_code) - push!(parts, " prepareArguments = prepareArguments,\n") - else - push!(parts, " prepareArguments = nothing,\n") - end - push!(parts, " parallelToolExecute = $parallel\n") - push!(parts, " )\n") - push!(parts, "end\n") - - tool_code = join(parts) - - # Write the file — tool must be included in YiemAgent.jl and registered in register_all_tools() - write(filepath, tool_code) - - onPartialResult(Dict("status" => "Done")) - - return agentToolResult( - [textContent("Tool '$(tool_name)' written to $filepath. Add include(\"tools/$(tool_name).jl\") to YiemAgent.jl and registerTool(store, $(tool_name)Tool()) to register_all_tools(), then restart the agent.")], - Dict{Any,Any}( - "file" => filepath, - "name" => tool_name, - "label" => tool_label, - "description" => tool_description, - ), - nothing, false - ) - end, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end diff --git a/src/type.jl b/src/type.jl index bc224af..ced7575 100644 --- a/src/type.jl +++ b/src/type.jl @@ -23,7 +23,7 @@ preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome, agentToolCallBatch, # Functions (defined elsewhere) - runAgent, takeResponse, followUp, stopAgent + promptAgent, takeResponse, followUp, stopAgent using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads @@ -407,7 +407,7 @@ function agentState( agentState( systemPrompt, model, - deepcopy(tools), + tools, deepcopy(messages), Vector{String}(), nothing, @@ -442,7 +442,7 @@ end """ Configuration for the agent tool execution loop. -# Arguments +# Fields - `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution - `afterToolCall::Union{Function, Nothing}`: Callback after tool execution - `toolExecution::String`: Execution mode — "sequential" or "parallel" @@ -485,13 +485,13 @@ Context passed to the `beforeToolCall` hook. # Arguments - `message::assistantMessageToolCall`: The assistant message containing the tool call - `toolCall::agentToolCall`: The tool call being prepared -- `args::Dict{String,Any}`: Validated tool arguments +- `args::AbstractDict{String, Any}`: Validated tool arguments - `context::agentContext`: Current conversation context """ struct beforeToolCallContext message::assistantMessageToolCall toolCall::agentToolCall - args::Dict{String,Any} + args::AbstractDict{String, Any} context::agentContext end @@ -506,7 +506,7 @@ Context passed to the `afterToolCall` hook. # Arguments - `message::assistantMessageToolCall`: The assistant message containing the tool call - `toolCall::agentToolCall`: The tool call that was executed -- `args::Dict{String,Any}`: Tool arguments +- `args::AbstractDict{String, Any}`: Tool arguments - `result::agentToolResult`: The raw tool result - `isError::Bool`: Whether execution resulted in an error - `context::agentContext`: Current conversation context @@ -514,7 +514,7 @@ Context passed to the `afterToolCall` hook. struct afterToolCallContext message::assistantMessageToolCall toolCall::agentToolCall - args::Dict{String,Any} + args::AbstractDict{String, Any} result::agentToolResult isError::Bool context::agentContext @@ -526,12 +526,12 @@ Event emitted when a tool call execution starts. # Arguments - `toolCallId::String`: ID of the tool call - `toolName::String`: Name of the tool -- `arguments::Dict{String,Any}`: Tool arguments +- `arguments::AbstractDict{String, Any}`: Tool arguments """ struct toolExecStartEvent toolCallId::String toolName::String - arguments::Dict{String,Any} + arguments::AbstractDict{String, Any} end """ @@ -540,13 +540,13 @@ Event emitted with partial results during tool execution. # Arguments - `toolCallId::String`: ID of the tool call - `toolName::String`: Name of the tool -- `arguments::Dict{String,Any}`: Tool arguments +- `arguments::AbstractDict{String, Any}`: Tool arguments - `partialResult::Any`: The partial result data """ struct toolExecUpdateEvent toolCallId::String toolName::String - arguments::Dict{String,Any} + arguments::AbstractDict{String, Any} partialResult::Any end diff --git a/src/utils.jl b/src/utils.jl index 29ac6a2..1a4c565 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -2,8 +2,8 @@ module utils export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, - _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI, - beforeToolCall, afterToolCall, agentEventSink + _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, + _toolsToOpenAI, beforeToolCall, afterToolCall using UUIDs, Dates, DataStructures, HTTP, JSON, NATS using GeneralUtils @@ -96,7 +96,7 @@ or pruning and reordering messages before formatting and calling the LLM. messages, and tools to be sent to the LLM # Examples -```julia +```jldoctest # Default: returns an agentContext with deep copies of system prompt, messages, and tools prepareContext(state).messages == deepcopy(state.messages) @@ -109,14 +109,14 @@ prepareContext(state).messages == deepcopy(state.messages) # end ``` """ -function prepareContext(state::agentState, agentEventSink)::agentContext - +function prepareContext(state::agentState, eventSink)::agentContext + #TODO filter tools from state.tools based on user intend in user message and tool description filteredTools = state.tools #TODO add filtered tools to the current system prompt / modify systemPrompt here preparedSystemPrompt = state.systemPrompt - + #TODO add system prompt, adjust/modify and inject additional context into messages preparedMessages = deepcopy(state.messages) # messages that will be send to LLM @@ -146,7 +146,7 @@ LLM message formats for different APIs/providers. an array of OpenAI-format message dicts # Examples -```julia +```jldoctest # Default output: formatMsgForLLm(ctx) == Dict("messages" => [ Dict("role" => "system", "content" => [...]), @@ -156,7 +156,7 @@ formatMsgForLLm(ctx) == Dict("messages" => [ ]) ``` """ -function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} +function formatMsgForLLM(ctx::agentContext, eventSink)::Dict{String, Any} """ openai message format example msg = Dict( @@ -208,7 +208,7 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} openaiReadyMsg = Dict{String, Any}() # openaiReadyMsg["model"] = "gemma-4-E4B-it-UD-Q4_K_XL" messages = Vector{Dict{String, Any}}() - agentEventSink("formatMsgForLLM 1") + eventSink("formatMsgForLLM 1") # System prompt as system message if !isempty(ctx.systemPrompt) push!(messages, Dict( @@ -216,7 +216,7 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] )) end - agentEventSink("formatMsgForLLM 2") + eventSink("formatMsgForLLM 2") # Conversation messages for msg in ctx.messages if msg isa userMessage @@ -229,10 +229,10 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} push!(messages, _toolResultMessageToOpenAI(msg)) end end - agentEventSink("formatMsgForLLM 3") + eventSink("formatMsgForLLM 3") # Convert ctx.tools into OpenAI tools format - tools_array = _toolsToOpenAI(ctx.tools, agentEventSink) - agentEventSink("formatMsgForLLM 4") + tools_array = _toolsToOpenAI(ctx.tools, eventSink) + eventSink("formatMsgForLLM 4") openaiReadyMsg["messages"] = messages openaiReadyMsg["temperature"] = 0.7 @@ -265,7 +265,7 @@ Common use cases: - `nothing` is treated as allow (equivalent to `beforeToolCallResult(false, "N/A")`). # Example -```julia +```jldoctest function beforeToolCall(context::beforeToolCallContext, signal::abortSignal) if context.toolCall.name == "deleteFile" # Block file deletion unless explicitly approved @@ -275,14 +275,10 @@ function beforeToolCall(context::beforeToolCallContext, signal::abortSignal) end ``` """ -function beforeToolCall(context::beforeToolCallContext, signal::abortSignal +function beforeToolCall(context::beforeToolCallContext, signal::abortSignal, eventSink )::beforeToolCallResult - # final context check - - # seek user approval via UI - - # other check + eventSink("beforeToolCall: $(context.toolCall.name) args=$(context.args)") return beforeToolCallResult(false, "N/A") end @@ -310,22 +306,15 @@ Common use cases: - `agentToolResult(...)` to return a modified result (content, details, usage, terminate flag can all be overridden). -""" -function afterToolCall(context::afterToolCallContext, signal::abortSignal +""" #WORKING arrange result from searchWine tool +function afterToolCall(context::afterToolCallContext, signal::abortSignal, eventSink )::Union{agentToolResult, Nothing} - # modify context.result if needed and return agentToolResult + eventSink("afterToolCall: $(context.toolCall.name) result=$(context.result) error=$(context.isError)") return nothing end - -#TODO -function agentEventSink(x) - -end - - """ Convert a userMessage to OpenAI message format. """ @@ -375,7 +364,7 @@ function _assistantMessageToolCallToOpenAI(msg::assistantMessageToolCall)::Dict{ ) end -""" +""" Convert an assistantMessage to OpenAI message format. """ function _assistantMessageToOpenAI(msg::assistantMessage)::Dict{String, Any} @@ -432,15 +421,15 @@ Convert an OrderedDict of agentTool definitions into OpenAI function tool format Returns an empty vector when `tools` is `nothing` or empty. # Examples -```julia +```jldoctest _toolsToOpenAI(nothing) # => Dict{String, Any}[] _toolsToOpenAI(tools) # => [Dict("type" => "function", "function" => Dict("name" => "getWeather", ...))] ``` """ -function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, agentEventSink)::Vector{Dict{String, Any}} +function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, eventSink)::Vector{Dict{String, Any}} tools_array = Vector{Dict{String, Any}}() - agentEventSink("_toolsToOpenAI 1") - agentEventSink(string(typeof(tools))) + eventSink("_toolsToOpenAI 1") + eventSink(string(typeof(tools))) if tools !== nothing for (_, tool) in tools push!(tools_array, Dict( @@ -453,13 +442,13 @@ function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, a )) end end - agentEventSink("_toolsToOpenAI 2") + eventSink("_toolsToOpenAI 2") return tools_array end """ - validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String} + validateRequiredArgs(args::AbstractDict{String, Any}, inputSchema::AbstractDict{String, Any}) -> Union{Nothing,String} Validates that all required fields listed in the tool's JSON Schema are present in `args`. Returns `nothing` if validation passes, or a descriptive error string @@ -470,15 +459,15 @@ with a custom validation function that performs additional checks (e.g. type coercion, format validation, cross-field constraints). # Arguments -- `args::Dict{String,Any}`: The arguments provided by the LLM -- `inputSchema::Dict{String,Any}`: The tool's `inputSchema` (JSON Schema format) +- `args::AbstractDict{String, Any}`: The arguments provided by the LLM +- `inputSchema::AbstractDict{String, Any}`: The tool's `inputSchema` (JSON Schema format) # Returns - `nothing` if all required args are present - `String` error message listing missing fields otherwise # Examples -```julia +```jldoctest schema = Dict("required" => ["city"]) args = Dict{String,Any}() validateRequiredArgs(args, schema) # => "Missing required arguments: city" @@ -487,7 +476,7 @@ args2 = Dict("city" => "Tokyo") validateRequiredArgs(args2, schema) # => nothing ``` """ -function validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any})::Union{Nothing,String} +function validateRequiredArgs(args::AbstractDict{String, Any}, inputSchema::AbstractDict{String, Any})::Union{Nothing,String} required = get(inputSchema, "required", Any[]) if isempty(required) return nothing @@ -533,7 +522,7 @@ false `toolExecutionStart` events. and converted to an `immediateOutcome` # Examples -```julia +```jldoctest # With validateRequiredArgs hook set on the tool validateToolArguments(toolWithHook, tc) # => validated args or throws @@ -541,7 +530,7 @@ validateToolArguments(toolWithHook, tc) # => validated args or throws validateToolArguments(toolDefault, tc) # => args or throws ``` """ -function validateToolArguments(tool::agentTool, prepared::agentToolCall)::Dict{String,Any} +function validateToolArguments(tool::agentTool, prepared::agentToolCall)::AbstractDict{String, Any} # Use default (2-arg: args + schema) or tool-specific hook (1-arg: args only) if isnothing(tool.validateRequiredArgs) result = validateRequiredArgs(prepared.arguments, tool.inputSchema) diff --git a/src_OLD/OLD_interface.jl b/src_OLD/OLD_interface.jl deleted file mode 100644 index fc74a9f..0000000 --- a/src_OLD/OLD_interface.jl +++ /dev/null @@ -1,1400 +0,0 @@ -module interface - -export addNewMessage, conversation, decisionMaker, reflector, generatechat, - generalconversation, detectWineryName, generateSituationReport - -using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames -using GeneralUtils -using ..type, ..util, ..llmfunction - -# ------------------------------------------------------------------------------------------------ # -# pythoncall setting # -# ------------------------------------------------------------------------------------------------ # -# Ref: https://github.com/JuliaPy/PythonCall.jl/issues/252 -# by setting the following variables, PythonCall.jl will use: -# 1. system's python and packages installed by system (via apt install) -# or 2. conda python and packages installed by conda -# if these setting are not set (comment out), PythonCall will use its own python and packages that -# installed by CondaPkg.jl (from env_preparation.jl) -# ENV["JULIA_CONDAPKG_BACKEND"] = "Null" # set condapkg backend = none -# systemPython = split(read(`which python`, String), "\n")[1] # system's python path -# ENV["JULIA_PYTHONCALL_EXE"] = systemPython # find python location with $> which python ex. raw"/root/conda/bin/python" - -# using PythonCall -# const py_agents = PythonCall.pynew() -# const py_llms = PythonCall.pynew() -# function __init__() -# # PythonCall.pycopy!(py_cv2, pyimport("cv2")) - -# # equivalent to from urllib.request import urlopen in python -# PythonCall.pycopy!(py_agents, pyimport("langchain.agents")) -# PythonCall.pycopy!(py_llms, pyimport("langchain.llms")) -# end - -# ---------------------------------------------- 100 --------------------------------------------- # - - -macro executeStringFunction(functionStr, args...) - # Parse the function string into an expression - func_expr = Meta.parse(functionStr) - - # Create a new function with the parsed expression - function_to_call = eval(Expr(:function, - Expr(:call, func_expr, args...), func_expr.args[2:end]...)) - - # Call the newly created function with the provided arguments - function_to_call(args...) -end - - - -""" Think and choose action - -# Arguments - - `config::T1` - config - - `state::T2` - a game state - -# Keyword Arguments - -# Return - - `thoughtdict::Dict` - -# Example -```jldoctest -julia> result = decisionMaker(agent) - -OrderedDict{String, Any} with 4 entries: - "plan" => "The user provided an image of a sparkling white wine (Asolo Prosecco Bella Principessa from Italy) and requested a search for similar wines in the inventory. According to store guidelines, I must st… - "action_name" => "SEARCH_WINE_DATABASE" - "action_input" => "Sparkling white wine from Italy" - "action_result" => "1) winery: Terrazze dell Etna, wine_name: Rose Brut. -``` -""" -function decisionMaker(a::T; recentevents::Integer=20, maxattempt=3 - ) where {T<:agent} - @info "YiemAgent decisionMaker() start " @__LINE__ - # lessonDict = copy(JSON.parsefile("lesson.json")) - - # lesson = - # if isempty(lessonDict) - # "" - # else - # lessons = Dict{String, Any}() - # for (k, v) in lessonDict - # lessons[k] = lessonDict[k][:lesson] - # end - - # """ - # You have attempted to help the user before and failed, either because your reasoning for the - # recommendation was incorrect or your response did not exactly match the user expectation. - # The following lesson(s) give a plan to avoid failing to help the user in the same way you - # did previously. Use them to improve your strategy to help the user. - - # Here are some lessons in JSON format: - # $(JSON.json(lessons)) - - # When providing the thought and action for the current trial, that into account these failed - # trajectories and make sure not to repeat the same mistakes and incorrect answers. - # """ - # end - - # recentevents_ind = GeneralUtils.recentElementsIndex( - # length(a.memory["events"]), recentevents; includelatest=true) - - requiredKeys = ["plan", "action_name", "action_input"] - context = - """ - - - """ - - # add context to text of the latest message (in the front). - # use for loop because in openai format, each msg may contain both text and image. - for d in a.chathistory[end]["content"] - if d["type"] == "text" - d["text"] = context * d["text"] - break - end - end - errornote = "N/A" - response = nothing # placeholder for show when error msg show up - - """ - { - "model": "your-model.gguf", - "messages": [ ... ], - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "agent_action", - "strict": true, - "schema": { - "type": "object", - "properties": { - "think": { - "type": "string", - "description": "Your step-by-step reasoning process. Explain why you are choosing this action." - }, - "action_name": { - "type": "string", - "enum": ["search_web", "getWeather", "calculate_math"], - "description": "The exact name of the tool to execute." - }, - "action_input": { - "type": "object", - "properties": { - "query": { "type": ["string", "null"], "description": "For search_web" }, - "location": { "type": ["string", "null"], "description": "For getWeather" }, - "equation": { "type": ["string", "null"], "description": "For calculate_math" } - }, - "required": ["query", "location", "equation"], - "additionalProperties": false - } - }, - "required": ["think", "action_name", "action_input"], - "additionalProperties": false - } - } - } - } - """ - - # strict output format - response_format = Dict( - "type"=> "json_schema", - "json_schema"=> Dict( - "name"=> "user_profile", - "strict"=> true, - "schema"=> Dict( - "type"=> "object", - "properties"=> Dict( - "plan"=> Dict("type"=> "string"), - "action_name"=> Dict("type"=> "string"), - "action_input"=> Dict("type"=> "string"), - ), - "required"=> ["plan", "action_name", "action_input"], - "additionalProperties"=> false - ) - ) - ) - - msg = Dict( - "model"=> "gemma-4-E4B-it-UD-Q4_K_XL", - "messages"=> a.chathistory, - "temperature"=> 0.7, - "response_format"=> response_format, - ) - - for attempt in 1:maxattempt - response = a.context.text2textInstructLLM(a.id, msg) - response = GeneralUtils.remove_french_accents(response) - # think, response = GeneralUtils.extractthink(response) - - # dollar sign in Julia means string interpolation - while occursin('$', response) - response = replace(response, '$' => "USD") - end - - # responsedict = nothing - # try - # responsedict = Serde.parse_yaml(response) - # catch e - # println("\nERROR YiemAgent decisionMaker() Error: $e --(not qualify response)-> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - # continue - # end - - # # check whether all answer's key points are in responsedict - # println("\n---") - # println(responsedict) - # println("---\n") - # ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - - # if !ispass - # errornote = errormsg - # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)-> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - # continue - # end - - responsedict = JSON.parse(response) - - if responsedict["action_input"] == "CHAT_BOX" && - occursin("similar", responsedict["action_input"]) - - continue - end - - # if responsedict["action_name"] ∉ ["CHAT_BOX", "SEARCH_WINE_DATABASE", "PRESENT_WINE_GUIDELINE", "END_CONVER_GUIDELINE"] - # errornote = "Your previous attempt didn't use the given functions" - # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)--> $(responsedict["action_name"])", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # continue - # end - - # println("\nYiem decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # pprintln(responsedict) - @info "YiemAgent decisionMaker() end " @__LINE__ - return responsedict - end - - # in case decisionMaker failed, force to use generatechat!() - responsedict = OrderedDict( - "plan"=> "N/A", - "action_name"=> "CHAT_BOX", - "action_input"=> "N/A" - ) - return responsedict -end - - - -""" Assigns a scalar value to each new child node to be used for selec- -tion and backpropagation. This value effectively quantifies the agent's progress in task completion, -serving as a heuristic to steer the search algorithm towards the most promising regions of the tree. - -# Arguments - - `state<:AbstractDict` - one of Yiem's agent - - `text2textInstructLLM::Function` - A function that handles communication to LLM service - -# Return - - `score::Integer` - -# Example -```jldoctest -julia> -``` - -# Signature -""" -function evaluator(a::T1, timeline, decisiondict, evaluateecontext - ) where {T1<:agent} - - systemmsg = - """ - - - You are a master sommelier of an online wine store. - - - - Under your supervision, a trainee sommelier is engaging with a store customer. Each time the customer speaks, the trainee will assess the situation, determine the next course of action, and pause to await your guidance before proceeding. - - - - Improve a trainee sommelier decision based on the store policy and guidelines while ensuring seamless interactions between the trainee and customers. - - - - trajectory: A conversation between your trainee and the customer that have occurred up until now - - evaluatee_context: The context that evaluatee use to make a decision - - evaluatee_decision: The decision made by the evaluatee, consists of the following elements: - "plan" is the trainee's plan - "action_name" is the name of the action taken, which can be one of the available tool name. - "action_input" is the input to the action. - - - - Use only infomation provided by the store policy and guidelines as a bedrocks for your response. - - - - The trainee's plan, action_name, and action_input must be logically consistent - - The trainee's action_input should be in a proper format as specified by the tools. - - The trainee's action name and action input should make sense. For example, if the trainee isn't finished talking, he shouldn't use the END_CONVER_GUIDELINE tool. - - - 1) trajectory_evaluation: Analyze the trajectory of a solution to answer the user's original question. - - Evaluate the correctness of each section and the overall trajectory based on the given question. - - Provide detailed reasoning and analysis, focusing on the latest thought, action, and observation. - - Incomplete trajectory are acceptable if the thoughts and actions up to that point are correct, even if the final answer isn't reached. - - Do not generate additional thoughts or actions. - 2) decision_evaluation: - - Examine how the trainee's decisions align with the store's policies and guidelines before proceeding. - 3) suggestion: Based store policy and guidelines, provide a suggestion for the immediate decision step only. - 4) approval: Can be "yes" or "no". "no" if the suggestion contradict the trainee's decision; otherwise, it is "yes". - - - - { - "trajectory_evaluation": "...", - "decision_evaluation": "...", - "suggestion": "...", - "approval": "...", - } - - - Let's begin! - """ - requiredKeys = [:trajectory_evaluation, :decision_evaluation, :approval, :suggestion] - errornote = "N/A" - - for attempt in 1:10 - evaluateecontext = replace(evaluateecontext, "" => "") - evaluateecontext = replace(evaluateecontext, "" => "") - - context = - """ - - - $timeline - - - $evaluateecontext - - - {plan: $(decisiondict["plan"]), action_name: $(decisiondict["action_name"]), action_input: $(decisiondict["action_input"])} - - P.S. $errornote - - """ - - unformatPrompt = - [ - Dict("name" => "system", "text" => systemmsg), - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(unformatPrompt, a.llmFormatName) - # add info - prompt = prompt * context - - response = a.context.text2textInstructLLM(prompt; senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) - response = GeneralUtils.remove_french_accents(response) - # response = replace(response, '$'=>"USD") - think, response = GeneralUtils.extractthink(response) - - responsedict = nothing - try - responsedict = copy(JSON.parsefile(response)) - catch - println("\nERROR YiemAgent generatechat() failed to parse response: $response", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - # check whether all answer's key points are in responsedict - ispass, errormsg = checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent evaluator() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - # if accepted_as_answer ∉ ["yes", "no"] # [PENDING] add errornote into the prompt - # error("generated accepted_as_answer has wrong format") - # end - - println("\nEvaluator() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - pprintln(Dict(responsedict)) - return responsedict - end - error("Evaluator failed to generate an evaluation, Response: \n$response\n<|End of error|>") -end - -""" Chat with llm. - -# Example userinput - -image_path = "test/large_image.png" -image_bytes = read(image_path) -base64_string = base64encode(image_bytes) - -# 2. Match the MIME type according to your file extension (e.g., png, jpeg) -mime_type = "image/png" -data1_uri = "data:;base64," - -# 3. Construct payload with the Data URI -message => Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Describe this image for me"), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data_uri) - ) - ] - ) - -""" -function conversation(a::sommelier; userinput::Union{Dict{String, Any}, JSON.Object{String, Any}}, - maximumMsg=50, max_think_loop::Integer=3) - - @info "YiemAgent conversation() start " @__LINE__ - userinput = GeneralUtils.dictify(userinput; keytype=String, sort_order=["text"]) - - # find text in usermsg - usertext = nothing - for (i, d) in enumerate(userinput["content"]) - if d["type"] == "text" - d["text"] = GeneralUtils.remove_french_accents(d["text"]) - usertext = d["text"] - end - end - - if usertext == "newtopic" - clearhistory(a) - return "Okay. What shall we talk about?" - else - - # add usermsg to a.chathistory but how do I handle images? - addNewMessage(a, "user", userinput; maximumMsg=maximumMsg) - - # thinking loop until AI wants to communicate with the user - loopcount = 0 - while true - loopcount += 1 - if loopcount > max_think_loop - - thoughtdict, result_raw = generatechat!(a) - assistant_response = Dict{String, Any}( - "role" => "assistant", - "content" => [Dict("type" => "text", "text" => thoughtdict["action_input"]),] - ) - addNewMessage(a, "assistant", assistant_response; maximumMsg=maximumMsg) - - items_info = [] - send_item_ind = [] # index of the item being send to frontend - if haskey(a.memory["shortmem"], "items_info") - for (i, item) in enumerate(a.memory["shortmem"]["items_info"]) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)), item name: $(item["wine_name"]) " @__LINE__ - if haskey(item, "wine_name") && occursin(item["wine_name"], thoughtdict["action_input"]) - push!(items_info, deepcopy(item)) - push!(send_item_ind, i) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - end - # remove sent items - deleteat!(a.memory["shortmem"]["items_info"], send_item_ind) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - - response_to_frontend = Dict{String, Any}( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => thoughtdict["action_input"]), - Dict( - "type" => "items_info", - "items_info" => items_info - ), - ] - ) - - return response_to_frontend - end - - - thoughtdict, result_raw = think(a) - - if thoughtdict["action_name"] ∈ ["CHAT_BOX"] - assistant_response = Dict{String, Any}( - "role" => "assistant", - "content" => [Dict("type" => "text", "text" => thoughtdict["action_input"]),] - ) - addNewMessage(a, "assistant", assistant_response; maximumMsg=maximumMsg) - - items_info = [] - send_item_ind = [] # index of the item being send to frontend - if haskey(a.memory["shortmem"], "items_info") - for (i, item) in enumerate(a.memory["shortmem"]["items_info"]) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)), item name: $(item["wine_name"]) " @__LINE__ - if haskey(item, "wine_name") && occursin(item["wine_name"], thoughtdict["action_input"]) - push!(items_info, deepcopy(item)) - push!(send_item_ind, i) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - end - # remove sent items - deleteat!(a.memory["shortmem"]["items_info"], send_item_ind) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - - response_to_frontend = Dict{String, Any}( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => thoughtdict["action_input"]), - Dict( - "type" => "items_info", - "items_info" => items_info - ), - ] - ) - - """ intended message to send to frontend should have the following format. - response_to_frontend = Dict{String, Any}( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => "assistant_text_response"), - Dict( - "type" => "items_info", - "items_info" => [ - Dict( - "wine_name"=> "wine name 1", - "wine_id"=> "...", - "image"=> base64 encoded image, - ... - ), - Dict( - "wine_name"=> "wine name 2", - "wine_id"=> "...", - "image"=> base64 encoded image, - ... - ), - ] - ), - ] - ) - """ - - - return response_to_frontend - else # still in action - - action_name = thoughtdict["action_name"] - action_input = thoughtdict["action_input"] - - action_call = Dict{String, Any}( - "role" => "action_call", - "content" => [Dict("type" => "text", "text" => "{action_name: $action_name, action_input: $action_input}"),] - ) - - addNewMessage(a, "action_call", action_call; maximumMsg=maximumMsg) - - action_result = thoughtdict["action_result"] - actionresult = Dict{String, Any}( - "role" => "action_result", - "content" => [Dict("type" => "text", "text" => "$action_result"),] - ) - - addNewMessage(a, "actionresult", actionresult; maximumMsg=maximumMsg) - @info "YiemAgent conversation() end think count $loopcount " @__LINE__ - end - end - end -end - - -""" -# Arguments - -# Return - -# Example -```jldoctest -julia> -``` - -""" -function think(a::T)::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - # a.memory[:recap] = generateSituationReport(a, a.context["text"2textInstructLLM]; skiprecent=0) - @info "YiemAgent think() start " @__LINE__ - thoughtdict = decisionMaker(a) - @info "YiemAgent think() 1 " @__LINE__ - @show thoughtdict - println("---\n") - - result_raw = nothing - if thoughtdict["action_name"] ∈ ["CHAT_BOX"] - - # sometime CHAT_BOX input is too short. - # if thoughtdict["action_input] < 20 character, use generatechat!() - if length(thoughtdict["action_input"]) < 20 - thoughtdict, result_raw = generatechat!(a) - else - thoughtdict["action_result"] = "Action result is the next user dialogue." - result_raw = thoughtdict["action_input"] - end - - elseif thoughtdict["action_name"] == "END_CONVER_GUIDELINE" - - thoughtdict, result_raw = end_conversation_guideline!(a, thoughtdict) - - elseif thoughtdict["action_name"] ∈ ["WINE_PRESENTATION_GUIDELINE"] - - thoughtdict, result_raw = wine_presentation_guideline!(a, thoughtdict) - - elseif thoughtdict["action_name"] == "SEARCH_WINE_DATABASE" - - thoughtdict, result_raw = search_wine_database!(a, thoughtdict; useSQLLLM=false) - if result_raw !== nothing && result_raw isa Vector - if haskey(a.memory["shortmem"], "items_info") - append!(a.memory["shortmem"]["items_info"], result_raw) - else - a.memory["shortmem"]["items_info"] = result_raw - end - end - - else - - error("condition is not defined ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - @info "YiemAgent think() end " @__LINE__ - # @show thoughtdict - println("---\n") - return (thoughtdict=thoughtdict, result_raw=result_raw) -end - -function chatbox!(a::T, thoughtdict::AbstractDict - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - thoughtdict["action_result"] = "Action result is the next user dialogue." - return (thoughtdict=thoughtdict, result_raw=nothing) -end - -function end_conversation_guideline!(a::T, thoughtdict::AbstractDict - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - - guideline = - """ - - - Provide customer with store contact info and business hours - - Invite customer to comeback - - Business Hours: everyday 9.00-20.00 - Tel. 0863055790 - - - """ - thoughtdict["action_result"] = guideline - - return (thoughtdict=thoughtdict, result_raw=nothing) -end - -function wine_presentation_guideline!(a::T, thoughtdict::AbstractDict - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - - guideline = - """ - - - Provide detailed introductions of the wines you've found to the user. - - Explain how the wine could match the user's intention and what its effects might mean for the user's experience. - - If multiple wines are available, highlight their differences and provide a comprehensive comparison of how each option aligns with the user's intention and what the potential effects of each option could mean for the user's experience. - - Provide your personal recommendation and provide a brief explanation of why you recommend it. - - People don't describe wine quality level in numbers so use convertion_table if neccessary - - Intensity level: - 1 to 2: May correspond to "light-bodied" or a similar description. - 2 to 3: May correspond to "med light bodied", "medium light" or a similar description. - 3 to 4: May correspond to "medium bodied" or a similar description. - 4 to 5: May correspond to "med full bodied", "medium full" or a similar description. - 4 to 5: May correspond to "full bodied" or a similar description. - Sweetness level: - 1 to 2: May correspond to "dry", "no sweet" or a similar description. - 2 to 3: May correspond to "off dry", "less sweet" or a similar description. - 3 to 4: May correspond to "semi sweet" or a similar description. - 4 to 5: May correspond to "sweet" or a similar description. - 4 to 5: May correspond to "very sweet" or a similar description. - Tannin level: - 1 to 2: May correspond to "low tannin" or a similar description. - 2 to 3: May correspond to "semi low tannin" or a similar description. - 3 to 4: May correspond to "medium tannin" or a similar description. - 4 to 5: May correspond to "semi high tannin" or a similar description. - 4 to 5: May correspond to "high tannin" or a similar description. - Acidity level: - 1 to 2: May correspond to "low acidity" or a similar description. - 2 to 3: May correspond to "semi low acidity" or a similar description. - 3 to 4: May correspond to "medium acidity" or a similar description. - 4 to 5: May correspond to "semi high acidity" or a similar description. - 4 to 5: May correspond to "high acidity" or a similar description. - - - """ - thoughtdict["action_result"] = guideline - - return (thoughtdict=thoughtdict, result_raw=nothing) -end - - -#PENDING -function generatechat!(a::T; maxattempt::Integer=10 - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - @info "YiemAgent generatechat!() start " @__LINE__ - # lessonDict = copy(JSON.parsefile("lesson.json")) - - # lesson = - # if isempty(lessonDict) - # "" - # else - # lessons = Dict{String, Any}() - # for (k, v) in lessonDict - # lessons[k] = lessonDict[k][:lesson] - # end - - # """ - # You have attempted to help the user before and failed, either because your reasoning for the - # recommendation was incorrect or your response did not exactly match the user expectation. - # The following lesson(s) give a plan to avoid failing to help the user in the same way you - # did previously. Use them to improve your strategy to help the user. - - # Here are some lessons in JSON format: - # $(JSON.json(lessons)) - - # When providing the thought and action for the current trial, that into account these failed - # trajectories and make sure not to repeat the same mistakes and incorrect answers. - # """ - # end - - # recentevents_ind = GeneralUtils.recentElementsIndex( - # length(a.memory["events"]), recentevents; includelatest=true) - - systemmsg = - """ - # store_policy - - Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - If you found wines in the store's database, they are in stock. - - You can only recommend wines that are currently in our inventory - - Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences. - - Ask the user one question at a time. - - Do not ask the user about wine's flavor e.g. floral, citrusy, nutty or some thing similar as these terms cannot be used to search the database. - - Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services. - - Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future. - - Spicy foods should be paired only with light red wines. - - We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user immediately if they are looking for these types of wines. Do not sell our wines as such. - - Gift box, gift card, and custom messages are available. Inform the user to contact our sales team. - - # store_guidelines - - Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting. - - Customer may provide images for you to look up. - - Encourage the customer to explore different options and try new things. - - If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead. - - Your store carries only wine. - - Vintage 0 means non-vintage. - - Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently. - - # situation - You are continuing the conversation with the user. - - # your role - Your name is $(a.name). You are a helpful sommelier for website-based $(a.retailername)'s wine store. You are working under your mentor supervision. - - # objective - - Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences. - - Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences. - - # your responsibility includes - - According to the store's policy and guidelines, continuing conversation with the customer using CHAT_BOX action. - - Keep the conversation with the customer going smoothly - - # your responsibility does NOT includes - - Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - - Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - - Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - # you should then respond to the user with interleaving plan, action_name, action_input in JSON format - 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. - 2) "action_name", action_name must be CHAT_BOX. - 3) "action_input", Dialogue you want to chat with the user according to your plan. - After the action is executed you gets "action_result". It is the output from the action you selected. - - # available actions - "CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan. - """ - - system_msg = Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => systemmsg), - ] - ) - - chathistory = deepcopy(a.chathistory[2:end]) # use deep copy because I want to replace system msg - pushfirst!(chathistory, system_msg) - - requiredKeys = ["plan", "action_name", "action_input"] - - errornote = "N/A" - response = nothing # placeholder for show when error msg show up - - for attempt in 1:maxattempt - if attempt > 1 - println("\nYiemAgent generatechat() attempt $attempt/$maxattempt ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - response_format = Dict( - "type"=> "json_schema", - "json_schema"=> Dict( - "name"=> "user_profile", - "strict"=> true, - "schema"=> Dict( - "type"=> "object", - "properties"=> Dict( - "plan"=> Dict( - "type"=> "string", - "description" => "Based on the current situation, state a complete action plan to complete the task and rationale. Be specific.", - ), - "action_name"=> Dict( - "type"=> "string", - "description" => "action_name must be CHAT_BOX", - ), - "action_input"=> Dict( - "type"=> "string", - "description" => "Dialogue you want to chat with the user according to your plan.", - ), - ), - "required"=> ["plan", "action_name", "action_input"], - "additionalProperties"=> false - ) - ) - ) - - msg = Dict( - "model" => "gemma-4-E4B-it-UD-Q4_K_XL", - "messages" => chathistory, - "temperature" => 0.7, - "response_format"=> response_format, - ) - - response = a.context.text2textInstructLLM(a.id, msg) - response = GeneralUtils.clean_json_response(response) - response = GeneralUtils.remove_french_accents(response) - think, response = GeneralUtils.extractthink(response) - - response = strip(response) - - responsedict = nothing - if occursin(requiredKeys[2], response) - try - _responsedict = JSON.parse(response) - responsedict = GeneralUtils.dictify(_responsedict; keytype=String, sort_order=requiredKeys) - catch - println("\nERROR YiemAgent generatechat() failed to parse response: $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - else - println("\nERROR YiemAgent generatechat() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - - # check whether all answer's key points are in responsedict - ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent generatechat() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - # if responsedict["action_name"] ∉ ["CHAT_BOX", "SEARCH_WINE_DATABASE", "PRESENT_WINE_GUIDELINE", "END_CONVER_GUIDELINE"] - # errornote = "Your previous attempt didn't use the given functions" - # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)--> $(responsedict["action_name"])", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # continue - # end - - # println("\nYiem decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # pprintln(responsedict) - responsedict["action_result"] = "Action result is the next user dialogue." - @info "YiemAgent generatechat!() end " @__LINE__ - return (thoughtdict=responsedict, result_raw=responsedict["action_input"]) - end - @info "YiemAgent generatechat() failed to generate a thought " @__LINE__ - error("YiemAgent generatechat() failed to generate a thought ", response) -end - - -function generatequestion(a, text2textInstructLLM::Function, timeline)::String - systemmsg = - """ - Your role: - Your name is $(a.name). You are a helpful English-speaking, website-based sommelier for $(a.retailername)'s online store currently talking with the user. - Your goal includes: - 1) Help the user select the best wines from your inventory that align with the user's preferences - 2) Thanks the user when they don't need any further assistance and invite them to comeback next time - - Your responsibility includes: - 1) From your point of view as a sommelier helping the user, ask yourself multiple questions based on the current situation - - Your responsibility does NOT includes: - 1) Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - 2) Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - 3) Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - At each round of conversation, you will be given the info: - Additional info: ... - Your recent events: latest 5 events of the situation - - You must follow the following guidelines: - - Your question should be specific, self-contained and not require any additional context. - - Once the user has chose their wine, ask the user if they need any further assistance. Do not offer any additional services. If the user doesn't need any further assistance, say goodbye and invite them to come back next time. - - You should follow the following guidelines: - - Focus on the latest conversation - - If the user interrupts, prioritize the user - - If you don't already know, find out the user's budget - - If you don't already know, find out the type of wine the user is looking for, such as red, white, sparkling, rose, dessert, fortified - - If you don't already know, find out the occasion for which the user is buying wine - - If you don't already know, find out the characteristics of wine the user is looking for, such as tannin, sweetness, intensity, acidity - - If you don't already know, find out what food will be served with wine - - If you haven't already, introduce the wines you found in the database to the user first - - Generally speaking, your inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - All wines in your inventory are always in stock. - - Engage in conversation to indirectly investigate the customer's intention, budget and preferences before checking your inventory. - - Do not ask the user about wine's flavor e.g. floral, citrusy, nutty or some thing similar as these terms cannot be used to search the database. - - Once the user has selected their wine, ask the user if they need any further assistance. Do not offer any additional services. If the user doesn't need any further assistance, say goodbye and invite them to come back next time. - - Medium and full-bodied red wines are bad with spicy foods. - - If a customer requests information about discounts, quantity, rewards programs, promotions, delivery options, boxes, gift wrapping, packaging, or personalized messages, please inform them that they can contact our sales team at the store. - - You should then respond to the user with: - 1) Thought: State your thought about the current situation - 2) Q: "Ask yourself" at least three, but no more than five, questions about the situation from your perspective. - 3) A: Given the situation, "answer to yourself" the best you can. Do not generate any extra text after you finish answering all questions - - You must only respond in format as described below: - Q1: ... - A1: ... - Q2: ... - A2: ... - ... - - Here are some examples: - Q: What the user is looking for? - A: The user is asking for a MPV car with 7-seat - Q: What do I know? - A: The user is looking for a car with 7-seat. Our dealer sell these kind of cars - Q: What brands the user prefer? - A: I don't know. The user didn't mentioned that. Let's find out. - Q: What else do I need to know before proceeding? - A: I don't know about the user budget, car's color, and other user's preferences yet. Let's find out more about the user's preferences. - Q: I'm still lacking information regarding the user's preferences for the powertrain. I've asked the user twice already, but perhaps they're not familiar with this. What should I do. - A: I'll proceed without asking the user about the powertrain. - Q: The user is buying for her husband, should I dig in to get more information? - A: Yes, I should. So that I have better idea about the user's preferences. - Q: Why the user saying this? - A: The user does not want an SUV because it does not have sliding doors - Q: The user is asking for a cappuccino. Do I have it at my cafe? - A: No I don't have. - Q: Since I don't have a cappuccino but I have a Late, should I ask if they are okay with that? - A: Yes, I should. - Q: Are they allergic to milk? - A: Since they mentioned a cappuccino before, it seems they are not allergic to milk. - Q: Have I checked the inventory yet? - A: No. I need more information from the user including ... - Q: What else do I need to know? - A: ... - Q: Should I present my item to the user? - A: Not yet, I will need to check my inventory first. - Q: Should I check our inventory now? - A: ... - Q: What the user intend to do with the car? - A: I don't know yet. Let's ask the user. - Q: What do I have in our inventory? - A: ... - Q: Which items are within the user price range? And which items are out of the user price rance? - A: ... - Q: Do I have what the user is looking for in our stock? - A: ... - Q: Am I certain about the information I'm going to share with the user, or should I verify the information first? - A: ... - Q: What should I do? - A: ... - Q: What shouldn't I do? - A: ... - Q: what kind of car suitable for off-road trip? - A: A four-wheel drive SUV is a good choice for off-road trips. - Q: What car specification would satisfy the user's needs? - A: The user is seeking an eco-friendly vehicle that accommodates seven passengers, including seniors and children, with prioritized accessibility and efficient refueling. While electric vehicles (EVs) offer eco-friendly benefits, their long charging times make hybrid models more practical for fast refueling. Additionally, a lower ground level is essential for ease of entry/exit for seniors and children. A hybrid multi-purpose vehicle (MPV) emerges as the optimal solution, balancing sustainability, seating capacity, accessibility, and refueling efficiency. - - Let's begin! - """ - - header = ["Q1:"] - dictkey = ["q1"] - - # context = - # if length(a.memory["shortmem"]["available_wine"]) != 0 - # "Available wines you've found in your inventory so far: $(availableWineToText(a.memory["shortmem"]["available_wine"]))" - # else - # "N/A" - # end - database_search_result = a.memory["shortmem"]["db_search_result"] - - # recent_ind = GeneralUtils.recentElementsIndex(length(a.memory[:events]), recent) - # recentevents = a.memory[:events][recent_ind] - # timeline = createTimeline(recentevents; eventindex=recent_ind) - errornote = "N/A" - response = nothing # store for show when error msg show up - - # recap = - # if length(a.memory[:recap]) <= recent - # "N/A" - # else - # recapkeys = keys(a.memory[:recap]) - # recapkeys_vec = [i for i in recapkeys] - # recapkeys_vec = recapkeys_vec[1:end-recent] - # tempmem = OrderedDict() - # for (k, v) in a.memory[:recap] - # if k ∈ recapkeys_vec - # tempmem[k] = v - # end - # end - - # GeneralUtils.dictToString(tempmem) - # end - - llmkwargs=Dict( - :num_ctx => 32768, - :temperature => 0.5, - ) - - for attempt in 1:10 - if attempt > 1 - println("\nYiemAgent generatequestion() attempt $attempt/10 ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - usermsg = - """ - Additional info: $database_search_result - Your recent events: $timeline - P.S. $errornote - """ - - _prompt = - [ - Dict("name" => "system", "text" => systemmsg), - Dict("name" => "user", "text" => usermsg) - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(_prompt, a.llmFormatName) - - response = text2textInstructLLM(prompt; - modelsize="medium", llmkwargs=llmkwargs, senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) - think, response = GeneralUtils.extractthink(response) - - # make sure generatequestion() don't have wine name that is not from retailer inventory - # check whether an agent recommend wines before checking inventory or recommend wines - # outside its inventory - # ask LLM whether there are any winery mentioned in the response - mentioned_winery = detectWineryName(a, response) - if mentioned_winery != "None" - mentioned_winery = String.(strip.(split(mentioned_winery, ","))) - - # check whether the wine is in event - isWineInEvent = false - for winename in mentioned_winery - for event in a.memory["events"] - if event["observation"] !== nothing && occursin(winename, event["observation"]) - isWineInEvent = true - break - end - end - end - - # if wine is mentioned but not in timeline or shortmem, - # then the agent is not supposed to recommend the wine - if isWineInEvent == false - errornote = "Your previous attempt mentioned wines that are not in your inventory which is not allowed." - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - end - - q_number = count("Q", response) - - # check for valid response - if q_number < 1 - errornote = "Your previous attempt has too few questions." - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - # check whether "A1" is in the response, if not error. - elseif !occursin("A1:", response) - errornote = "Your previous attempt does not have A1:" - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - # check whether response has all header - detected_kw = GeneralUtils.detectKeywordVariation(header, response) - if 0 ∈ values(detected_kw) - errornote = "\nYour previous attempt did not have all points according to the required response format" - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum(values(detected_kw)) > length(header) - errornote = "\nYour previous attempt has duplicated points according to the required response format" - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - response = "Q1: " * responsedict["q1"] - println("\nYiemAgent generatequestion() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - try pprintln(response) catch e println(response) end - - return response - end - error("YiemAgent generatequestion() failed to generate a response ", response) -end - - -function generateSituationReport(a, text2textInstructLLM::Function; skiprecent::Integer=0 - )::OrderedDict - - systemmsg = - """ - You are an assistant being in the given events. - Your task is to writes a summary for each event seperately into an ongoing, interleaving series. - - At each round of conversation, you will be given the situation: - Total events: number of events you need to summarize. - Events timeline: ... - Context: ... - - You should follow the following guidelines: - - Use the word "user" and "assistant" instead of their name in the report - - You should then respond to the user with the following: - Event: a detailed summary for each event without exaggerated details. - - You must only respond in format as described below: - Event_1: ... - Event_2: ... - ... - - Here are some examples: - Event_1: The user ask me about where to buy a toy. - Event_2: I told the user to go to the store at 2nd floor. - - Event_1: The user greets the assistant by saying 'hello'. - Event_2: The assistant respond warmly and inquire about how he can assist the user. - - Let's begin! - """ - - header = ["Event_$i:" for i in eachindex(a.memory["events"])] - dictkey = lowercase.(["Event_$i" for i in eachindex(a.memory["events"])]) - - ind = GeneralUtils.nonRecentElementsIndex(length(a.memory["events"]), skiprecent) - events = a.memory["events"][ind] - timeline = createTimeline(events) - - errornote = "N/A" - response = nothing # store for show when error msg show up - for attempt in 1:10 - if attempt > 1 # use to prevent LLM generate the same respond over and over - println("\nYiemAgent generateSituationReport() attempt $attempt/10 ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - usermsg = """ - Total events: $(length(events)) - Events timeline: $timeline - P.S. $errornote - """ - - _prompt = - [ - Dict("name" => "system", "text" => systemmsg), - Dict("name" => "user", "text" => usermsg) - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(_prompt, "qwen3") - - response = text2textInstructLLM(prompt; senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, "qwen3") - - # check whether response has all header - detected_kw = GeneralUtils.detectKeywordVariation(header, response) - kwvalue = [i for i in values(detected_kw)] - zeroind = findall(x -> x == 0, kwvalue) - missingkeys = [header[i] for i in zeroind] - if 0 ∈ values(detected_kw) - errornote = "$missingkeys are missing in your previous attempt" - println("\nERROR YiemAgent generateSituationReport() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum(values(detected_kw)) > length(header) - errornote = "Your previous response has duplicated events" - println("\nERROR YiemAgent generateSituationReport() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - - println("\ngenerateSituationReport() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - try pprintln(response) catch e println(response) end - - return responsedict - end - error("generateSituationReport failed to generate a response ", response) -end - - -function detectWineryName(a, text) - systemmsg = - """ - You are a sommelier of a wine store. - Your task is to identify and list any winery names mentioned in the provided text. - - At each round of conversation, you will be given the situation: - Text: a text describing the situation. - - Tips: - - Winery usually contains Château, Chateau, Domaine, Côte, Cotes, St. de, or a combination of these words. - - You should then respond to the user with: - Winery_names: A list of winery names mentioned in the text or "None" if no winery name is mentioned. - - You must only respond in format as described below: - Winery_names: ... - - Here are some examples: - Winery_names: Domaine Courbis, Chateau Lafite Rothschild, Matarromera Domaine Roulot, Château, Cotes - - Let's begin! - """ - - header = ["Winery_names:"] - dictkey = ["winery_names"] - - response = nothing # placeholder for show when error msg show up - - for attempt in 1:10 - usermsg = """ - Text: $text - """ - _prompt = - [ - Dict("name" => "system", "text" => systemmsg), - Dict("name" => "user", "text" => usermsg) - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(_prompt, a.llmFormatName) - - response = a.context.text2textInstructLLM(prompt; senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) - think, response = GeneralUtils.extractthink(response) - println("\ndetectWineryName() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - try pprintln(response) catch e println(response) end - - # check whether response has all header - detected_kw = GeneralUtils.detectKeywordVariation(header, response) - missingkeys = [k for (k, v) in detected_kw if v === nothing] - - if !isempty(missingkeys) - errornote = "$missingkeys are missing from your previous response" - println("\nERROR YiemAgent rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum([length(i) for i in values(detected_kw)]) > length(header) - errornote = "\nYour previous attempt has duplicated points according to the required response format" - println("\nERROR YiemAgent rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - - result = responsedict["winery_names"] - - return result - end - error("detectWineryName failed to generate a response") - end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -end # module interface - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src_OLD/OLD_type.jl b/src_OLD/OLD_type.jl deleted file mode 100644 index d554f2a..0000000 --- a/src_OLD/OLD_type.jl +++ /dev/null @@ -1,375 +0,0 @@ -module type - -export agent, sommelier, companion, virtualcustomer, agentcontext - -using Dates, UUIDs, DataStructures, JSON, NATS -using GeneralUtils - -# ---------------------------------------------- 100 --------------------------------------------- # - - -mutable struct agentcontext - text2textInstructLLM::Function - getTextEmbedding::Function - executeSQL::Function - similarSQLVectorDB::Function - insertSQLVectorDB::Function - similarSommelierDecision::Function - insertSommelierDecision::Function - find_related_tables_for_user_question::Function - pg_conn_str::String - agentconfig::AbstractDict -end - -abstract type agent end - -mutable struct sommelier <: agent - name::String # agent name - id::String # agent id - retailername::String - retailerid::String - tools::Dict - maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized - chathistory::Vector{Dict{String, Any}} - memory::Dict{String, Any} - context::agentcontext - llmFormatName::String -end - -""" A sommelier agent. - -# Arguments - - `context::agentcontext` - Application context containing shared functions for LLM, SQL, and vector database operations. - -# Keyword Arguments - - `name::String` - Agent's name. Default: `"Assistant"` - - `id::String` - Agent's ID. Default: generated UUID string. - - `retailername::String` - Retailer name associated with the sommelier. Default: `"retailer_name"` - - `maxHistoryMsg::Integer` - Maximum history messages. Default: `20` - - `chathistory::Vector{Dict{String, String}}` - Chat history. Default: empty vector. - - `llmFormatName::String` - LLM format name. Default: `"granite3"` - -# Return - - `sommelier`: An instantiated sommelier agent. - -# Example -```julia -julia> using YiemAgent -julia> context = agentcontext( - text2textInstructLLM, - getTextEmbedding, - executeSQL, - similarSQLVectorDB, - insertSQLVectorDB, - similarSommelierDecision, - insertSommelierDecision - ) -julia> agent = sommelier(context, name="WineExpert", id="123", retailername="MyWineShop") -``` -""" -function sommelier( - context::agentcontext, # agent functions, db connect and other context - ; - name::String= "Assistant", - id::String= string(uuid4()), - retailername::String= "not specified", - retailerid::String= "not specified", - maxHistoryMsg::Integer= 20, - chathistory::Vector{Dict{String, Any}} = Vector{Dict{String, Any}}(), - llmFormatName::String= "granite3" - ) - - tools = Dict( # update input format - "chatbox"=> Dict( - "description" => "Useful for when you need to ask the user for more context. Do not ask the user their own question.", - "input" => """Input is a text in JSON format.{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}""", - "output" => "" , - ), - "winestock"=> Dict( - "description" => "A handy tool for searching wine in your inventory that match the user preferences.", - "input" => """Input is a JSON-formatted string that contains a detailed and precise search query.{\"wine type\": \"rose\", \"price\": \"max 35\", \"sweetness level\": \"sweet\", \"intensity level\": \"light bodied\", \"Tannin level\": \"low\", \"Acidity level\": \"low\"}""", - "output" => """Output are wines that match the search query in JSON format.""", - ), - ) - - """ Memory - - Chat history use openai format as follow: - - image1_path = "test/large_image.png" --- - image1_bytes = read(image1_path) | this part must be done - image1_base64_string = base64encode(image1_bytes) | in frontend - mime_type = "image/png" | not in agent code - data1_uri = "data:;base64," --- - - chathistory= [ - Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => "You are a helpful assistant"), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => " - LLM context here... - - Do you know this wine? Just give me brief intro." - ), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data1_uri) - ), - ] - ), - ] - - shortmem = Dict( - "1"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."), - "2"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."), - ... - ) - """ - memory = Dict{String, Any}( - "shortmem"=> OrderedDict{String, Any}(), - "scratchpad"=> "", - "recap"=> OrderedDict{String, Any}(), - ) - - newAgent = sommelier( - name, - id, - retailername, - retailerid, - tools, - maxHistoryMsg, - chathistory, - memory, - context, - llmFormatName - ) - systemmsg = - """ - # store_policy - - Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - If you found wines in the store's database, they are in stock. - - You can only recommend wines that are currently in our inventory - - Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences. - - Ask the user one question at a time. - - Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services. - - Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future. - - Spicy foods should be paired only with light red wines. - - We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user imediately if they are looking for these types of wines. Do not sell our wines as such. - - Gift box, gift card, and custom messages are available. Inform the user to contact our sales team. - - # store_guidelines - - Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting. - - Customer may provide images for you to look up. - - Encourage the customer to explore different options and try new things. - - If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead. - - Your store carries only wine. - - Vintage 0 means non-vintage. - - Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently. - - User usually ask for something similar. This means you should use the search term based on the profile they like. - - # situation - You are having conversation with a customer. - - # your role - Your name is $(newAgent.name). You are a helpful sommelier for website-based $(newAgent.retailername)'s wine store. - - # objective - - Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences. - - Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences. - - # your responsibility includes - - According to the store's policy and guidelines, and make an informed decision about what available_actions you need to use to achieve the objective. - - Keep the conversation with the customer going smoothly - - # your responsibility does NOT includes - - Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - - Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - - Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - # you should then respond to the user with interleaving plan, action_name, action_input in JSON format - 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. - 2) "action_name", (Typically corresponds to the execution of the first step in your plan) Can be one of the available_actions name - 3) "action_input", The input to the action you are about to perform according to your plan. - After the action is executed you gets "action_result". It is the output from the action you selected. - - # available actions - "CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan. - "SEARCH_WINE_DATABASE", allows you to search information about wines you want in your inventory's database. The input is strictly supported search term including: retailer_name, wine price, winery, name, vintage, region, country, type of wine, grape varietal, tasting notes, occasion, food pairing, intensity, tannin, sweetness, and acidity. - Example query 1: "Dry, full-bodied red wine from Burgundy, France. Grape varietal could be Merlot or Syrah. price 100 to 1000 USD." - Example query 2: "Red or white wine, medium tannin, price under 700 USD" - Example query 3: "white wine from Tuscany, Italy or Bordeaux, France - "WINE_PRESENTATION_GUIDELINE", which you can use to check the store guidelines about how to present wines you have found to the user. The input is "nothing" keyword. The output is the guidelines that you can follow. - "END_CONVER_GUIDELINE", which you can use to check the store guidelines about how to end the conversation with the user. The input is "nothing" keyword. The output is the guidelines that you can follow. - """ - - system_msg = Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => systemmsg), - ] - ) - - push!(newAgent.chathistory, system_msg) - - return newAgent -end - - -mutable struct virtualcustomer <: agent - name::String # agent name - id::String # agent id - systemmsg::String # system message - tools::Dict - maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized - chathistory::Vector{Dict{String, Any}} - memory::Dict{String, Any} - context # NamedTuple of functions - llmFormatName::String -end - -function virtualcustomer( - context, # NamedTuple of functions - ; - name::String= "Assistant", - id::String= string(uuid4()), - maxHistoryMsg::Integer= 20, - chathistory::Vector{Dict{String, String}} = Vector{Dict{String, String}}(), - llmFormatName::String= "granite3", - systemmsg::String= - """ - Your name: $name - Your sex: Female - Your role: You are a helpful assistant. - You should follow the following guidelines: - - Focus on the latest conversation. - - Your like to be short and concise. - - Let's begin! - """, - ) - - tools = Dict( # update input format - "chatbox"=> Dict( - "description" => "Useful for when you need to ask the user for more context. Do not ask the user their own question.", - "input" => """Input is a text in JSON format.{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}""", - "output" => "" , - ), - ) - - """ Memory - Ref: Chat prompt format is openai - chathistory = [ - Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => system_msg), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Do you know this wine? Just give me brief intro."), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data1_uri) - ) - ] - ) - ] - """ - memory = Dict{String, Any}( - "shortmem"=> OrderedDict{String, Any}( - ), - "scratchpad"=> "", - "events"=> Vector{Dict{String, Any}}(), - "state"=> Dict{String, Any}( - ), - "recap"=> OrderedDict{String, Any}(), - ) - - newAgent = virtualcustomer( - name, - id, - systemmsg, - tools, - maxHistoryMsg, - chathistory, - memory, - context, - llmFormatName - ) - - return newAgent -end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -end # module type \ No newline at end of file diff --git a/src_OLD/llmfunction.jl b/src_OLD/llmfunction.jl deleted file mode 100644 index e7c8903..0000000 --- a/src_OLD/llmfunction.jl +++ /dev/null @@ -1,1925 +0,0 @@ -module llmfunction - -export virtualWineUserChatbox, jsoncorrection, search_wine_database!, # recommendbox, - virtualWineUserRecommendbox, userChatbox, userRecommendbox, extractWineAttributes_1, - extractWineAttributes_2, paraphrase, SQLexecution - -using HTTP, JSON, URIs, Random, PrettyPrinting, UUIDs, Dates, DataFrames, DataStructures, - Base64, LibPQ, NATS -using GeneralUtils, SQLLLM -using ..type, ..util - -# ---------------------------------------------- 100 --------------------------------------------- # - - -""" -Chat with a virtual wine customer for recommendations. - -Formats the input for the configured LLM model and communicates via MQTT -to receive a response tuple containing text, selection, reward, and terminal status. - -# Arguments -- `a::T1`: An agent instance (subtype of `agent`) with `config` containing `externalservice` - and `mqttServerInfo` keys -- `input::T2`: Text to send to the virtual wine customer LLM - -# Returns -- `Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}}`: A tuple of - `(response_text, select, reward, isterminal)` where `select` may be `Nothing` - -# Notes -- Requires `a.config["externalservice"]["virtualWineCustomer_1"]` with `llminfo` and `mqtttopic`. -- Requires `a.config["mqttServerInfo"]` with `broker` and `port`. -- Only supports `llama3instruct` model name (other models throw an error). -- Uses `GeneralUtils.sendReceiveMqttMsg` with a 120-second timeout. - -# TODO -- Add `recommend()` to compare wines -""" -function virtualWineUserRecommendbox(a::T1, input - )::Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}} where {T1<:agent} - - # put in model format - virtualWineCustomer = a.config["externalservice"]["virtualWineCustomer_1"] - llminfo = virtualWineCustomer["llminfo"] - prompt = - if llminfo["name"] == "llama3instruct" - formatLLMtext_llama3instruct("assistant", input) - else - error("llm model name is not defied yet $(@__LINE__)") - end - - # send formatted input to user using GeneralUtils.sendReceiveMqttMsg - msgMeta = GeneralUtils.generate_msgMeta( - virtualWineCustomer["mqtttopic"], - senderName= "virtualWineUserRecommendbox", - senderId= a.id, - receiverName= "virtualWineCustomer", - mqttBroker= a.config["mqttServerInfo"]["broker"], - mqttBrokerPort= a.config["mqttServerInfo"]["port"], - msgId = "dummyid" #CHANGE remove after testing finished - ) - - outgoingMsg = Dict( - "msgMeta"=> msgMeta, - "payload"=> Dict( - "text"=> prompt, - ) - ) - - result = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; timeout=120) - response = result["response"] - - return (response["text"], response["select"], response["reward"], response["isterminal"]) -end - - - -""" -Chatbox for conversing with a virtual wine customer AI. - -Formats the chat history with a system prompt, sends it via MQTT to a text2text instruct -LLM service, and parses the JSON response. Retries up to 5 times on failure. - -# Arguments -- `config::T1`: Configuration dictionary (subtype of `AbstractDict`) containing: - - `externalservice["text2text"]["mqtttopic"]`: MQTT topic for the LLM service - - `mqttServerInfo["broker"]`: MQTT broker address - - `mqttServerInfo["port"]`: MQTT broker port -- `input::T2`: Current sommelier message text (subtype of `AbstractString`) -- `virtualCustomerChatHistory`: Chat history vector of dictionaries with `"name"` and `"text"` keys - -# Returns -- `Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}}`: A tuple of - `(text, select, reward, isterminal)` representing the virtual customer's response - -# Notes -- The system prompt defines the virtual customer's persona and response format. -- Chat history role names are transformed: "user" → "you", "assistant" → "sommelier". -- Uses `jsoncorrection` to fix malformed LLM JSON responses. -- Retries up to 5 times on error before throwing. -- Uses `formatLLMtext` with `"llama3instruct"` format. - -# Examples -```jldoctest -julia> result = YiemAgent.virtualWineUserChatbox(config, sommelier_msg, history) -("I'd like something under $50", nothing, 0, false) -``` -""" -function virtualWineUserChatbox(config::T1, input::T2, virtualCustomerChatHistory - )::Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}} where {T1<:AbstractDict, T2<:AbstractString} - - previouswines = - """ - You have the following wines previously: - - """ - - systemmsg = - """ - You find yourself in a well-stocked wine store, engaged in a conversation with the store's knowledgeable sommelier. - You're on a quest to find a bottle of wine that aligns with your specific preferences and requirements. - - The ideal wine you're seeking should meet the following criteria: - 1. It should fit within your budget. - 2. It should be suitable for the occasion you're planning. - 3. It should pair well with the food you intend to serve. - 4. It should be of a particular type of wine you prefer. - 5. It should possess certain characteristics, including: - - The level of sweetness. - - The intensity of its flavor. - - The amount of tannin it contains. - - Its acidity level. - - Here's the criteria details: - { - "budget": 50, - "occasion": "graduation ceremony", - "food pairing": "Thai food", - "type of wine": "red", - "wine sweetness level": "dry", - "wine intensity level": "full-bodied", - "wine tannin level": "low", - "wine acidity level": "medium", - } - - You should only respond with "text", "select", "reward", "isterminal" steps. - "text" is your conversation. - "select" is an integer. Choose an option when presented with choices, or leave it null if none of the options satisfy you or if no choices are available. - "reward" is an integer, it can be three number: - 1) 1 if you find the right wine. - 2) 0 if you don’t find the ideal wine. - 3) -1 if you’re dissatisfied with the sommelier’s response. - "isterminal" can be false if you still want to talk with the sommelier, true otherwise. - - You should only respond in JSON format as describe below: - { - "text": "your conversation", - "select": null, - "reward": 0, - "isterminal": false - } - - Here are some examples: - - sommelier: "What's your budget? - you: - { - "text": "My budget is 30 USD.", - "select": null, - "reward": 0, - "isterminal": false - } - - sommelier: "The first option is Zena Crown and the second one is Buano Red." - you: - { - "text": "I like the 2nd option.", - "select": 2, - "reward": 1, - "isterminal": true - } - - Let's begin! - """ - -pushfirst!(virtualCustomerChatHistory, Dict("name"=> "system", "text"=> systemmsg)) - - # replace the :user key in chathistory to allow the virtual wine customer AI roleplay - chathistory::Vector{Dict{String, Any}} = Vector{Dict{String, Any}}() - for i in virtualCustomerChatHistory - newdict = Dict() - newdict["name"] = - if i["name"] == "user" - "you" - elseif i["name"] == "assistant" - "sommelier" - else - i["name"] - end - - newdict["text"] = i["text"] - push!(chathistory, newdict) - end - - push!(chathistory, Dict("name"=> "assistant", "text"=> input)) - - # put in model format - prompt = formatLLMtext(chathistory, "llama3instruct") - prompt *= - """ - <|start_header_id|>you<|end_header_id|> - {"text" - """ - - pprint(prompt) - externalService = config["externalservice"]["text2textinstruct"] - - # send formatted input to user using GeneralUtils.sendReceiveMqttMsg - msgMeta = GeneralUtils.generate_msgMeta( - externalService["mqtttopic"], - senderName= "virtualWineUserChatbox", - senderId= string(uuid4()), - receiverName= "text2textinstruct", - mqttBroker= config["mqttServerInfo"]["broker"], - mqttBrokerPort= config["mqttServerInfo"]["port"], - msgId = string(uuid4()) # remove after testing finished - ) - - outgoingMsg = Dict( - "msgMeta"=> msgMeta, - "payload"=> Dict( - "text"=> prompt, - ) - ) - - attempt = 0 - for attempt in 1:5 - try - response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; timeout=120) - _responseJsonStr = response["response"]["text"] - expectedJsonExample = - """ - Here is an expected JSON format: - { - "text": "...", - "select": "...", - "reward": "...", - "isterminal": "..." - } - """ - responseJsonStr = jsoncorrection(config, _responseJsonStr, expectedJsonExample) - responseDict = copy(JSON.parsefile(responseJsonStr)) - - text::AbstractString = responseDict["text"] - select::Union{Nothing, Number} = responseDict["select"] == "null" ? nothing : responseDict["select"] - reward::Number = responseDict["reward"] - isterminal::Bool = responseDict["isterminal"] - - if text != "" - # pass test - else - error("virtual customer not answer correctly") - end - - return (text, select, reward, isterminal) - catch e - io = IOBuffer() - showerror(io, e) - errorMsg = String(take!(io)) - st = sprint((io, v) -> show(io, "text/plain", v), stacktrace(catch_backtrace())) - println("") - @warn "Error occurred: $errorMsg\n$st" - println("") - end - end - error("virtualWineUserChatbox failed to get a response") -end - -""" -Search for wines in stock. - -Executes a wine search via SQL (either through SQLLLM or direct query), optionally fetching -and base64-encoding bottle images for each result. - -# Arguments -- `a::T`: An agent instance (subtype of `agent`) with context containing `executeSQL`, - `pg_conn_str`, and `agentconfig` -- `thoughtdict::AbstractDict`: A dictionary containing `action_input` (the search query string) - -# Keyword Arguments -- `useSQLLLM::Bool=false`: Whether to use SQLLLM for the query instead of direct SQL generation - -# Returns -- `NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}}`: A named tuple with: - - `thoughtdict`: The input thoughtdict with `action_result` populated - - `result_raw`: Vector of wine dictionaries with image data, or `nothing` - -# Notes -- When `useSQLLLM=false`, performs hard SQL filtering followed by vector search. -- Fetches bottle images from `http://192.168.88.106:8080/` and encodes as base64. -- Uses `wine_search_term_classification` to extract search conditions. -- Requires PostgreSQL connection info from `a.context.agentconfig["externalservice"]["sommpanion_db"]`. - -# Examples -```jldoctest -julia> thoughtdict = OrderedDict("action_input" => "red wine under 50"); -julia> result = YiemAgent.search_wine_database!(agent, thoughtdict) -(thoughtdict=OrderedDict{String, Any}(...), result_raw=[Dict(...), ...]) -``` -""" -function search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool=false - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - - println("\ncheckinventory order: $(thoughtdict["action_input"]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - if useSQLLLM - # add suppport for similarSQLVectorDB - textresult, result_raw = SQLLLM.query( - inventoryquery, - a.context.executeSQL, - a.context.text2textInstructLLM; - insertSQLVectorDB=a.context.insertSQLVectorDB, - similarSQLVectorDB=a.context.similarSQLVectorDB, - llmFormatName="qwen3") - thoughtdict["action_result"] = textresult - else - - # direct query with possible sql instead of SQLLLM. - hard_conditions, vector_search = wine_search_term_classification(a, thoughtdict["action_input"]) - - # do hard filter - # sql = generatesql(a, inventoryquery) - sql = predefined_wine_search_sql(hard_conditions) - @info "\nsql: $sql, \nvector_search: $vector_search" - textresult, sql_result_df, success, _ = SQLexecution(a.context.executeSQL, sql) - - # do vector search - vector_search_str = "" - for i in vector_search - vector_search_str = vector_search_str * " " * i["value"] - end - vector_search_str = String(strip(vector_search_str)) - vector_search_str = GeneralUtils.removestring(vector_search_str, ["%"]) - @show vector_search - @show vector_search_str - - - config = a.context.agentconfig - host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':') - port = parse(Int, _port) - dbname = "winedb" - user = config["externalservice"]["sommpanion_db"]["user"] - password = config["externalservice"]["sommpanion_db"]["password"] - pg_conn_str = "host=$host_url port=$port dbname=$dbname user=$user password=$password" - - #WORKING - # df = GeneralUtils.find_text_vector_similarity( - # vector_search_str, - # "wine", - # "tasting_notes_embedding", - # GeneralUtils.execute_postgres_sql(pg_conn_str, sql), #BUG input pair (F, arg) - # a.context.getTextEmbedding([vector_search_str]) #BUG input pair (F, arg) - # ) - - - # @show df - # error(888888) - - - items = nothing - if sql_result_df !== nothing - result_vec = GeneralUtils.dfToVectorDict(sql_result_df) - - # get image - for d in result_vec - image_url_json_str = d["image_url"] - image_url_json_obj = JSON.parse(image_url_json_str) - base_url = "http://192.168.88.106:8080/" - if haskey(image_url_json_obj, "bottle") - url = base_url * image_url_json_obj["bottle"] - image_data = HTTP.get(url) # vector{int} data - image_base64_string = base64encode(image_data.body) - d["image"] = image_base64_string - else - d["image"] = nothing - end - end - items = result_vec # image is added to each item - end - - thoughtdict["action_result"] = textresult - end - - return (thoughtdict=thoughtdict, result_raw=items) -end - - -""" -Generate an SQL query from a natural language search term. - -Uses an LLM to generate SQL based on the database schema relevant to the search term, -with validation and retry logic. - -# Arguments -- `a::T`: An agent instance (subtype of `agent`) with context containing: - - `find_related_tables_for_user_question`: Function to find relevant database tables - - `pg_conn_str`: PostgreSQL connection string - - `text2textInstructLLM`: LLM function for text generation -- `searchterm::String`: Natural language search term to convert to SQL - -# Keyword Arguments -- `maxattempt::Int=10`: Maximum number of attempts to get a valid SQL response from the LLM - -# Returns -- `String`: A valid SQL query string - -# Notes -- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model for SQL generation. -- Dynamically fetches only relevant table schemas for the search term. -- Validates LLM response contains required keys: "plan", "action_name", "action_input". -- `action_name` must be "RUNSQL"; `action_input` must not contain "RUNSQL". -- Strips triple backticks and extracts SQL from fenced code blocks. -- Throws on failure after `maxattempt` retries. - -# Examples -```jldoctest -julia> sql = YiemAgent.generatesql(agent, "red wine under 50") -"SELECT ... WHERE w.wine_type = 'red' AND rw.price < 50;" -``` -""" -function generatesql(a::T, searchterm::String, - ; maxattempt=10 - )::String where {T<:agent} - - systemmsg = - """ - # database_search_guidelines - - Keep SQL queries focused only on the provided information. - - Use wildcard character (%) to search more effectively. - - Do not create any table in the database. - - A junction table can be used to link tables together. Another use case is for filtering data. - - If you can't find a single table that can be used to answer the user's search term, try joining multiple tables to see if you can obtain the answer. - - Text information in the database usually stored in lower case. If your search returns empty, try using lower case to search. - - Overly strict condition usually yields empth result - - # situation - At each round of conversation, you will be given the following: - - user search term - - # objective - Consult the database_search_guidelines. Then find the data from a database to satisfy the user's search term. - - # your responsibility includes - Fulfill the objective. - - # you should then respond to the user with interleaving plan, action_name, action_input - 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. - 2) "action_name", Must be "RUNSQL" - 3) "action_input", The input to the action you are about to perform according to your plan. - After the action is executed you gets "action_result". It is the output from the action you selected. - - # you should only respond in JSON format as described below - "plan": "...", - "action_name": "...", - "action_input": "..." - - # available_actions - "RUNSQL", which you can use to execute SQL against the database. - The input must be a single SQL query to be executed against the database. - For more effective text search, it's necessary to use case-insensitivity and the ILIKE operator. - Do not wrap the SQL as it will be executed against the database directly and SQL must be ended with ';'. - """ - - # table_schema = - # """ - # create table customer ( - # customer_id uuid primary key default gen_random_uuid (), - # customer_firstname varchar(128), - # customer_lastname varchar(128), - # customer_displayname varchar(128) not null, - # customer_username varchar(128), - # customer_password varchar(128), - # customer_gender varchar(128), - # country varchar(128), - # telephone varchar(128), - # email varchar(128) not null, - # customer_birthdate varchar(128), - # note text, - - # other_attributes jsonb, - # created_time timestamptz default current_timestamp, - # updated_time timestamptz default current_timestamp, - # description text - # ); - - # create table retailer ( - # retailer_id uuid primary key default gen_random_uuid (), - # retailer_name varchar(128) not null, - # retailer_username varchar(128) not null, - # retailer_password varchar(128) not null, - # retailer_address text not null, - # country varchar(128) not null, - # contact_person varchar(128) not null, - # telephone varchar(128) not null, - # email varchar(128) not null, - # note text, - - # other_attributes jsonb, - # created_time timestamptz default current_timestamp, - # updated_time timestamptz default current_timestamp, - # description text - # ); - - # create table food ( - # food_id uuid primary key default gen_random_uuid (), - # food_name varchar(128) not null, - # country varchar(128), - # spiciness integer, - # sweetness integer, - # sourness integer, - # savoriness integer, - # bitterness integer, - # serving_temperature integer, - # image_url jsonb, - # note text, - # other_attributes jsonb, - - # created_time timestamptz default current_timestamp, - # updated_time timestamptz default current_timestamp, - # description text - # ); - - # create table wine ( - # wine_id uuid primary key default gen_random_uuid (), - # seo_name varchar(128) not null, - # wine_name varchar(128) not null, - # winery varchar(128) not null, - # vintage integer not null, - # region varchar(128) not null, - # country varchar(128) not null, - # wine_type varchar(128) not null, - # grape varchar(128) not null, - # serving_temperature varchar(128) not null, - # intensity integer, - # sweetness integer, - # tannin integer, - # acidity integer, - # fizziness integer, - # tasting_notes text, - # image_url jsonb, - # manufacturer_sku text, - # note text, - # other_attributes jsonb, - - # created_time timestamptz default current_timestamp, - # updated_time timestamptz default current_timestamp, - # description text - # ); - - # create table wine_food ( - # wine_id uuid references wine(wine_id), - # food_id uuid references food(food_id), - # constraint wine_food_id primary key (wine_id, food_id), - - # created_time timestamptz default current_timestamp, - # updated_time timestamptz default current_timestamp - # ); - - # CREATE TABLE retailer_wine ( - # retailer_id uuid references retailer(retailer_id), - # wine_id uuid references wine(wine_id), - # constraint retailer_wine_id primary key (retailer_id, wine_id), - # price NUMERIC(10, 2), - # currency varchar(3) not null, - - # created_time timestamptz default current_timestamp, - # updated_time timestamptz default current_timestamp - # ); - - # CREATE TABLE retailer_food ( - # retailer_id uuid references retailer(retailer_id), - # food_id uuid references food(food_id), - # constraint retailer_food_id primary key (retailer_id, food_id), - # price NUMERIC(10, 2), - # currency varchar(3) not null, - - # created_time timestamptz default current_timestamp, - # updated_time timestamptz default current_timestamp - # ); - # """ - - requiredKeys = ["plan", "action_name", "action_input"] - errornote = "" - # provide similar sql only for the first attempt - # sql, distance = a.context.similarSQLVectorDB(searchterm) - - # similarSQL_ = sql !== nothing ? sql : "None" - # # if sql is really close, just use it - # if similarSQL_ != "None" && distance <= 0.1 - # return similarSQL_ - # end - - #CHANGE use find_related_tables_for_user_question and inject only related table schema instead - # of hard code table schema. CPU embedding is too slow. use embedding service on GPU. - related_tables = a.context.find_related_tables_for_user_question(searchterm) - table_schema = "" - for table in related_tables - _table_schema_str = GeneralUtils.get_db_table_schema_simple(a.context.pg_conn_str, table) - table_schema_str = sprint(show, _table_schema_str) * "\n" - table_schema = table_schema * table_schema_str - end - - context = - """ - - - $table_schema - - - """ - input = context * searchterm - - msg = Dict( - "model" => "gemma-4-E4B-it-UD-Q4_K_XL", - "messages" => [ - Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => systemmsg), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => input), - ] - ), - ], - "temperature" => 0.7 - ) - - for attempt in 1:maxattempt - response = a.context.text2textInstructLLM("random_id", msg) - - response = GeneralUtils.clean_json_response(response) - - think, response = GeneralUtils.extractthink(response) - responsedict = nothing - try - _responsedict = JSON.parse(response) - responsedict = GeneralUtils.dictify(_responsedict, keytype=String, sort_order=requiredKeys) - catch - println("\nERROR decisionMaker() failed to parse response: $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - # check whether all answer's key points are in responsedict - ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - # remove backticks Error occurred: MethodError: no method matching occursin(::String, ::Vector{String}) - if occursin("```", responsedict["action_input"]) - sql = GeneralUtils.extract_triple_backtick_text(responsedict["action_input"])[1] - if sql[1:4] == "sql\n" - sql = sql[5:end] - end - sql = split(sql, ';') # some time there are comments in the sql - sql = sql[1] * ';' - - responsedict["action_input"] = sql - end - - toollist = ["RUNSQL"] - if responsedict["action_name"] ∉ toollist - errornote = "Your previous attempt has action_name that is not in the tool list" - println("\nERROR SQLLLM decisionMaker(). Attempt $attempt/$maxattempt. $errornote --(not qualify response)--> $(responsedict["action_name"]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - for i in toollist - if occursin(i, responsedict["action_input"]) - errornote = "Your previous attempt has action_name in action_input which is not allowed" - println("\nERROR SQLLLM decisionMaker(). Attempt $attempt/$maxattempt. $errornote --(not qualify response)--> $(responsedict["action_input"]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - end - - # println("\nSQLLLM decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # pprintln(responsedict) - # println("---") - - return responsedict["action_input"] - end - error("SQLLLM DecisionMaker() failed to generate a thought \n", response) -end - - -""" -# Example -```jldoctest -julia> using ChatAgent -julia> agent = YiemAgent.sommelier(...) -julia> thoughtdict = - OrderedDict{String, Any}( - "plan" => "The user is asking a very specific question about a wine (Brunello di Montalcino from Tenuta CastelGiocondo). Although the policy suggests gathering budget, wine type, and occasion, the user has provided enough specific information (name, region, producer) to attempt a direct search in the database. I will use the SEARCH_WINE_DATABASE action to check if this specific wine is in our inventory.", - "action_name" => "SEARCH_WINE_DATABASE", - "action_input" => "Brunello di Montalcino from Tenuta CastelGiocondo") -``` -julia> predefined_wine_search_sql(agent, thoughtdict["action_input"]) -""" -""" -Classify a wine search term into hard SQL conditions and vector search words. - -Uses an LLM to extract database column conditions from a natural language search term, -then classifies them into hard conditions (for SQL WHERE clauses) and vector search -entries (for approximate matching). - -# Arguments -- `a::T`: An agent instance (subtype of `agent`) with context containing: - - `find_related_tables_for_user_question`: Function to find relevant tables - - `pg_conn_str`: PostgreSQL connection string - - `text2textInstructLLM`: LLM function for text generation -- `searchterm::String`: Natural language search term to classify - -# Keyword Arguments -- `maxattempt::Int=10`: Maximum number of attempts to get a valid classification from the LLM - -# Returns -- `NamedTuple{(:hard_conditions, :vector_search), Tuple{Vector{JSON.Object{String, Any}}, Vector{JSON.Object{String, Any}}}}`: - - `hard_conditions`: Entries with standard operators (=, <>, !=, >, <, >=, <=) for SQL WHERE clauses - - `vector_search`: Entries with non-standard operators (LIKE, IN, IS NULL, etc.) for vector search - -# Notes -- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model with JSON schema response format. -- Applies fuzzy correction for "fuzzy_correction" bucket columns via `resolve_entity`. -- Uses `classify_column` to determine the column type bucket. -- Uses `harvest_entity_catalog` and `resolve_entity` (threshold=0.9) for fuzzy matching. - -# Examples -```jldoctest -julia> result = YiemAgent.wine_search_term_classification(agent, "dry red wine under 30") -(hard_conditions=[...], vector_search=[...]) -``` -""" -function wine_search_term_classification(a::T, searchterm::String, - ; maxattempt=10 - ) where {T<:agent} - - systemmsg = - """ - - # situation - At each round of conversation, you will be given the following: - - user search term - - database tables schema - - # objective - Consult the provided database schema (tables and columns), please map a user's natural-language search term to the appropriate database columns and tables—identify the relevant fields, operators, and values (e.g., for SQL filtering). - - # your responsibility includes - Fulfill the objective. - - # You must output your response as a JSON object containing a single key: "extracted_info". - The "extracted_info" key must contain an array of objects. Each object must contain: - 1) "table_name": The name of the table. - 2) "column_name": The specific column being filtered. - 3) "operator": The comparison operator (e.g., "=", ">"). - 4) "value": The value to compare against. - - If the user does not specify any filters, return an empty array for "extracted_info": {"extracted_info": []}. - - # here are some example - - 4-wheel drive car with red color that will give me fast and furious emotion. No more than 7000 USD - - - { - "extracted_info": [ - { - "table_name": "car_info", - "column_name": "drive_type", - "operator": "=", - "value": "4-wheel" - }, - { - "table_name": "car_info", - "column_name": "color", - "operator": "=", - "value": "red" - }, - { - "table_name": "car_info", - "column_name": "drive_feeling", - "operator": "=", - "value": "fast and furious" - }, - { - "table_name": "price_list", - "column_name": "price", - "operator": "<", - "value": "7000" - } - } - - """ - - - # use find_related_tables_for_user_question and inject only related table schema for a given search term - # to LLM instead of giving LLM all tables schema. - related_tables = a.context.find_related_tables_for_user_question(searchterm) - table_schema = "" - for table in related_tables - _table_schema_str = get_db_table_schema_simple_with_samples(a.context.pg_conn_str, table) - - # _table_schema_str = GeneralUtils.get_db_table_schema_simple(a.context.pg_conn_str, table) - table_schema_str = sprint(show, _table_schema_str) * "\n" - table_schema = table_schema * table_schema_str - end - - context = - """ - - - $table_schema - - - """ - input = context * searchterm - - response_format = Dict( - "type" => "json_schema", - "json_schema" => Dict( - "name" => "extracted_conditions", - "strict" => true, - "schema" => Dict( - "type" => "object", - "properties" => Dict( - "extracted_info" => Dict( - "type" => "array", - "items" => Dict( - "type" => "object", - "properties" => Dict( - "table_name" => Dict( - "type" => "string", - "description" => "The name of the database table." - ), - "column_name" => Dict( - "type" => "string", - "description" => "The name of the column to filter on." - ), - "operator" => Dict( - "type" => "string", - "enum" => ["=", "!=", ">", "<", ">=", "<=", "LIKE", "IN", "IS NULL", "IS NOT NULL"], - "description" => "The SQL comparison operator." - ), - "value" => Dict( - "type" => ["string", "null"], - "description" => "The value to compare against. Use null for IS NULL/IS NOT NULL." - ) - ), - "required" => ["table_name", "column_name", "operator", "value"], - "additionalProperties" => false - ) - ) - ), - "required" => ["extracted_info"], - "additionalProperties" => false - ) - ) -) - - msg = Dict( - "model" => "gemma-4-E4B-it-UD-Q4_K_XL", - "messages" => [ - Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => systemmsg), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => input), - ] - ), - ], - "temperature" => 0.7, - "response_format"=> response_format, - ) - - for attempt in 1:maxattempt - response = a.context.text2textInstructLLM("random_id", msg) - responsedict = JSON.parse(response) - # responsedict = nothing - # try - # responsedict = Serde.parse_yaml(response) - # catch e - # println("\nERROR YiemAgent predefined_wine_search_sql() Error: $e --(not qualify response)-> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - # continue - # end - - # println("\n ", table_schema) - println("\n ", responsedict) - @info "before BM25 " @__LINE__ - - # to ensure user input is correct - for entry in responsedict["extracted_info"] - table_name = entry["table_name"]::String - column_name = entry["column_name"]::String - - bucket = classify_column(a.context.pg_conn_str, table_name, column_name) - - if bucket == "fuzzy_correction" - words_catalog = GeneralUtils.harvest_entity_catalog(a.context.pg_conn_str, table_name, column_name) - resolved_word = GeneralUtils.resolve_entity(entry["value"], words_catalog; threshold=0.9) - entry["value"] = resolved_word - end - end - - # filter for column that will be used for hard condition (SQL where clause) - # column with non-standard operator will be used in vector search - vector_search_words = "" - hard_operators = ["=","<>","!=",">","<",">=","<=","!<","!>","<=>"] - - # Build new list of hard condition entries - hard_conditions = JSON.Object{String, Any}[] - vector_search = JSON.Object{String, Any}[] - for entry in responsedict["extracted_info"] - if entry["operator"] ∈ hard_operators - push!(hard_conditions, entry) - else - push!(vector_search, entry) - end - end - responsedict = hard_conditions - - println("") - @show responsedict - @info "predefined_wine_search_sql() " @__LINE__ - - return (hard_conditions=hard_conditions, vector_search=vector_search) - end - error("SQLLLM DecisionMaker() failed to generate a thought \n", response) -end - -""" -Build a SQL query from pre-classified wine search conditions. - -Constructs a JOIN query across `wine`, `retailer_wine`, and `retailer` tables with -dynamic WHERE clauses based on the provided conditions. - -# Arguments -- `conditions::Vector{JSON.Object{String, Any}}`: Vector of condition objects, each containing: - - `table_name`: One of "wine", "retailer_wine" (other tables are skipped) - - `column_name`: The column to filter on - - `operator`: SQL comparison operator (=, <>, !=, >, <, >=, <=) - - `value`: The value to compare against (number or string) - -# Returns -- `String`: A complete SQL query with WHERE clause - -# Notes -- Supports table aliases: "wine" → "w", "retailer_wine" → "rw" -- Automatically handles numeric vs string value types in SQL formatting -- Strings are single-quote escaped (replaces "'" with "''") -- Returns a query with no WHERE clause if conditions vector is empty - -# Examples -```jldoctest -julia> cond = [JSON.Object{String, Any}("table_name"=>"wine", "column_name"=>"wine_type", "operator"=>"=", "value"=>"red")]; -julia> YiemAgent.predefined_wine_search_sql(cond) -"SELECT ... FROM wine AS w JOIN ... WHERE w.wine_type = 'red';" -``` -""" -function predefined_wine_search_sql(conditions::Vector{JSON.Object{String, Any}})::String - # 1. Base SQL structure - base_query = -""" -SELECT - w.winery, - w.wine_name, - w.wine_id, - w.vintage, - w.region, - w.country, - w.wine_type, - w.grape, - w.serving_temperature, - w.sweetness, - w.intensity, - w.tannin, - w.acidity, - w.tasting_notes, - rw.price, - rw.currency, - w.image_url, - r.retailer_name, - rw.retailer_id -FROM wine AS w -JOIN retailer_wine AS rw ON w.wine_id = rw.wine_id -JOIN retailer AS r ON rw.retailer_id = r.retailer_id -""" - - # 2. Dynamic WHERE Clause Builder - where_clauses = String[] - - # Iterate over each condition object in the array - for cond in conditions - table_name = String(cond["table_name"]) - column_name = String(cond["column_name"]) - op = String(cond["operator"]) - raw_val = cond["value"] - - # Determine table alias - alias = if table_name == "wine" - "w" - elseif table_name == "retailer_wine" - "rw" - else - continue - end - - # --- Value Type Handling --- - final_val = raw_val - - if op in ("=", "<", ">", "<=", ">=") - str_val = string(raw_val) - num_val = tryparse(Float64, str_val) - - if !isnothing(num_val) - final_val = isinteger(num_val) ? round(Int, num_val) : num_val - end - end - - # --- SQL Formatting --- - if isa(final_val, Number) - clause = "$(alias).$(column_name) $(op) $(final_val)" - else - escaped_val = replace(string(final_val), "'" => "''") - clause = "$(alias).$(column_name) $(op) '$(escaped_val)'" - end - - push!(where_clauses, clause) - end - - # 3. Assemble Final Query - where_sql = isempty(where_clauses) ? "" : "WHERE " * join(where_clauses, " AND ") - - return string(base_query, where_sql, ";") -end - -""" -Execute a SQL query against the database and return formatted results. - -Adds `ORDER BY RANDOM() LIMIT 2` for non-LIMITed queries, removes `DISTINCT`, and returns -either a formatted string result or the DataFrame. - -# Arguments -- `executeSQL::Function`: A function that executes SQL and returns results (e.g., PostgreSQL connection) -- `sql::T`: The SQL query string to execute (subtype of `AbstractString`) - -# Returns -- `NamedTuple{(:result_str, :result_raw, :success, :errormsg)}`: A named tuple with: - - `result_str::Union{String, Nothing}`: Formatted string representation of results - - `result_raw::Union{DataFrame, Nothing}`: The DataFrame result, or `nothing` - - `success::Bool`: Whether the query executed successfully - - `errormsg::Union{String, Nothing}`: Error message if failed, or `nothing` - -# Notes -- Removes `DISTINCT` keyword before execution (incompatible with `RANDOM()`) -- Appends `ORDER BY RANDOM() LIMIT 2` if query doesn't have `LIMIT` and ends with `;` -- Returns "No records found" message if zero rows -- Returns column count warning if more than 30 columns -- Randomly samples 2 rows if result has more than 2 rows - -# Examples -```jldoctest -julia> result = YiemAgent.SQLexecution(execute_fn, "SELECT * FROM wine;") -(result_str="...", result_raw=DataFrame(...), success=true, errormsg=nothing) -``` -""" -function SQLexecution(executeSQL::Function, sql::T - )::NamedTuple where {T<:AbstractString} - - try - # add LIMIT to the SQL to prevent loading large data - sql = strip(sql) - - # remove DISTINCT keyword because it is incompatible with RANDOM() - sql = replace(sql, "DISTINCT" => "") - - if sql[end] == ';' - if !occursin("LIMIT", sql) - sql = sql[1:end-1] * " ORDER BY RANDOM() LIMIT 2;" - end - else - sql = sql * ";" - end - result = executeSQL(sql) - df = DataFrame(result) - tablesize = size(df) - row, column = tablesize - if row == 0 - return (result_str="No records found. Try loosening your search criteria.", result_raw=nothing, success=true, errormsg=nothing) - elseif column > 30 - return (result_str="There are more than 30 columns. Please be more specific.", result_raw=df, success=true, errormsg=nothing) - else - df1 = - if row > 2 - # ramdom row to pick - df[sample(1:nrow(df), 2, replace=false), :] # random select 2 rows from df - else - df - end - result = GeneralUtils.dfToString(df1) - # println("\n~~~ SQLexecution() result: ", @__FILE__, " ", @__LINE__) - # println(sql) - # println(df1) - # println("\n") - return (result_str=result, result_raw=df1, success=true, errormsg=nothing) - end - catch e - io = IOBuffer() - showerror(io, e) - errorMsg = String(take!(io)) - st = sprint((io, v) -> show(io, "text/plain", v), stacktrace(catch_backtrace())) - println(errorMsg) - return (result_str=nothing, result_raw=nothing, success=false, errormsg=errorMsg) - end -end - -""" -DEPRECATED: Search for wines in stock (legacy implementation). - -Use `search_wine_database!` instead. This function uses the older approach with -`extractWineAttributes_1` and `extractWineAttributes_2` for attribute extraction. - -# Arguments -- `a::T`: An agent instance (subtype of `agent`) -- `thoughtdict::AbstractDict`: Dictionary containing `action_input` (search query) - -# Keyword Arguments -- `useSQLLLM::Bool=false`: Whether to use SQLLLM for the query - -# Returns -- `NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}}` - -# Notes -- DEPRECATED: Use `search_wine_database!` for the current implementation. -- Calls `extractWineAttributes_1` and `extractWineAttributes_2` for attribute extraction. -""" -function DEPRECIATED_search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool=false - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - - # XXX - predefined_wine_search_sql(a, thoughtdict["action_input"]) - - println("\ncheckinventory order: $(thoughtdict["action_input"]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - wineattributes_1 = extractWineAttributes_1(a, thoughtdict["action_input"]) - wineattributes_2 = extractWineAttributes_2(a, thoughtdict["action_input"]) - - retrieve_attributes = ["winery", "wine_name", "wine_id", "vintage", "region", "country", "wine_type", "grape", "serving_temperature", "sweetness", "intensity", "tannin", "acidity", "tasting_notes", "price", "currency", "image_url", "retailer_name", "retailer_id"] - _inventoryquery = "$(thoughtdict["action_input"]), $wineattributes_1, $wineattributes_2, retailer_name: $(a.retailername), retailerid: $(a.retailerid)" - inventoryquery = "Retrieves $retrieve_attributes of wines that match the following criteria - {$_inventoryquery}" - println("\ncheckinventory input: $inventoryquery ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - - if useSQLLLM - # add suppport for similarSQLVectorDB - textresult, result_raw = SQLLLM.query( - inventoryquery, - a.context.executeSQL, - a.context.text2textInstructLLM; - insertSQLVectorDB=a.context.insertSQLVectorDB, - similarSQLVectorDB=a.context.similarSQLVectorDB, - llmFormatName="qwen3") - thoughtdict["action_result"] = textresult - else - - # direct query with possible sql instead of SQLLLM. - sql = generatesql(a, inventoryquery) - println("\nSQL: $sql ", @__FILE__, ":", @__LINE__, " $(Dates.now()) \n") - textresult, sql_result_df, success, _ = SQLexecution(a.context.executeSQL, sql) - - items = nothing - if sql_result_df !== nothing - result_vec = GeneralUtils.dfToVectorDict(sql_result_df) - - # get image - for d in result_vec - image_url_json_str = d["image_url"] - image_url_json_obj = JSON.parse(image_url_json_str) - base_url = "http://192.168.88.106:8080/" - if haskey(image_url_json_obj, "bottle") - url = base_url * image_url_json_obj["bottle"] - image_data = HTTP.get(url) # vector{int} data - image_base64_string = base64encode(image_data.body) - d["image"] = image_base64_string - else - d["image"] = nothing - end - end - items = result_vec # image is added to each item - end - - thoughtdict["action_result"] = textresult - end - - return (thoughtdict=thoughtdict, result_raw=items) -end - -""" -Extract wine attributes from a user's search query. - -Uses an LLM to parse natural language input into structured wine attributes including -name, winery, vintage, country, type, grape, price range, occasion, and food pairing. - -# Arguments -- `a::T1`: An agent instance (subtype of `agent`) with context containing: - - `text2textInstructLLM`: LLM function for text generation - - `pg_conn_str`: PostgreSQL connection string - - `id`: Agent identifier -- `input::T2`: User's search query string (subtype of `AbstractString`) - -# Keyword Arguments -- `maxattempt::Int=10`: Maximum number of attempts to get a valid response from the LLM - -# Returns -- `String`: Comma-separated list of extracted attributes in the format `"key: value, key: value"` - Attributes with "N/A", empty, or "none" values are excluded. - -# Notes -- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model with temperature 0.7. -- Extracts: wine_name, winery, vintage, country, wine_type, grape_varietal, tasting_notes, - wine_price_min, wine_price_max, occasion, food_to_be_paired_with_wine -- Validates response contains all required keys via `checkAgentResponse_JSON`. -- Applies fuzzy entity resolution via `harvest_entity_catalog` and `resolve_entity` (threshold=0.9). -- Strips "(some comment)" patterns from values. -- Removes keys: thought, tasting_notes, occasion, food_to_be_paired_with_wine, vintage from final output. - -# Examples -```jldoctest -julia> YiemAgent.extractWineAttributes_1(agent, "red wine from Napa under 50") -"wine_name:N/A, winery:N/A, vintage:N/A, country:United States, wine_type:red, grape_varietal:N/A, wine_price_min:0, wine_price_max:50" -``` -""" -function extractWineAttributes_1(a::T1, input::T2; maxattempt=10 - )::String where {T1<:agent, T2<:AbstractString} - - systemmsg = - """ - - At each round of conversation, the user provides the following: - - The query: the query provided by the user. - - - Extract information from the user's query as much as possible according to wine attributes extraction guidelines to fill out user's preference form. - - - Fulfill the objective. - - - - If specific information required in the preference form is not available in the query or there isn't any, mark with "N/A" to indicate this. - Additionally, words like 'any' or 'unlimited' mean no information is available. - - Do not generate other comments. - - - wine_name: name of the wine - winery: name of the winery - vintage: the year of the wine - country: a country where wine is produced. Can be "Austria", "Australia", "France", "Germany", "Italy", "Portugal", "Spain", "United States". Use "or" if there are multiple countries. - wine_type: can be one of: "red", "white", "sparkling", "rose", "dessert" or "fortified" - grape_varietal: the name of the primary grape used to make the wine - tasting_notes: a word describe the wine's flavor, such as "butter", "oak", "fruity", "raspberry", "earthy", "floral", etc - wine_price_min: minimum price range of wine. Example: For wine price 20, wine_price_min will be 0. For wine price 10 to 100, wine_price_min will be 10. - wine_price_max: maximum price range of wine. Example: For wine price 20, wine_price_max will be 20. For wine price 10 to 100, wine_price_max will be 100. - occasion: the occasion the user is having the wine for - food_to_be_paired_with_wine: food that the user will be served with the wine such as poultry, fish, steak, etc - _keyword suffice is the related keyword that appears in user's query. - - - "wine_name": "...", - "winery": "...", - "vintage": "...", - "country": "...", - "wine_type": "...", - "grape_varietal": "...", - "tasting_notes": "...", - "wine_price_min": "...", - "wine_price_max": "...", - "occasion": "...", - "food_to_be_paired_with_wine": "..." - - - User's query: red, Chenin Blanc, Riesling, 20 USD from Tuscany, Italy or Napa Valley, USA - "wine_name": "N/A", - "winery": "N/A", - "vintage": "N/A", - "country": "Italy or United States", - "wine_type": "red or white", - "grape_varietal": "Chenin Blanc or Riesling", - "tasting_notes": "citrus", - "wine_price_min": "0", - "wine_price_max": "20", - "occasion": "N/A", - "food_to_be_paired_with_wine": "N/A" - - User's query: Domaine du Collier Saumur Blanc 2019, France, white, Merlot - "wine_name": "Saumur Blanc", - "winery": "Domaine du Collier", - "vintage": "2019", - "country": "France", - "wine_type": "white", - "grape_varietal": "Merlot", - "tasting_notes": "N/A", - "wine_price_min": "N/A", - "wine_price_max": "N/A", - "occasion": "N/A", - "food_to_be_paired_with_wine": "N/A" - - """ - requiredKeys = ["wine_name", "winery", "vintage", "country", "wine_type", "grape_varietal", "tasting_notes", "wine_price_min", "wine_price_max", "occasion", "food_to_be_paired_with_wine"] - errornote = "" - context = - """ - - $errornote - - """ - - input = context * input - - msg = Dict( - "model" => "gemma-4-E4B-it-UD-Q4_K_XL", - "messages" => [ - Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => systemmsg), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => input), - ] - ), - ], - "temperature" => 0.7 - ) - - for attempt in 1:maxattempt - response = a.context.text2textInstructLLM(a.id, msg) - response = GeneralUtils.clean_json_response(response) - - response = GeneralUtils.remove_french_accents(response) - think, response = GeneralUtils.extractthink(response) - responsedict = nothing - try - _responsedict = JSON.parse(response) - responsedict = GeneralUtils.dictify(_responsedict; keytype=String, sort_order=requiredKeys) - catch - println("\nERROR YiemAgent extractWineAttributes_1() failed to parse response: $response", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - # check whether all answer's key points are in responsedict - ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent extractWineAttributes_1() $errornote --(not qualify response)> $responsedict", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - removekeys = ["thought", "tasting_notes", "occasion", "food_to_be_paired_with_wine", "vintage"] - for i in removekeys - delete!(responsedict, i) - end - # remove (some text) - for (k, v) in responsedict - _v = replace(v, r"\(.*?\)" => "") - responsedict[k] = _v - end - - @info "YiemAgent extractWineAttributes_1() " @__LINE__ - @show responsedict - @info "---\n" @__LINE__ - - # check each attributes against each column in a database table with BM25 - for (k, v) in responsedict - if k ∉ ["wine_price_min", "wine_price_max"] - words_catalog = GeneralUtils.harvest_entity_catalog(a.context.pg_conn_str, "wine", k) - resolved_word = GeneralUtils.resolve_entity(v, words_catalog; threshold=0.9) - responsedict[k] = resolved_word - end - end - - result = "" - for (k, v) in responsedict - # some time LLM generate text with "(some comment)". this line removes it - if !occursin("N/A", v) && v != "" && !occursin("none", v) && !occursin("None", v) - result *= "$k: $v, " - end - end - - result = result[1:end-2] # remove the ending ", " - - @info "YiemAgent extractWineAttributes_1() " @__LINE__ - @show result - @info "---\n" @__LINE__ - - return result - end - error("extractWineAttributes_1() failed to get a response") -end - -""" -Extract wine intensity, sweetness, tannin, and acidity attributes from a query. - -Uses an LLM with a conversion table to map descriptive words (e.g., "medium-bodied", "low acidity") -to integer ranges on a 1-5 scale. - -# Arguments -- `a::T1`: An agent instance (subtype of `agent`) with context containing: - - `text2textInstructLLM`: LLM function for text generation - - `id`: Agent identifier -- `input::T2`: User's query string containing descriptive wine preferences - (subtype of `AbstractString`) - -# Returns -- `String`: Comma-separated list of extracted attributes in the format - `"key: value, key: value"`. Only includes numeric values or non-N/A strings. - -# Notes -- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model with temperature 0.7. -- Extracts: sweetness, acidity, tannin, intensity (each with min/max values). -- Applies `remove_french_accents` to the LLM response. -- Extracts thinking via `extractthink` before JSON parsing. -- Validates response contains all required keys via `checkAgentResponse_JSON`. -- Removes keyword fields (sweetness_keyword, acidity_keyword, etc.) from final output. -- Only includes values that are numbers or non-"N/A" strings. - -# TODO -- "French dry white wines with medium bod" — the LLM does not recognize sweetness. Use LLM self-questioning to solve. -- French Syrah, Viognier, under 100 — LLM extracts intensity of 3-5. Investigate why. - -# Examples -```jldoctest -julia> YiemAgent.extractWineAttributes_2(agent, "medium-bodied, low acidity, medium tannin") -"acidity_min:1, acidity_max:2, tannin_min:3, tannin_max:4, intensity_min:3, intensity_max:4" -``` -""" -function extractWineAttributes_2(a::T1, input::T2)::String where {T1<:agent, T2<:AbstractString} - - conversiontable = - """ - - Intensity level: - 1 to 2: May correspond to "light-bodied" or a similar description. - 2 to 3: May correspond to "med light bodied", "medium light" or a similar description. - 3 to 4: May correspond to "medium bodied" or a similar description. - 4 to 5: May correspond to "med full bodied", "medium full" or a similar description. - 4 to 5: May correspond to "full bodied" or a similar description. - Sweetness level: - 1 to 2: May correspond to "dry", "no sweet" or a similar description. - 2 to 3: May correspond to "off dry", "less sweet" or a similar description. - 3 to 4: May correspond to "semi sweet" or a similar description. - 4 to 5: May correspond to "sweet" or a similar description. - 4 to 5: May correspond to "very sweet" or a similar description. - Tannin level: - 1 to 2: May correspond to "low tannin" or a similar description. - 2 to 3: May correspond to "semi low tannin" or a similar description. - 3 to 4: May correspond to "medium tannin" or a similar description. - 4 to 5: May correspond to "semi high tannin" or a similar description. - 4 to 5: May correspond to "high tannin" or a similar description. - Acidity level: - 1 to 2: May correspond to "low acidity" or a similar description. - 2 to 3: May correspond to "semi low acidity" or a similar description. - 3 to 4: May correspond to "medium acidity" or a similar description. - 4 to 5: May correspond to "semi high acidity" or a similar description. - 4 to 5: May correspond to "high acidity" or a similar description. - - """ - - systemmsg = - """ - - At each round of conversation, you will be given the following information: - conversion_table: a conversion table that maps descriptive words to their corresponding integer levels - query: the words from the user's query that describe their preferences - - - Fill out the user's preference form based on the corresponding words from the user's query according to the guidelines. - - - Fulfill the objective - - - - The preference form requires sweetness, acidity, tannin, intensity infomation - - If specific information required in the preference form is not available in the query or there isn't any, mark with 'N/A' to indicate this. - Additionally, words like 'any' or 'unlimited' mean no information is available. - - Use the conversion table to convert the descriptive word level of sweetness, intensity, tannin, and acidity into a corresponding integer. - - Do not generate other comments. - - - sweetness_keyword: The exact keywords in the user's query describing the sweetness level of the wine. - sweetness: ( S ), where ( S ) represents integers indicating the range of sweetness levels. Example: 1-2 - acidity_keyword: The exact keywords in the user's query describing the acidity level of the wine. - acidity: ( A ), where ( A ) represents integers indicating the range of acidity level. Example: 3-5 - tannin_keyword: The exact keywords in the user's query describing the tannin level of the wine. - tannin: ( T ), where ( T ) represents integers indicating the range of tannin level. Example: 1-3 - intensity_keyword: The exact keywords in the user's query describing the intensity level of the wine. - intensity: ( I ), where ( I ) represents integers indicating the range of intensity level. Example: 2-4 - - - "sweetness_keyword": "...", - "sweetness_min": "...", - "sweetness_max": "...", - "acidity_keyword": "...", - "acidity_min": "...", - "acidity_max": "...", - "tannin_keyword": "...", - "tannin_min": "...", - "tannin_max": "...", - "intensity_keyword": "...", - "intensity_min": "...", - "intensity_max": "..." - - - User's query: I want a wine with a medium-bodied, low acidity, medium tannin. - "sweetness_keyword": "N/A", - "sweetness_min": "N/A", - "sweetness_max": "N/A", - "acidity_keyword": "low acidity", - "acidity_min": 1, - "acidity_max": 2, - "tannin_keyword": "medium tannin", - "tannin_min": 3, - "tannin_max": 4, - "intensity_keyword": "medium-bodied", - "intensity_min": 3, - "intensity_max": 4 - - User's query: German red wine, under 100, pairs with spicy food. - "sweetness_keyword": "N/A", - "sweetness_min": "N/A", - "sweetness_max": "N/A", - "acidity_keyword": "N/A", - "acidity_min": "N/A", - "acidity_max": "N/A", - "tannin_keyword": "N/A", - "tannin_min": "N/A", - "tannin_max": "N/A", - "intensity_keyword": "N/A", - "intensity_min": "N/A", - "intensity_max": "N/A" - - """ - requiredKeys = ["sweetness_keyword", "sweetness_min", "sweetness_max", - "acidity_keyword", "acidity_min", "acidity_max", - "tannin_keyword", "tannin_min", "tannin_max", - "intensity_keyword", "intensity_min", "intensity_max"] - errornote = "" - context = - """ - - $conversiontable - $errornote - - """ - - input = context * input - - msg = Dict( - "model" => "gemma-4-E4B-it-UD-Q4_K_XL", - "messages" => [ - Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => systemmsg), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => input), - ] - ), - ], - "temperature" => 0.7 - ) - - for attempt in 1:10 - response = a.context.text2textInstructLLM(a.id, msg) - response = GeneralUtils.clean_json_response(response) - - response = GeneralUtils.remove_french_accents(response) - think, response = GeneralUtils.extractthink(response) - responsedict = nothing - try - _responsedict = JSON.parse(response) - responsedict = GeneralUtils.dictify(_responsedict; keytype=String, sort_order=requiredKeys) - catch - println("\nERROR YiemAgent extractWineAttributes_2() failed to parse response: $response", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - # check whether all answer's key points are in responsedict - ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent extractWineAttributes_2() $errornote --(not qualify response)> $responsedict", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - # delete some key words from responsedict - for (k, v) in responsedict - if k ∈ ["sweetness_keyword", "acidity_keyword", "tannin_keyword", "intensity_keyword"] - delete!(responsedict, k) - end - end - - # get result in String. Reject "N/A" value - result = "" - for (k, v) in responsedict - if typeof(v) <: Number - result *= "$k: $v, " - elseif typeof(v) == String && !occursin("N/A", v) - result *= "$k: $v, " - end - end - result = result[1:end-2] # remove the ending ", " - - @info "YiemAgent extractWineAttributes_2() " @__LINE__ - @show result - @info "---\n" @__LINE__ - - return result - end - error("extractWineAttributes_2() failed to get a response") -end - - - - - - -""" -Get a simplified DDL schema for a PostgreSQL table with sample values. - -Establishes a new connection and delegates to the connection-based overload. - -# Arguments -- `pg_conn_str::String`: PostgreSQL connection string -- `table_name::String`: Name of the table to get schema for - -# Keyword Arguments -- `schema_name::String="public"`: PostgreSQL schema name - -# Returns -- `String`: Formatted DDL schema string with sample values - -# Notes -- Delegates to `get_db_table_schema_simple_with_samples(conn, table_name, ...)` after creating - a new `LibPQ.Connection`. - -# Examples -```jldoctest -julia> schema = YiemAgent.get_db_table_schema_simple_with_samples(conn_str, "wine") -"CREATE TABLE public.wine (\n wine_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n ...\n);" -``` -""" -function get_db_table_schema_simple_with_samples(pg_conn_str::String, table_name::String; - schema_name::String="public")::String - conn = LibPQ.Connection(pg_conn_str) - return get_db_table_schema_simple_with_samples(conn, table_name; schema_name=schema_name) -end - -""" -Get a simplified DDL schema for a PostgreSQL table with sample values. - -Queries the PostgreSQL catalog for column metadata, fetches sample values, and builds -a DDL string with inline comments for each column. - -# Arguments -- `conn`: An active PostgreSQL connection (e.g., `LibPQ.Connection`) -- `table_name::String`: Name of the table to get schema for - -# Keyword Arguments -- `schema_name::String="public"`: PostgreSQL schema name -- `sample_count::Int=3`: Number of non-null sample values to fetch per column - -# Returns -- `String`: Formatted DDL schema string in the style of `CREATE TABLE` statements - with sample values as inline comments - -# Notes -- Queries `pg_attribute`, `pg_class`, `pg_namespace`, `pg_attrdef`, and `pg_constraint` - for column metadata, defaults, and constraints. -- Fetches up to `sample_count` non-null values per column via `json_agg`. -- Throws an error if the table is not found. - -# Examples -```jldoctest -julia> schema = YiemAgent.get_db_table_schema_simple_with_samples(conn, "wine") -"CREATE TABLE public.wine (\n wine_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n ...\n);" -``` -""" -function get_db_table_schema_simple_with_samples(conn, table_name::String; schema_name::String="public", sample_count::Int=3)::String - # 1. SQL query for catalog metadata - meta_sql = """ - SELECT - a.attname AS column_name, - format_type(a.atttypid, a.atttypmod) AS data_type, - pg_get_expr(def.adbin, def.adrelid) AS default_value, - COALESCE( - (SELECT pg_get_constraintdef(p.oid) - FROM pg_catalog.pg_constraint p - WHERE p.conrelid = c.oid AND a.attnum = ANY(p.conkey) - LIMIT 1), '' - ) AS constraint_definition - FROM pg_catalog.pg_attribute a - JOIN pg_catalog.pg_class c ON a.attrelid = c.oid - JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid - LEFT JOIN pg_catalog.pg_attrdef def ON def.adrelid = c.oid AND def.adnum = a.attnum - WHERE c.relname = \$1 - AND n.nspname = \$2 - AND a.attnum > 0 - AND NOT a.attisdropped - ORDER BY a.attnum; - """ - - meta_res = DataFrame(execute(conn, meta_sql, [table_name, schema_name])) - - if nrow(meta_res) == 0 - error("Table '$schema_name.$table_name' not found.") - end - - # 2. Build single dynamic query to fetch non-null samples for all columns - sample_selects = String[] - for row in eachrow(meta_res) - c_name = row.column_name - push!(sample_selects, """ - (SELECT json_agg(s."$c_name") - FROM ( - SELECT "$c_name" - FROM "$schema_name"."$table_name" - WHERE "$c_name" IS NOT NULL - LIMIT $sample_count - ) s - ) AS "$c_name" - """) - end - - sample_sql = "SELECT " * join(sample_selects, ",\n ") * ";" - sample_df = DataFrame(execute(conn, sample_sql)) - - # 3. Build DDL definitions with inline sample comments - ddl_lines = String[] - constraints = String[] - - for row in eachrow(meta_res) - col_name = row.column_name - data_type = row.data_type - default_val = ismissing(row.default_value) ? "" : " DEFAULT " * row.default_value - - col_def = " \"$col_name\" $data_type$default_val" - - # Fetch sample data for this column from the single-row sample DataFrame - samples_comment = "" - if nrow(sample_df) > 0 - raw_samples = sample_df[1, Symbol(col_name)] - samples_str = ismissing(raw_samples) || isnothing(raw_samples) ? "[]" : string(raw_samples) - samples_comment = " -- Samples: $samples_str" - end - - push!(ddl_lines, col_def * samples_comment) - - # Handle table-level constraints - con_def = ismissing(row.constraint_definition) ? "" : row.constraint_definition - if !isempty(con_def) && !(con_def in constraints) - push!(constraints, " " * con_def) - end - end - - all_definitions = vcat(ddl_lines, constraints) - body = join(all_definitions, ",\n") - - return "CREATE TABLE \"$schema_name\".\"$table_name\" (\n$body\n);" -end - - - -function classify_column(pg_conn_str::String, table_name::String, column_name::String; - sample_size::Integer=1000) - conn = LibPQ.Connection(pg_conn_str) - return classify_column(conn, table_name, column_name; sample_size=sample_size) -end - - -function classify_column(conn::LibPQ.Connection, table_name::String, column_name::String; sample_size::Int=1000) - # 1. Fetch BOTH data_type and udt_name (User Defined Type name) - meta_query = """ - SELECT data_type, udt_name - FROM information_schema.columns - WHERE table_name = lower('$(table_name)') - AND column_name = lower('$(column_name)'); - """ - - pg_type = "unknown" - udt_name = "unknown" - - try - df = DataFrame(LibPQ.execute(conn, meta_query)) - if !isempty(df) - pg_type = df[1, :data_type] - udt_name = df[1, :udt_name] - end - catch e - @error "Failed to fetch metadata for $table_name.$column_name" exception=e - return "error" - end - - # 2. FAST-TRACK: Check for pgvector FIRST - # pgvector registers as "USER-DEFINED" in data_type, but "vector" in udt_name - if udt_name == "vector" - return "semantic_search" - end - - # 3. FAST-TRACK: Hard rules for standard non-text Postgres types - if pg_type in ["integer", "bigint", "smallint", "numeric", "real", - "double precision", "boolean", "date", - "timestamp without time zone", "timestamp with time zone", "uuid"] - return "exact_or_range" - end - - # 4. SAMPLE: Get text statistics for remaining text columns - stats_query = """ - SELECT - COUNT(*)::int AS total_count, - COUNT(DISTINCT $(column_name)::text)::int AS unique_count, - COALESCE(AVG(LENGTH($(column_name)::text)), 0)::float AS avg_len, - COALESCE(STDDEV(LENGTH($(column_name)::text)), 0)::float AS std_len - FROM ( - SELECT $(column_name) - FROM $(table_name) - WHERE $(column_name) IS NOT NULL - LIMIT $sample_size - ) AS sampled_data; - """ - - try - df = DataFrame(LibPQ.execute(conn, stats_query)) - if isempty(df) || df[1, :total_count] == 0 - return "unknown" - end - - total = df[1, :total_count] - unique = df[1, :unique_count] - avg_len = df[1, :avg_len] - std_len = df[1, :std_len] - ratio = unique / total - - # 5. HEURISTICS: Route the column_name to the correct text bucket - return classify_text_column(unique, ratio, avg_len, std_len) - - catch e - @warn "Failed to sample column_name $table_name.$column_name" exception=e - return "unknown" - end -end - -# The Decision Tree for Text Columns (Unchanged, but kept for completeness) -function classify_text_column(unique_count::Integer, ratio::Float64, avg_len::Float64, std_len::Float64) - if avg_len > 60 && std_len > 25 - return "full_text_search" - end - if ratio > 0.90 && avg_len < 40 - return "exact_or_regex" - end - if unique_count <= 100 - return "fuzzy_correction" - end - if ratio > 0.10 && avg_len < 35 - return "fuzzy_correction" - end - if avg_len < 60 - return "fuzzy_correction" - end - return "full_text_search" -end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -end # module llmfunction \ No newline at end of file diff --git a/test/toolTest.jl b/test/toolTest.jl deleted file mode 100644 index f613397..0000000 --- a/test/toolTest.jl +++ /dev/null @@ -1,367 +0,0 @@ -using Test -using Dates -using YiemAgent -using YiemAgent.toolRegistry -using YiemAgent.type -using YiemAgent.agentCore - -@testset "register_all_tools with toolStore" begin - - # ------------------------------------------------------------------ # - # 1. register_all_tools registers all static tools + listTools # - # ------------------------------------------------------------------ # - store = toolStore(name="test1") - loaded = register_all_tools(store) - @test !isempty(loaded) - @test length(loaded) == 4 # getWeather + getTime + writeTool + listTools - - names = [k for k in keys(loaded)] - @test "getTime" in names - @test "getWeather" in names - @test "writeTool" in names - @test "listTools" in names - - # ------------------------------------------------------------------ # - # 2. register_all_tools returns tools in registration order # - # ------------------------------------------------------------------ # - @test collect(keys(loaded))[1] == "getWeather" - @test collect(keys(loaded))[2] == "getTime" - @test collect(keys(loaded))[3] == "writeTool" - @test collect(keys(loaded))[4] == "listTools" - - # ------------------------------------------------------------------ # - # 3. Verify loaded tool fields are correct # - # ------------------------------------------------------------------ # - # getTime - time_tool = loaded["getTime"] - @test time_tool.name == "getTime" - @test time_tool.label == "Time Lookup" - @test time_tool.validateRequiredArgs !== nothing - @test time_tool.parallelToolExecute == false - @test time_tool.inputSchema["required"] == Any[] - - # getWeather - weather = loaded["getWeather"] - @test weather.name == "getWeather" - @test weather.label == "Weather Lookup" - @test weather.execute !== nothing - @test weather.parallelToolExecute == false - @test weather.inputSchema["required"] == ["city"] - - # writeTool - wt = loaded["writeTool"] - @test wt.name == "writeTool" - @test wt.label == "Create Tool" - @test wt.execute !== nothing - @test "name" in wt.inputSchema["required"] - @test "executeCode" in wt.inputSchema["required"] - - # ------------------------------------------------------------------ # - # 4. Tool execution returns valid results # - # ------------------------------------------------------------------ # - sig = nothing - op = x -> x # no-op partial result callback - - # execute getTime - result_t = time_tool.execute("call-1", Dict{String,Any}("city" => "Tokyo"), sig, op) - @test result_t isa agentToolResult - @test result_t.content[1] isa textContent - @test occursin("Tokyo", result_t.content[1].text) - - # execute getTime with timezone - result_tz = time_tool.execute("call-2", Dict{String,Any}("timezone" => "America/New_York"), sig, op) - @test result_tz isa agentToolResult - @test occursin("America/New_York", result_tz.content[1].text) - - # execute getWeather - result_w = weather.execute("call-3", Dict{String,Any}("city" => "Bangkok"), sig, op) - @test result_w isa agentToolResult - @test result_w.content[1] isa textContent - @test occursin("Bangkok", result_w.content[1].text) - - # execute getWeather with units - result_w2 = weather.execute("call-4", Dict{String,Any}("city" => "London", "units" => "fahrenheit"), sig, op) - @test occursin("72\u00b0F", result_w2.content[1].text) - - # ------------------------------------------------------------------ # - # 5. getTools / registerTool / clearTools (per-store isolation) # - # ------------------------------------------------------------------ # - store3 = toolStore(name="test3") - registry_tools = getTools(store3) - @test isempty(registry_tools) - - # Register tools manually - registerTool(store3, loaded["getTime"]) - registerTool(store3, loaded["getWeather"]) - registerTool(store3, loaded["writeTool"]) - - reg = getTools(store3) - @test !isempty(reg) - @test "getTime" in keys(reg) - @test "getWeather" in keys(reg) - @test "writeTool" in keys(reg) - @test collect(keys(reg))[1] == "getTime" - @test collect(keys(reg))[2] == "getWeather" - @test collect(keys(reg))[3] == "writeTool" - - clearTools(store3) - @test isempty(getTools(store3)) - - test_tool = agentTool( - name = "manualTool", - label = "Manual Tool", - description = "Registered manually", - inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), - execute = (toolCallId, args, signal, onPartialResult) -> - agentToolResult([textContent("manual")], Dict{Any,Any}(), nothing, false), - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = true - ) - registerTool(store3, test_tool) - reg = getTools(store3) - @test haskey(reg, "manualTool") - @test length(reg) == 1 - @test reg["manualTool"].parallelToolExecute == true - - # ------------------------------------------------------------------ # - # 6. getTools returns direct reference (mutations affect registry) # - # ------------------------------------------------------------------ # - copy1 = getTools(store3) - copy2 = getTools(store3) - @test copy1 === copy2 # same reference, not a deep copy - empty!(copy1) - @test isempty(getTools(store3)) # mutation propagates - - # ------------------------------------------------------------------ # - # 7. Per-store isolation — two stores don't share tools # - # ------------------------------------------------------------------ # - storeA = toolStore(name="isolationA") - storeB = toolStore(name="isolationB") - - registerTool(storeA, loaded["getTime"]) - registerTool(storeB, loaded["getWeather"]) - - regA = getTools(storeA) - regB = getTools(storeB) - - @test "getTime" in keys(regA) - @test "getWeather" ∉ keys(regA) - @test "getWeather" in keys(regB) - @test "getTime" ∉ keys(regB) - - clearTools(storeA) - @test isempty(getTools(storeA)) - @test !isempty(getTools(storeB)) # storeB unaffected -end - -@testset "listTool" begin - store = toolStore(name="test_list") - register_all_tools(store) # auto-registers getWeather, getTime, writeTool + listTools - - # register_all_tools auto-registers listTool - @test "listTools" in keys(store.tools) - - # listTool returns an agentTool, not a string or array - list_t = listTool(store) - @test list_t isa agentTool - @test list_t.name == "listTools" - @test list_t.label == "List Tools" - @test isempty(list_t.inputSchema["required"]) - - # Verify all tools appear (3 loaded + listTools = 4) - result = list_t.execute("call-1", Dict{String,Any}(), nothing, x -> x) - @test result isa agentToolResult - @test result.content[1] isa textContent - @test occursin("listTools", result.content[1].text) - @test occursin("getWeather", result.content[1].text) - @test occursin("getTime", result.content[1].text) - @test occursin("writeTool", result.content[1].text) - @test result.details["count"] == 4 - - # Each listTool call creates an independent closure - storeB = toolStore(name="test_listB") - registerTool(storeB, store.tools["getWeather"]) - list_tB = listTool(storeB) - - resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x) - resultB = list_tB.execute("call-4", Dict{String,Any}(), nothing, x -> x) - - @test occursin("getWeather", resultA.content[1].text) - @test occursin("getWeather", resultB.content[1].text) - @test occursin("getTime", resultA.content[1].text) - @test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather -end - -@testset "executePreparedToolCall with static tools" begin - # Tests executePreparedToolCall with statically loaded tools. - # The world-age issue is resolved because tool.execute comes from - # a statically included module, not a dynamically created one. - - store = toolStore(name="test_static") - register_all_tools(store) - - weather_tool = store.tools["getWeather"] - - # Create a preparedToolCall that mimics what prepareToolCall() returns - tool_call = agentToolCall( - "function", "call-static-1", "getWeather", - Dict{String,Any}("city" => "San Francisco") - ) - prep = preparedToolCall( - weather_tool, tool_call, Dict{String,Any}("city" => "San Francisco") - ) - sig = abortSignal(false) - - # This call goes through: executePreparedToolCall -> prep.tool.execute(...) - result = executePreparedToolCall( - prep, sig, x -> nothing - ) - - @test result isa executedOutcome - @test result.isError == false - @test result.result.content[1] isa textContent - @test occursin("San Francisco", result.result.content[1].text) -end - -@testset "executePreparedToolCall with validation (static tools)" begin - # Tests executePreparedToolCall with a tool that has custom validation hooks. - # This exercises the full tool execution path including validation. - - store = toolStore(name="test_static_validate") - register_all_tools(store) - - time_tool = store.tools["getTime"] - - tool_call = agentToolCall( - "function", "call-static-2", "getTime", - Dict{String,Any}("timezone" => "America/New_York") - ) - prep = preparedToolCall( - time_tool, tool_call, Dict{String,Any}("timezone" => "America/New_York") - ) - sig = abortSignal(false) - - result = executePreparedToolCall( - prep, sig, x -> nothing - ) - - @test result isa executedOutcome - @test result.isError == false - @test result.result.content[1] isa textContent - @test occursin("America/New_York", result.result.content[1].text) -end - -@testset "executeToolCallsSequential with static tools (full pipeline)" begin - # Tests the full tool execution pipeline: executeToolCallsSequential - # which calls prepareToolCall -> executePreparedToolCall -> finalizeExecutedToolCall - # with statically loaded tools. - - store = toolStore(name="test_full_pipeline") - register_all_tools(store) - - # Build agentContext from the store's tools - tools = getTools(store) - ctx = agentContext( - "test system prompt", - agentMessage[], - tools - ) - - # Create an assistant message containing tool calls - assistant_msg = assistantMessage( - role="assistant", - content=Vector{messageContent}(), - api="openai", - provider="test", - model="test-model", - usage=llmUsage(0, 0), - stopReason="tool_calls", - errorMessage=nothing, - timestamp=now() - ) - - # Create tool calls for multiple statically loaded tools - tool_calls = [ - agentToolCall( - "function", "call-seq-1", "getWeather", - Dict{String,Any}("city" => "Tokyo") - ), - agentToolCall( - "function", "call-seq-2", "getTime", - Dict{String,Any}("timezone" => "Europe/London") - ), - ] - - config = agentLoopConfig( - nothing, nothing, "sequential" - ) - sig = abortSignal(false) - - # Execute the full pipeline - batch = executeToolCallsSequential( - ctx, assistant_msg, tool_calls, config, sig, x -> nothing - ) - - @test batch.messages isa Vector{toolResultMessage} - @test length(batch.messages) == 2 - @test batch.messages[1].toolName == "getWeather" - @test batch.messages[1].isError == false - @test occursin("Tokyo", batch.messages[1].content[1].text) - @test batch.messages[2].toolName == "getTime" - @test batch.messages[2].isError == false - @test occursin("Europe/London", batch.messages[2].content[1].text) -end - -@testset "executeToolCallsParallel with static tools (full pipeline)" begin - # Same as above but tests parallel execution path. - - store = toolStore(name="test_parallel") - register_all_tools(store) - - tools = getTools(store) - ctx = agentContext( - "test system prompt", - agentMessage[], - tools - ) - - assistant_msg = assistantMessage( - role="assistant", - content=Vector{messageContent}(), - api="openai", - provider="test", - model="test-model", - usage=llmUsage(0, 0), - stopReason="tool_calls", - errorMessage=nothing, - timestamp=now() - ) - - tool_calls = [ - agentToolCall( - "function", "call-par-1", "getWeather", - Dict{String,Any}("city" => "Paris") - ), - agentToolCall( - "function", "call-par-2", "getTime", - Dict{String,Any}("city" => "Sydney") - ), - ] - - config = agentLoopConfig( - nothing, nothing, "parallel" - ) - sig = abortSignal(false) - - batch = executeToolCallsParallel( - ctx, assistant_msg, tool_calls, config, sig, x -> nothing - ) - - @test batch.messages isa Vector{toolResultMessage} - @test length(batch.messages) == 2 - @test batch.messages[1].toolName == "getWeather" - @test batch.messages[1].isError == false - @test batch.messages[2].toolName == "getTime" - @test batch.messages[2].isError == false -end diff --git a/user_code.jl b/user_code.jl index 2123d8a..54ad652 100644 --- a/user_code.jl +++ b/user_code.jl @@ -32,7 +32,7 @@ catch e println(io) end - agentEventSink(err_msg) + eventSink(err_msg) end """ @@ -66,13 +66,13 @@ end -struct agentEventSink +struct eventSink natsConn::NATS.Connection topic::String senderID::String end -function (aes::agentEventSink)(msg::String) +function (aes::eventSink)(msg::String) NATS.publish(aes.natsConn, aes.topic, msg) end @@ -88,11 +88,11 @@ text2text_llm = text2textInstructLLM(agent_conn, "sender", config["externalservice"]["fileserver"]["url"]) -debugNats = agentEventSink(agent_conn, "sommanion.debug", "sender") +debugNats = eventSink(agent_conn, "sommanion.debug", "sender") agent = YiemAgent.yiemAgent( text2text_llm; - agentEventSink=debugNats + eventSink=debugNats ) msg = Dict(