Compare commits

..

85 Commits

Author SHA1 Message Date
ton 4e592173a6 update 2026-08-12 14:37:11 +07:00
ton 0cacb5c94a update 2026-08-12 04:35:34 +07:00
ton 6c96409969 update 2026-08-12 04:33:19 +07:00
ton 06d51c1ee9 update 2026-08-12 04:00:09 +07:00
ton 2ad3d1df38 update 2026-08-11 19:10:37 +07:00
ton 83c7770877 update 2026-08-11 18:57:53 +07:00
ton bad14fbe7f update 2026-08-11 18:42:34 +07:00
ton 578e8f55bd update 2026-08-11 18:28:03 +07:00
ton ae3e432b02 update 2026-08-11 17:35:56 +07:00
ton 7c14390400 update 2026-08-11 17:28:25 +07:00
ton 89885c1583 update 2026-08-11 16:43:48 +07:00
ton 5a27630ccf update 2026-08-11 12:15:05 +07:00
ton ed91260468 update 2026-08-10 20:37:28 +07:00
ton c13aeb3a74 Merge pull request 'V0.8.0 verify tool use' (#43) from v0.8.0-verify_tool_use into v0.8.0
Reviewed-on: #43
2026-08-10 13:10:57 +00:00
ton 287704778f update 2026-08-10 20:07:15 +07:00
ton a9fa23f01b update 2026-08-10 19:19:58 +07:00
ton c5cb18f0f1 update 2026-08-10 16:10:04 +07:00
ton c78f4b023d update 2026-08-10 14:55:09 +07:00
ton 1b69f69c7d update 2026-08-10 13:33:45 +07:00
ton 3891099eaa update readme 2026-08-10 10:39:30 +07:00
ton 268d340e2f Merge pull request 'V0.8.0 use tool module' (#42) from v0.8.0-use_tool_module into v0.8.0
Reviewed-on: #42
2026-08-10 02:48:25 +00:00
ton 189bc2efcf update 2026-08-10 09:43:35 +07:00
ton 92b3e4081f update 2026-08-09 22:50:02 +07:00
ton 750eff483b update 2026-08-09 22:08:04 +07:00
ton 5cc35c78f4 Merge pull request 'V0.8.0 add tools' (#41) from v0.8.0-add_tools into v0.8.0
Reviewed-on: #41
2026-08-09 11:47:02 +00:00
ton ed5415d92a update 2026-08-09 08:45:35 +07:00
ton 6b3d575ea0 update 2026-08-09 06:50:37 +07:00
ton da16c80a0a update 2026-08-09 06:27:48 +07:00
ton 069240912b update 2026-08-09 04:45:31 +07:00
ton 2aa0d1e9a4 update 2026-08-08 19:44:06 +07:00
ton 03e1dd7628 update 2026-08-08 18:49:51 +07:00
ton 70296a3bf2 update 2026-08-08 12:21:52 +07:00
ton 75b2ce5978 update 2026-08-08 12:11:53 +07:00
ton d8172a7fbe Merge pull request 'V0.8.0 async think loop' (#40) from v0.8.0-async_think_loop into v0.8.0
Reviewed-on: #40
2026-08-08 04:13:05 +00:00
ton d29a413159 update 2026-08-08 11:09:00 +07:00
ton 0ddbcf9ca1 update 2026-08-08 09:38:05 +07:00
ton 04e75e61b8 update 2026-08-08 08:51:07 +07:00
ton c1ef544734 update 2026-08-08 08:04:08 +07:00
ton e60cbc67c4 update 2026-08-07 23:13:13 +07:00
ton 8e356f06bc update 2026-08-07 17:48:50 +07:00
ton 131973edd4 update 2026-08-07 15:55:37 +07:00
ton 0f16e045b9 update 2026-08-07 15:54:36 +07:00
ton 02e93cae57 update 2026-08-07 15:47:27 +07:00
ton 4df1043032 update 2026-08-07 15:18:42 +07:00
ton 7b465baddd update 2026-08-07 14:31:33 +07:00
ton 9e637f92bd update 2026-08-07 14:07:40 +07:00
ton 197a7a0cb7 update 2026-08-07 13:59:13 +07:00
ton 14bac755ea update 2026-08-07 13:28:09 +07:00
ton 2de4fa07c1 update 2026-08-07 10:19:55 +07:00
ton b08121ee50 update 2026-08-07 09:37:27 +07:00
ton d63ddd3d27 update 2026-08-07 09:24:41 +07:00
ton 0a6af36b34 update 2026-08-07 08:46:48 +07:00
ton d0bacbb538 update 2026-08-06 19:39:42 +07:00
ton 51fca0aa8c update 2026-08-06 19:30:59 +07:00
ton ebd79aa686 update 2026-08-06 18:59:39 +07:00
ton dde9b019dd update 2026-08-06 18:44:28 +07:00
ton e592441387 update 2026-08-06 18:40:09 +07:00
ton 8232946e91 update 2026-08-06 18:29:31 +07:00
ton 8a2da0f5c3 update 2026-08-06 15:32:56 +07:00
ton 4cb01c71bb update 2026-08-05 19:33:40 +07:00
ton ec28e0ff54 update 2026-08-05 19:09:56 +07:00
ton afc866a3a5 update 2026-08-05 10:27:17 +07:00
ton a3f9e39249 update 2026-08-05 04:21:44 +07:00
ton ba13ccc4b9 update 2026-08-04 23:17:13 +07:00
ton a90203dc0a update 2026-08-04 23:04:44 +07:00
ton 2c0f25189b update 2026-08-04 22:17:58 +07:00
ton c6f97be63a update 2026-08-04 22:03:56 +07:00
ton af0fa30107 update 2026-08-04 21:50:58 +07:00
ton 6f971abe5d update 2026-08-04 21:46:49 +07:00
ton e622a522dc update 2026-08-04 21:45:00 +07:00
ton 96ff9f0924 update 2026-08-04 21:42:42 +07:00
ton 2072ebe554 update 2026-08-04 19:45:08 +07:00
ton 52eaeab5fc update 2026-08-04 14:51:53 +07:00
ton 937d52053f update 2026-08-04 12:13:22 +07:00
ton 569f85333f update 2026-08-03 18:03:51 +07:00
ton 92eee07168 update 2026-08-03 11:23:45 +07:00
ton c626d2cec5 update 2026-08-03 07:27:06 +07:00
ton 1ab97b3972 add yiemAgent 2026-08-02 20:57:53 +07:00
ton d2081333f6 update 2026-08-02 17:58:57 +07:00
ton 1cb9ca106b update 2026-08-01 23:01:52 +07:00
ton 94dd44236d update 2026-08-01 21:47:30 +07:00
ton be42fc4738 update 2026-08-01 21:46:38 +07:00
ton 7c4e84ba93 update 2026-08-01 21:38:31 +07:00
ton 368307742e update 2026-08-01 17:56:16 +07:00
ton b7658af76b up version 2026-07-28 00:14:01 +00:00
31 changed files with 6850 additions and 5700 deletions
+93 -174
View File
@@ -1,20 +1,8 @@
# This file is machine-generated - editing it directly is not advised # This file is machine-generated - editing it directly is not advised
julia_version = "1.12.7" julia_version = "1.12.6"
manifest_format = "2.0" manifest_format = "2.0"
project_hash = "43252cf86e6030e836ede74c563b58ab2f0f0548" project_hash = "0db36d4fb31037ba05065476e6aebaf4cd0e1e8c"
[[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.Accessors]]
deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"]
@@ -56,12 +44,6 @@ git-tree-sha1 = "d57bd3762d308bded22c3b82d033bff85f6195c6"
uuid = "ec485272-7323-5ecc-a04f-4719b315124d" uuid = "ec485272-7323-5ecc-a04f-4719b315124d"
version = "0.4.0" version = "0.4.0"
[[deps.Arrow]]
deps = ["ArrowTypes", "BitIntegers", "CodecLz4", "CodecZstd", "ConcurrentUtilities", "DataAPI", "Dates", "EnumX", "Mmap", "PooledArrays", "SentinelArrays", "StringViews", "Tables", "TimeZones", "TranscodingStreams", "UUIDs"]
git-tree-sha1 = "4a69a3eadc1f7da78d950d1ef270c3a62c1f7e01"
uuid = "69666777-d1a9-59fb-9406-91d4454c9d45"
version = "2.8.1"
[[deps.ArrowTypes]] [[deps.ArrowTypes]]
deps = ["Sockets", "UUIDs"] deps = ["Sockets", "UUIDs"]
git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101" git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101"
@@ -76,12 +58,6 @@ version = "1.11.0"
uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
version = "1.11.0" version = "1.11.0"
[[deps.BitIntegers]]
deps = ["Random"]
git-tree-sha1 = "091d591a060e43df1dd35faab3ca284925c48e46"
uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1"
version = "0.3.7"
[[deps.BufferedStreams]] [[deps.BufferedStreams]]
git-tree-sha1 = "6863c5b7fc997eadcabdbaf6c5f201dc30032643" git-tree-sha1 = "6863c5b7fc997eadcabdbaf6c5f201dc30032643"
uuid = "e1450e63-4bb3-523b-b2a4-4ffa8c0fd77d" uuid = "e1450e63-4bb3-523b-b2a4-4ffa8c0fd77d"
@@ -98,9 +74,9 @@ version = "1.11.0"
[[deps.CSV]] [[deps.CSV]]
deps = ["CodecZlib", "Dates", "FilePathsBase", "InlineStrings", "Mmap", "Parsers", "PooledArrays", "PrecompileTools", "SentinelArrays", "Tables", "Unicode", "WeakRefStrings", "WorkerUtilities"] deps = ["CodecZlib", "Dates", "FilePathsBase", "InlineStrings", "Mmap", "Parsers", "PooledArrays", "PrecompileTools", "SentinelArrays", "Tables", "Unicode", "WeakRefStrings", "WorkerUtilities"]
git-tree-sha1 = "abed1e735dd4152f48c90cf0767e1790e25f332f" git-tree-sha1 = "8d8e0b0f350b8e1c91420b5e64e5de774c2f0f4d"
uuid = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" uuid = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
version = "0.10.17" version = "0.10.16"
[[deps.CodeTracking]] [[deps.CodeTracking]]
deps = ["InteractiveUtils", "REPL", "UUIDs"] deps = ["InteractiveUtils", "REPL", "UUIDs"]
@@ -114,29 +90,16 @@ git-tree-sha1 = "40956acdbef3d8c7cc38cba42b56034af8f8581a"
uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf" uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf"
version = "0.3.4" version = "0.3.4"
[[deps.CodecLz4]]
deps = ["Lz4_jll", "TranscodingStreams"]
git-tree-sha1 = "d58afcd2833601636b48ee8cbeb2edcb086522c2"
uuid = "5ba52731-8f18-5e0d-9241-30f10d1ec561"
version = "0.4.6"
[[deps.CodecZlib]] [[deps.CodecZlib]]
deps = ["TranscodingStreams", "Zlib_jll"] deps = ["TranscodingStreams", "Zlib_jll"]
git-tree-sha1 = "970758a3d591a2a5c2a907c53f2e2f8c1b1d3537" git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9"
uuid = "944b1d66-785c-5afd-91f1-9de20f533193" uuid = "944b1d66-785c-5afd-91f1-9de20f533193"
version = "0.7.9" version = "0.7.8"
[[deps.CodecZstd]]
deps = ["TranscodingStreams", "Zstd_jll"]
git-tree-sha1 = "da54a6cd93c54950c15adf1d336cfd7d71f51a56"
uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2"
version = "0.8.7"
[[deps.CommonSolve]] [[deps.CommonSolve]]
deps = ["PrecompileTools"] git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1"
git-tree-sha1 = "6c389fa857f6ca5a95474b52a52023fd77f24cb7"
uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
version = "0.2.14" version = "0.2.13"
[[deps.Compat]] [[deps.Compat]]
deps = ["TOML", "UUIDs"] deps = ["TOML", "UUIDs"]
@@ -156,7 +119,7 @@ version = "0.1.1"
[[deps.CompilerSupportLibraries_jll]] [[deps.CompilerSupportLibraries_jll]]
deps = ["Artifacts", "Libdl"] deps = ["Artifacts", "Libdl"]
uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae"
version = "1.3.1+2" version = "1.3.0+1"
[[deps.CompositionsBase]] [[deps.CompositionsBase]]
git-tree-sha1 = "802bb88cd69dfd1509f6670416bd4434015693ad" git-tree-sha1 = "802bb88cd69dfd1509f6670416bd4434015693ad"
@@ -167,12 +130,6 @@ weakdeps = ["InverseFunctions"]
[deps.CompositionsBase.extensions] [deps.CompositionsBase.extensions]
CompositionsBaseInverseFunctionsExt = "InverseFunctions" CompositionsBaseInverseFunctionsExt = "InverseFunctions"
[[deps.ConcurrentUtilities]]
deps = ["Serialization", "Sockets"]
git-tree-sha1 = "3c9be947934c38475bafe822c6d61aaed17f0738"
uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb"
version = "2.6.0"
[[deps.ConstructionBase]] [[deps.ConstructionBase]]
git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb" git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb"
uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9" uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9"
@@ -194,9 +151,9 @@ uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f"
version = "4.2.0" version = "4.2.0"
[[deps.DBInterface]] [[deps.DBInterface]]
git-tree-sha1 = "265d7f87f329c063cbde41920f9d00713a43cedd" git-tree-sha1 = "a444404b3f94deaa43ca2a58e18153a82695282b"
uuid = "a10d1c49-ce27-4219-8d33-6db1a4562965" uuid = "a10d1c49-ce27-4219-8d33-6db1a4562965"
version = "2.7.0" version = "2.6.1"
[[deps.DataAPI]] [[deps.DataAPI]]
git-tree-sha1 = "abe83f3a2f1b857aac70ef8b269080af17764bbe" git-tree-sha1 = "abe83f3a2f1b857aac70ef8b269080af17764bbe"
@@ -251,9 +208,9 @@ version = "1.11.0"
[[deps.Distributions]] [[deps.Distributions]]
deps = ["AliasTables", "FillArrays", "LinearAlgebra", "PDMats", "Printf", "QuadGK", "Random", "Roots", "SpecialFunctions", "Statistics", "StatsAPI", "StatsBase", "StatsFuns"] deps = ["AliasTables", "FillArrays", "LinearAlgebra", "PDMats", "Printf", "QuadGK", "Random", "Roots", "SpecialFunctions", "Statistics", "StatsAPI", "StatsBase", "StatsFuns"]
git-tree-sha1 = "a958ab3a40c755563f5e1405c0846cb0446bf19d" git-tree-sha1 = "d2facc77c08c1c2bfb1a77c148edd05b3db5410b"
uuid = "31c24e10-a181-5473-b8eb-7969acd0382f" uuid = "31c24e10-a181-5473-b8eb-7969acd0382f"
version = "0.25.131" version = "0.25.130"
[deps.Distributions.extensions] [deps.Distributions.extensions]
DistributionsChainRulesCoreExt = "ChainRulesCore" DistributionsChainRulesCoreExt = "ChainRulesCore"
@@ -287,11 +244,15 @@ git-tree-sha1 = "d2e49e7efd29719d6f28b891b0e0e159daa9d2b4"
uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04"
version = "0.1.11" version = "0.1.11"
[[deps.EzXML]] [[deps.FileIO]]
deps = ["Printf", "XML2_jll"] deps = ["Pkg", "Requires", "UUIDs"]
git-tree-sha1 = "7ea1aa5869e2626ccae84480e4f37185bc6f41d3" git-tree-sha1 = "6621fef488e496356c9c9625d0562c12a6070819"
uuid = "8f5d6c58-4d21-5cfd-889c-e3ad7ee6a615" uuid = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549"
version = "1.2.3" version = "1.20.0"
weakdeps = ["HTTP"]
[deps.FileIO.extensions]
HTTPExt = "HTTP"
[[deps.FilePathsBase]] [[deps.FilePathsBase]]
deps = ["Compat", "Dates"] deps = ["Compat", "Dates"]
@@ -327,24 +288,17 @@ uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820"
version = "1.11.0" version = "1.11.0"
[[deps.Gamma]] [[deps.Gamma]]
deps = ["LogExpFunctions"] git-tree-sha1 = "86f86b6168a016ed88e4ae4e64577b98c3b59e8e"
git-tree-sha1 = "becc397f7cfb06e343496ae6ffb04818a851da51"
uuid = "a0844989-3bd2-4988-8bea-c9407ab0941b" uuid = "a0844989-3bd2-4988-8bea-c9407ab0941b"
version = "1.2.0" version = "1.1.0"
[[deps.GeneralUtils]] [[deps.GeneralUtils]]
deps = ["AWS", "AWSS3", "CSV", "DataFrames", "DataStructures", "Dates", "Distributions", "Graphs", "HTTP", "JSON", "LibPQ", "NATS", "OrderedCollections", "PrettyPrinting", "Random", "Revise", "SHA", "StringDistances", "URIs", "UUIDs"] deps = ["CSV", "DataFrames", "DataStructures", "Dates", "Distributions", "Graphs", "HTTP", "JSON", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SHA", "StringDistances", "UUIDs"]
git-tree-sha1 = "4d7ff3b02a9cbd6d4f65fea43551b0bca6a83a22" git-tree-sha1 = "129b8fa1bf3bf6d8c0a080f39db09b9b986ef8de"
repo-rev = "main" repo-rev = "main"
repo-url = "https://git.yiem.cc/ton/GeneralUtils" repo-url = "https://git.yiem.cc/ton/GeneralUtils"
uuid = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" uuid = "c6c72f09-b708-4ac8-ac7c-2084d70108fe"
version = "0.6.9" version = "0.5.11"
[[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.Graphs]]
deps = ["ArnoldiMethod", "DataStructures", "Inflate", "LinearAlgebra", "Random", "SimpleTraits", "SparseArrays", "Statistics"] deps = ["ArnoldiMethod", "DataStructures", "Inflate", "LinearAlgebra", "Random", "SimpleTraits", "SparseArrays", "Statistics"]
@@ -361,9 +315,9 @@ version = "1.14.0"
[[deps.HTTP]] [[deps.HTTP]]
deps = ["Base64", "CodecZlib", "Dates", "EnumX", "PrecompileTools", "Random", "Reseau", "SHA", "URIs", "UUIDs", "Zlib_jll"] deps = ["Base64", "CodecZlib", "Dates", "EnumX", "PrecompileTools", "Random", "Reseau", "SHA", "URIs", "UUIDs", "Zlib_jll"]
git-tree-sha1 = "49a90cd540681d5b79bf5c8ca13c92677e37005e" git-tree-sha1 = "0a58fbbdee93d132a2fb1159b7f5e1b5c2465e71"
uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3"
version = "2.6.6" version = "2.6.4"
[[deps.HashArrayMappedTries]] [[deps.HashArrayMappedTries]]
git-tree-sha1 = "2eaa69a7cab70a52b9687c8bf950a5a93ec895ae" git-tree-sha1 = "2eaa69a7cab70a52b9687c8bf950a5a93ec895ae"
@@ -393,11 +347,6 @@ git-tree-sha1 = "d1b1b796e47d94588b3757fe84fbf65a5ec4a80d"
uuid = "d25df0c9-e2be-5dd7-82c8-3ad0b3e990b9" uuid = "d25df0c9-e2be-5dd7-82c8-3ad0b3e990b9"
version = "0.1.5" version = "0.1.5"
[[deps.IniFile]]
git-tree-sha1 = "f550e6e32074c939295eb5ea6de31849ac2c9625"
uuid = "83e8ac13-25f8-5344-8a64-a9f2b223428f"
version = "0.5.1"
[[deps.InlineStrings]] [[deps.InlineStrings]]
git-tree-sha1 = "8f3d257792a522b4601c24a577954b0a8cd7334d" git-tree-sha1 = "8f3d257792a522b4601c24a577954b0a8cd7334d"
uuid = "842dd82b-1e85-43dc-bf29-5d0ee9dffc48" uuid = "842dd82b-1e85-43dc-bf29-5d0ee9dffc48"
@@ -457,9 +406,9 @@ version = "1.8.0"
[[deps.JSON]] [[deps.JSON]]
deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"] deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"]
git-tree-sha1 = "c7345ab1a7ca4dc8a02c9f6510da0d9857bbe513" git-tree-sha1 = "65979512c25a0727f050e6e4be40f0fd9ec893f7"
uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
version = "1.7.1" version = "1.7.0"
weakdeps = ["ArrowTypes"] weakdeps = ["ArrowTypes"]
[deps.JSON.extensions] [deps.JSON.extensions]
@@ -492,10 +441,18 @@ git-tree-sha1 = "0f2899fdadaab4b8f57db558ba21bdb4fb52f1f0"
uuid = "b39eb1a6-c29a-53d7-8c32-632cd16f18da" uuid = "b39eb1a6-c29a-53d7-8c32-632cd16f18da"
version = "1.21.3+0" 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]] [[deps.LaTeXStrings]]
git-tree-sha1 = "f88f3ccef05a6a72a0cf0ed417c8fd68530f4ab2" git-tree-sha1 = "dda21b8cbd6a6c40d9d02a73230f9d70fed6918c"
uuid = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f" uuid = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f"
version = "1.4.1" version = "1.4.0"
[[deps.LayerDicts]] [[deps.LayerDicts]]
git-tree-sha1 = "6087ad3521d6278ebe5c27ae55e7bbb15ca312cb" git-tree-sha1 = "6087ad3521d6278ebe5c27ae55e7bbb15ca312cb"
@@ -543,12 +500,6 @@ version = "1.11.3+1"
uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb"
version = "1.11.0" 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.LinearAlgebra]]
deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"]
uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
@@ -580,18 +531,6 @@ git-tree-sha1 = "1d4c737ab26f51ceed52ab2019c09b7660eb7440"
uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b" uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b"
version = "3.8.0" version = "3.8.0"
[[deps.Lz4_jll]]
deps = ["Artifacts", "JLLWrappers", "Libdl"]
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]] [[deps.MacroTools]]
git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522"
uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09"
@@ -680,7 +619,7 @@ version = "0.8.7+0"
[[deps.OpenSSL_jll]] [[deps.OpenSSL_jll]]
deps = ["Artifacts", "Libdl"] deps = ["Artifacts", "Libdl"]
uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95" uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95"
version = "3.5.6+0" version = "3.5.4+0"
[[deps.OpenSpecFun_jll]] [[deps.OpenSpecFun_jll]]
deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl"] deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl"]
@@ -709,6 +648,15 @@ git-tree-sha1 = "3de8f5e6e90ebfa8d6d1f86997d6cdcd6a912ff3"
uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0"
version = "2.8.7" 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.PooledArrays]]
deps = ["DataAPI", "Future"] deps = ["DataAPI", "Future"]
git-tree-sha1 = "36d8b4b899628fb92c2749eb488d884a926614d3" git-tree-sha1 = "36d8b4b899628fb92c2749eb488d884a926614d3"
@@ -734,9 +682,9 @@ version = "0.4.3"
[[deps.PrettyTables]] [[deps.PrettyTables]]
deps = ["Crayons", "LaTeXStrings", "Markdown", "PrecompileTools", "Printf", "REPL", "Reexport", "StringManipulation", "Tables"] deps = ["Crayons", "LaTeXStrings", "Markdown", "PrecompileTools", "Printf", "REPL", "Reexport", "StringManipulation", "Tables"]
git-tree-sha1 = "1b8aa19f229b1cea7fc93874a52e49db6a854450" git-tree-sha1 = "4ac881f5432bd93463a41767a814a45245be22b6"
uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d"
version = "3.4.8" version = "3.4.6"
[deps.PrettyTables.extensions] [deps.PrettyTables.extensions]
PrettyTablesExcelExt = "XLSX" PrettyTablesExcelExt = "XLSX"
@@ -797,20 +745,15 @@ version = "1.3.1"
[[deps.Reseau]] [[deps.Reseau]]
deps = ["NetworkOptions", "OpenSSL_jll", "PrecompileTools", "Random", "SHA"] deps = ["NetworkOptions", "OpenSSL_jll", "PrecompileTools", "Random", "SHA"]
git-tree-sha1 = "aaee74fab545e9af4b21d10c2203c93c636c4ae0" git-tree-sha1 = "701ef63506992668c0069a4d1ac1540b77258e6c"
uuid = "802f3686-a58f-41ce-bb0c-3c43c75bba36" uuid = "802f3686-a58f-41ce-bb0c-3c43c75bba36"
version = "1.4.1" version = "1.3.6"
[[deps.Retry]]
git-tree-sha1 = "41ac127cd281bb33e42aba46a9d3b25cd35fc6d5"
uuid = "20febd7b-183b-5ae2-ac4a-720e7ce64774"
version = "0.4.1"
[[deps.Revise]] [[deps.Revise]]
deps = ["CRC32c", "CodeTracking", "FileWatching", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"] deps = ["CRC32c", "CodeTracking", "FileWatching", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"]
git-tree-sha1 = "ab0f5630e37968bc49b8c4cedd3279a7d30b9486" git-tree-sha1 = "ec46aed6a3a8cc6b67839ca361e7b4aa32eaeee1"
uuid = "295af30f-e4ad-537b-8983-00126c2a3abe" uuid = "295af30f-e4ad-537b-8983-00126c2a3abe"
version = "3.17.0" version = "3.16.3"
weakdeps = ["Distributed"] weakdeps = ["Distributed"]
[deps.Revise.extensions] [deps.Revise.extensions]
@@ -830,9 +773,9 @@ version = "0.5.2+0"
[[deps.Roots]] [[deps.Roots]]
deps = ["Accessors", "CommonSolve", "Printf"] deps = ["Accessors", "CommonSolve", "Printf"]
git-tree-sha1 = "13a9e0164267bb9ebc55c1c5d72fceea8f09555b" git-tree-sha1 = "7fb25a964849d90a0446366cdefca822e0e84900"
uuid = "f2b01f46-fcfa-551c-844a-d8ac1e96c665" uuid = "f2b01f46-fcfa-551c-844a-d8ac1e96c665"
version = "3.0.7" version = "3.0.6"
[deps.Roots.extensions] [deps.Roots.extensions]
RootsChainRulesCoreExt = "ChainRulesCore" RootsChainRulesCoreExt = "ChainRulesCore"
@@ -854,6 +797,14 @@ version = "3.0.7"
uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce"
version = "0.7.0" 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]] [[deps.SQLStrings]]
git-tree-sha1 = "55de0530689832b1d3d43491ee6b67bd54d3323c" git-tree-sha1 = "55de0530689832b1d3d43491ee6b67bd54d3323c"
uuid = "af517c2e-c243-48fa-aab8-efac3db270f5" uuid = "af517c2e-c243-48fa-aab8-efac3db270f5"
@@ -897,12 +848,6 @@ git-tree-sha1 = "907703e0d50846f300650d7225bdcab145b7bca9"
uuid = "4f5b5e99-b0ad-42cd-b47a-334e172ec8bd" uuid = "4f5b5e99-b0ad-42cd-b47a-334e172ec8bd"
version = "1.1.2" 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.SortingAlgorithms]]
deps = ["DataStructures"] deps = ["DataStructures"]
git-tree-sha1 = "13cd91cc9be159e3f4d95b857fa2aa383b53772a" git-tree-sha1 = "13cd91cc9be159e3f4d95b857fa2aa383b53772a"
@@ -916,9 +861,9 @@ version = "1.12.0"
[[deps.SpecialFunctions]] [[deps.SpecialFunctions]]
deps = ["IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"] deps = ["IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"]
git-tree-sha1 = "429071b23f4c9a13fb6582f807cc2ef454082408" git-tree-sha1 = "c3ac026e735264e9bdc6a9bcbd1b1e781b36e3bc"
uuid = "276daf66-3868-5448-9aa4-cd146d93841b" uuid = "276daf66-3868-5448-9aa4-cd146d93841b"
version = "2.9.0" version = "2.8.3"
[deps.SpecialFunctions.extensions] [deps.SpecialFunctions.extensions]
SpecialFunctionsChainRulesCoreExt = "ChainRulesCore" SpecialFunctionsChainRulesCoreExt = "ChainRulesCore"
@@ -928,9 +873,9 @@ version = "2.9.0"
[[deps.StaticArrays]] [[deps.StaticArrays]]
deps = ["LinearAlgebra", "PrecompileTools", "Random", "StaticArraysCore"] deps = ["LinearAlgebra", "PrecompileTools", "Random", "StaticArraysCore"]
git-tree-sha1 = "fac51faf3bb96e8bc0bf6f9f39ca4955652776bb" git-tree-sha1 = "246a8bb2e6667f832eea063c3a56aef96429a3db"
uuid = "90137ffa-7385-5640-81b9-e52037218182" uuid = "90137ffa-7385-5640-81b9-e52037218182"
version = "1.9.19" version = "1.9.18"
[deps.StaticArrays.extensions] [deps.StaticArrays.extensions]
StaticArraysChainRulesCoreExt = "ChainRulesCore" StaticArraysChainRulesCoreExt = "ChainRulesCore"
@@ -947,9 +892,9 @@ version = "1.4.4"
[[deps.Statistics]] [[deps.Statistics]]
deps = ["LinearAlgebra"] deps = ["LinearAlgebra"]
git-tree-sha1 = "389592b8592e5fb48f498ff60dc6acb4a0e62953" git-tree-sha1 = "ae3bb1eb3bba077cd276bc5cfc337cc65c3075c0"
uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
version = "1.11.4" version = "1.11.1"
weakdeps = ["SparseArrays"] weakdeps = ["SparseArrays"]
[deps.Statistics.extensions] [deps.Statistics.extensions]
@@ -963,9 +908,9 @@ version = "1.8.0"
[[deps.StatsBase]] [[deps.StatsBase]]
deps = ["AliasTables", "DataAPI", "DataStructures", "IrrationalConstants", "LinearAlgebra", "LogExpFunctions", "Missings", "Printf", "Random", "SortingAlgorithms", "SparseArrays", "Statistics", "StatsAPI"] deps = ["AliasTables", "DataAPI", "DataStructures", "IrrationalConstants", "LinearAlgebra", "LogExpFunctions", "Missings", "Printf", "Random", "SortingAlgorithms", "SparseArrays", "Statistics", "StatsAPI"]
git-tree-sha1 = "adb9da019510162e67a4493fc235c23203d8b09e" git-tree-sha1 = "e4d7a1a0edc20af42689ea6f4f3587a2175d50ee"
uuid = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" uuid = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91"
version = "0.34.13" version = "0.34.12"
[[deps.StatsFuns]] [[deps.StatsFuns]]
deps = ["HypergeometricFunctions", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"] deps = ["HypergeometricFunctions", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"]
@@ -983,20 +928,15 @@ version = "2.2.1"
[[deps.StringDistances]] [[deps.StringDistances]]
deps = ["Distances", "StatsAPI"] deps = ["Distances", "StatsAPI"]
git-tree-sha1 = "456f343c7a6c8f52d8b501c0da2a05538ab1b907" git-tree-sha1 = "cd83a04baf746e3b43b83c61b7de77ab0409b80a"
uuid = "88034a9c-02f8-509d-84a9-84ec65e18404" uuid = "88034a9c-02f8-509d-84a9-84ec65e18404"
version = "1.0.1" version = "1.0.0"
[[deps.StringManipulation]] [[deps.StringManipulation]]
deps = ["PrecompileTools"] deps = ["PrecompileTools"]
git-tree-sha1 = "773065c6e0e903924a9d838259be74338422aef2" git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484"
uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e" uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e"
version = "0.5.0" version = "0.4.7"
[[deps.StringViews]]
git-tree-sha1 = "f2dcb92855b31ad92fe8f079d4f75ac57c93e4b8"
uuid = "354b36f9-a18e-4713-926e-db85100087ba"
version = "1.3.7"
[[deps.StructTypes]] [[deps.StructTypes]]
deps = ["Dates", "UUIDs"] deps = ["Dates", "UUIDs"]
@@ -1006,9 +946,9 @@ version = "1.11.0"
[[deps.StructUtils]] [[deps.StructUtils]]
deps = ["Dates", "UUIDs"] deps = ["Dates", "UUIDs"]
git-tree-sha1 = "2d0fc55c61321ba245c47be599570d11bac50303" git-tree-sha1 = "c65ae4aa47e543c278aea0a3468786d33021a3ff"
uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42"
version = "2.8.5" version = "2.8.4"
[deps.StructUtils.extensions] [deps.StructUtils.extensions]
StructUtilsMeasurementsExt = ["Measurements"] StructUtilsMeasurementsExt = ["Measurements"]
@@ -1033,12 +973,6 @@ deps = ["Artifacts", "Libdl", "libblastrampoline_jll"]
uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c"
version = "7.8.3+2" 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.TOML]]
deps = ["Dates"] deps = ["Dates"]
uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
@@ -1046,9 +980,9 @@ version = "1.0.3"
[[deps.TZJData]] [[deps.TZJData]]
deps = ["Artifacts"] deps = ["Artifacts"]
git-tree-sha1 = "d3b30a9f29a898e21fb4bdf280101b7a12e1f39c" git-tree-sha1 = "72df96b3a595b7aab1e101eb07d2a435963a97e2"
uuid = "dc5dba14-91b3-4cab-a142-028a31da12f7" uuid = "dc5dba14-91b3-4cab-a142-028a31da12f7"
version = "1.5.1+2025b" version = "1.5.0+2025b"
[[deps.TableTraits]] [[deps.TableTraits]]
deps = ["IteratorInterfaceExtensions"] deps = ["IteratorInterfaceExtensions"]
@@ -1058,9 +992,14 @@ version = "1.0.1"
[[deps.Tables]] [[deps.Tables]]
deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "OrderedCollections", "TableTraits"] deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "OrderedCollections", "TableTraits"]
git-tree-sha1 = "a94d9bdda1b7bed0046cea645639ab3f62196fac" git-tree-sha1 = "0f38a06c83f0007bbab3cf911262841c9a0f07e0"
uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c"
version = "1.14.0" version = "1.13.0"
[[deps.Tar]]
deps = ["ArgTools", "SHA"]
uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e"
version = "1.10.0"
[[deps.Test]] [[deps.Test]]
deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] deps = ["InteractiveUtils", "Logging", "Random", "Serialization"]
@@ -1083,9 +1022,9 @@ uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa"
version = "0.11.3" version = "0.11.3"
[[deps.URIs]] [[deps.URIs]]
git-tree-sha1 = "908fec9df6c5de98548ead82a468c95ccf6cd263" git-tree-sha1 = "3b0738bd7c5645641845da25cbd99800b8718689"
uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
version = "1.7.0" version = "1.6.2"
[[deps.UTCDateTimes]] [[deps.UTCDateTimes]]
deps = ["Dates", "TimeZones"] deps = ["Dates", "TimeZones"]
@@ -1113,20 +1052,8 @@ git-tree-sha1 = "cd1659ba0d57b71a464a29e64dbc67cfe83d54e7"
uuid = "76eceee3-57b5-4d4a-8e66-0e911cebbf60" uuid = "76eceee3-57b5-4d4a-8e66-0e911cebbf60"
version = "1.6.1" 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.YiemAgent]]
deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "HTTP", "JSON", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "Serialization", "URIs", "UUIDs", "msghandler"] deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "GeneralUtils", "HTTP", "JSON", "LLMMCTS", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SQLLLM", "Serialization", "URIs", "UUIDs"]
path = "." path = "."
uuid = "e012c34b-7f78-48e0-971c-7abb83b6f0a2" uuid = "e012c34b-7f78-48e0-971c-7abb83b6f0a2"
version = "0.8.0" version = "0.8.0"
@@ -1153,14 +1080,6 @@ git-tree-sha1 = "011b0a7331b41c25524b64dc42afc9683ee89026"
uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8" uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8"
version = "1.0.21+0" version = "1.0.21+0"
[[deps.msghandler]]
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.4.0"
[[deps.nghttp2_jll]] [[deps.nghttp2_jll]]
deps = ["Artifacts", "Libdl"] deps = ["Artifacts", "Libdl"]
uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
+6 -4
View File
@@ -1,6 +1,6 @@
name = "YiemAgent" name = "YiemAgent"
uuid = "e012c34b-7f78-48e0-971c-7abb83b6f0a2" uuid = "e012c34b-7f78-48e0-971c-7abb83b6f0a2"
version = "0.9.1" version = "0.8.0"
authors = ["narawat lamaiin <narawat@outlook.com>"] authors = ["narawat lamaiin <narawat@outlook.com>"]
[deps] [deps]
@@ -12,22 +12,24 @@ Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe"
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
LLMMCTS = "d76c5a4d-449e-4835-8cc4-dd86ec44f241"
LibPQ = "194296ae-ab2e-5f79-8cd4-7183a0a5a0d1" LibPQ = "194296ae-ab2e-5f79-8cd4-7183a0a5a0d1"
NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a" NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a"
PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337" PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3"
Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
msghandler = "f2724d33-f338-4a57-b9f8-1be882570d10"
[compat] [compat]
Base64 = "1.11.0" Base64 = "1.11.0"
CSV = "0.10.15" CSV = "0.10.15"
DataFrames = "1.7.0" DataFrames = "1.7.0"
GeneralUtils = "0.6.9" GeneralUtils = "0.5.10"
HTTP = "2.4.0" HTTP = "2.4.0"
JSON = "1.6.1" JSON = "1.6.1"
LLMMCTS = "0.1.5"
NATS = "0.1.0" NATS = "0.1.0"
msghandler = "1.4.0" SQLLLM = "0.2.8"
+23 -300
View File
@@ -1,311 +1,34 @@
# YiemAgent # YiemAgent
Julia framework for building agents with tool use and MCP (Model Context Protocol) support. Julia framework for building agents with tool use.
## Quick Start ## Getting Started
```julia 1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...`
using YiemAgent 2. Create a `yiemAgent` with `loadTools("src/tools")`
3. Call `runAgent(agent, "message")` then `takeResponse(agent)`
# 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 ## Architecture
``` ```
src/ src/
├── YiemAgent.jl # Module entry point ├── YiemAgent.jl # Module entry point
├── type.jl # Core types (messages, tools, agent state, events) ├── type.jl # Core types (messages, tools, agent state)
├── utils.jl # Message formatting, context preparation, validation ├── utils.jl # Message formatting, validation
├── toolRegistry.jl # Tool store, MCP discovery, listTools registration ├── agentCore.jl # Agent loop, tool execution pipeline
├── agentCore.jl # Agent loop, tool execution pipeline, yiemAgent struct ├── api.jl # Public API (runAgent, takeResponse, etc.)
└── api.jl # Public API (promptAgent, takeResponse, followUp, stopAgent) └── 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
``` ```
## Tool Development
See `src/tools/README.md` for:
- Tool anatomy (schema, execute, getTool)
- Validation hooks
- Agent loop lifecycle
- Self-modifying tools (`writeTool`)
+1625
View File
File diff suppressed because it is too large Load Diff
-155
View File
@@ -1,155 +0,0 @@
# YiemAgent: Agile Agent Framework
## 1. Business Context & Success Metrics
### Business Goal
YiemAgent is a Julia-based AI agent framework that enables building chatbot assistants with tool-calling capabilities, model-agnostic LLM integration, and runtime tool discovery via MCP (Model Context Protocol). It provides an asynchronous agent loop with channel-based message passing, supporting both single-turn and multi-turn conversations with automatic tool call resolution.
### User Stories
- **US-001**: As a developer, I want to create an AI agent with a simple constructor so that I can quickly prototype chatbot functionality in Julia
- **US-002**: As a developer, I want the agent to discover tools automatically from an MCP server so that I don't need to manually register tool APIs
- **US-003**: As a developer, I want the agent to handle tool calls automatically in a loop so that the LLM can use tools without external orchestration
- **US-004**: As a developer, I want to intercept tool calls with before/after hooks so that I can add user approval, logging, or result sanitization
- **US-005**: As a developer, I want the framework to support multiple LLM models and providers through a pluggable `llmCall` interface so that I can swap models without changing agent logic
### KPIs & Targets
- **KPI-001**: Agent loop processes messages with <500ms overhead for sequential tool calls (validates US-003)
- **KPI-002**: MCP tool discovery completes within 2 seconds for 50+ tools (validates US-002)
- **KPI-003**: 100% of message types have OpenAI format conversion (validates US-001)
## 2. Technical Boundaries
### In Scope
- Julia module implementing an AI agent with asynchronous message processing
- Channel-based input (user→agent), follow-up (async queue), and output (agent→user) interfaces
- LLM model abstraction with tool call extraction from multiple response formats (OpenAI content blocks, LMStudio/vLLM format)
- Tool registry with MCP server integration (tools/list, tools/call via JSON-RPC 2.0)
- Agent loop with before/after tool call hooks for lifecycle management
- Message type system with OpenAI format conversion utilities
- Abort signal and termination flag support via tool implementations
### Out of Scope
- GUI/CLI interface (exposed via channels only; UI layer is external)
- Persistent chat history or long-term memory (TODO comments in code)
- Multi-agent orchestration (single agent instance per `yiemAgent` struct)
- Embedding generation or retrieval-augmented generation (RAG)
- Natural language-to-code generation or execution sandboxes
### Dependencies
- **Julia Standard Library**: `Dates`, `UUIDs`, `Base.Threads`
- **External Packages**: `JSON`, `DataStructures`, `HTTP`, `PrettyPrinting`, `Serialization`, `DataFrames`
- **Internal Package**: `GeneralUtils` (project-specific utility library)
- **Optional**: `NATS` (MCP server communication), `LibPQ` (PostgreSQL)
### Deployment Constraints
- **NFR-001**: Library designed for embedded use in Docker containers (MicroK8s cluster, per `NFR-501` from template)
- **NFR-002**: Must support Julia 1.9+ for module include/export semantics compatibility
- **NFR-003**: Thread-safe channel operations; single-agent instance processes messages sequentially on background `@spawn` task
## 3. Functional Requirements (FR)
### FR-001: Agent Message Processing
The agent shall process user messages through an asynchronous loop that:
- Accepts messages via `inputChannel` (capacity 16) and `followUpChannel` (capacity 32)
- Prioritizes `inputChannel` messages over `followUpChannel` messages
- Feeds responses to `outputChannel` (capacity 16) in OpenAI message format
- Exits cleanly on `:shutdown` signal
**Traceability**: US-001, US-003
### FR-002: LLM Model Agnosticism
The agent shall accept a pluggable `llmCall` function that:
- Receives formatted messages and returns an LLM response dict
- The response dict may contain `message.tool_calls` (LMStudio format) or `content` blocks (OpenAI format)
- The framework extracts tool calls regardless of response format
**Traceability**: US-005
### FR-003: Tool Call Execution Loop
The agent shall automatically execute tool calls returned by the LLM:
- Extract tool calls via `_extractToolCalls()` which supports both `message.tool_calls` and `content[{type="tool_calls"}]` formats
- Build `agentContext` via `prepareContext()` (pluggable hook)
- Format messages via `formatMsgForLLM()` to OpenAI format
- Extract tool calls → loop: execute tool calls → append results to history → call LLM again
- Break loop when LLM response has no tool calls
- Support `terminate` flag: if `toolResultBatch.terminate == true`, break loop and return final response
**Traceability**: US-003
### FR-004: Tool Discovery and Registration
The agent shall:
- Query MCP server via `mcpserver("tools/list")` (JSON-RPC 2.0) to discover tools
- Support pagination via `nextCursor` field
- Wrap each MCP tool definition as an `agentTool` with `execute` hook that calls `mcpserver("tools/call", name, args, sessionId)`
- Register a builtin `listTools` tool that triggers runtime discovery and lists all available tools
- Skip tools already registered (idempotent)
**Traceability**: US-002
### FR-005: Tool Call Lifecycle Hooks
The agent shall support two lifecycle hooks:
- `beforeToolCall(beforeToolCallContext, signal)` — can block execution by returning `beforeToolCallResult(true, reason)`
- `afterToolCall(afterToolCallContext)` — can transform result by returning modified `agentToolResult`
- Errors from hooks are caught and converted to `immediateOutcome`/error tool result messages (never throw)
**Traceability**: US-004
### FR-006: Message Type Conversion
The agent shall:
- Convert internal message types (`userMessage`, `assistantMessage`, `assistantMessageToolCall`, `toolResultMessage`) to OpenAI format via `_userMessageToOpenAI()`, `_assistantMessageToOpenAI()`, `_assistantMessageToolCallToOpenAI()`, `_toolResultMessageToOpenAI()`
- Convert internal `messageContent` types (`textContent`, `imageContent`, `reasoningContent`) to OpenAI content blocks via `_messageContentToBlocks()`
- Convert `agentTool` definitions to OpenAI function tool format via `_toolsToOpenAI()`
**Traceability**: US-001
## 4. Non-Functional Requirements (NFRs)
### 4.1 Performance & Scalability
- **NFR-101**: Agent loop message processing overhead <500ms per sequential tool call (measured KPI: KPI-001)
- **NFR-102**: MCP tool discovery for 50+ tools completes within 2 seconds (measured KPI: KPI-002)
- **NFR-103**: Channel buffers prevent unbounded memory growth — `inputChannel` (16), `followUpChannel` (32), `outputChannel` (16)
### 4.2 Availability & Reliability
- **NFR-201**: Agent loop catches all errors via `try/catch` in `_agentLoop()` and calls `eventSink(errMsg)` before exit (measured by event sink logging)
- **NFR-202**: `prepareToolCall()` never throws errors — validation failures return `immediateOutcome` with error tool result
### 4.3 Privacy & Security
- **Data Classification**: Tool arguments and results may contain PII (user messages); MCP tool results treated as untrusted input
- **No built-in auth/authz**: Responsibility of `mcpServer` implementation and `beforeToolCall` hook
- **Input validation**: JSON schema validation via `validateToolArguments()` checks required fields only (tools need custom validation via `validateRequiredArgs` hook)
### 4.4 Observability & Telemetry
- **Required Logs**: `agentLoop` events (via `eventSink`) emit state machine steps (`_agentLoop 1` through `_agentLoop 6`), `_processMessage` steps (1 through 18), `prepareToolCall` steps (1 through 10)
- **Critical Metrics**:
- `agent._state.messages.length` — conversation history size (logged at each step)
- `toolExecutionStart`/`toolExecutionEnd` events (via `toolExecStartEvent`, `toolExecEndEvent` structs)
- **Retention**: No built-in log retention; agent is a library, does not manage external logging
## 5. Acceptance Conditions
- [ ] **FR-001**: Agent processes messages through channel system with priority ordering (inputChannel > followUpChannel)
- [ ] **FR-002**: `llmCall` is pluggable; response formats supported include `message.tool_calls` dict and `content` array blocks
- [ ] **FR-003**: Agent loops on tool calls until LLM returns no tool calls or terminate flag is set
- [ ] **FR-004**: MCP tools are discovered via `tools/list` with pagination; all registered tools are callable via `tools/call`
- [ ] **FR-005**: `beforeToolCall` hook can block; `afterToolCall` hook can transform result; errors return error tool result
- [ ] **FR-006**: All message types (userMessage, assistantMessage, assistantMessageToolCall, toolResultMessage) convert to OpenAI format
- [ ] **NFR-101**: Message processing overhead <500ms per sequential tool call
- [ ] **NFR-102**: MCP discovery for 50+ tools completes <2 seconds
- [ ] **NFR-201**: Agent loop catches errors and exits gracefully without panics
-119
View File
@@ -1,119 +0,0 @@
# Solution Design: YiemAgent Agent Framework
## 1. Problem Decomposition
| Problem | Description | User Impact |
|---------|-------------|-------------|
| P-001 | Developers need a chatbot framework in Julia that handles LLM calls and tool calls without writing boilerplate | Must write separate request/response handling, tool call parsing, and loop management code for each agent |
| P-002 | Tool discovery is manual and repetitive | Must register every tool individually; no automatic discovery from MCP-compatible servers |
| P-003 | Multiple LLM providers have different response formats | Must handle `message.tool_calls` (LMStudio/vLLM) vs `content` blocks (OpenAI API) separately |
| P-004 | Tool execution needs lifecycle hooks for observability and control | No built-in before/after hooks for authorization, logging, or result sanitization |
| P-005 | Agent state management is error-prone | Must handle message history, tool registration, and pending tool call tracking manually |
## 2. Solution Approach
**Approach**: "Build a channel-based agent system where the LLM talks to tools automatically, tools talk to MCP servers automatically, and you compose agents by injecting `llmCall` and `mcpServer` functions — the framework owns the loop, the state, and the format conversion."
**Key Principles**:
- **Channel-based async processing**: Each agent owns three channels (`inputChannel`, `followUpChannel`, `outputChannel`); a background `@spawn` task runs the agent loop, processing messages in priority order
- **Tool call loop**: The agent calls `llmCall()`, extracts tool calls, executes them (sequentially or in parallel), appends results, and calls `llmCall()` again — repeating until no tool calls remain
- **Pluggable hooks**: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, and `llmCall` are all injectable functions — the framework provides sensible defaults, but you override what you need
- **MCP-native tool discovery**: Tools are discovered at startup via MCP `tools/list` and can be discovered at runtime via the `listTools` builtin tool (idempotent registration with pagination support)
- **Format-agnostic LLM responses**: `_extractToolCalls()` handles both OpenAI content blocks and LMStudio/vLLM nested message format, allowing the same agent to work with any model provider
## 3. Decision Rationale
| Decision ID | Decision | Rationale | Alternatives Rejected |
|-------------|----------|-----------|----------------------|
| SD-001 | Use channel-based async processing with `@spawn` task | Provides non-blocking message queues; `inputChannel` blocks if full (backpressure); `followUpChannel` allows async follow-ups while agent is busy | Threading with shared state — race conditions; callback-based async — hard to compose |
| SD-002 | Background `_agentLoop` task runs message dispatch loop | Isolates message processing from construction; agent can be created and used concurrently | Synchronous agent.run(message) — blocks caller; manual task management — boilerplate |
| SD-003 | `_processMessage` runs `llmCall()` in an inner loop (while loop) until no tool calls remain | Handles multi-turn tool use transparently — agent decides when to stop based on LLM response | External loop in caller code — duplicates agent loop logic; single-turn only — no multi-step tool use |
| SD-004 | MCP server integration via callable struct `mcpServer(method, args...)` | MCP is model-agnostic protocol; callable struct allows type-stable single dispatch; supports NATS transport (struct has `natsConn`, `topic`, `senderID` fields) | Ad-hoc HTTP client for MCP — breaks NATS-only transports; tight coupling to specific transport |
| SD-005 | Tool call lifecycle: `prepareToolCall()` → `executePreparedToolCall()` → `finalizeExecutedToolCall()` — three phases, never throw errors | Preparation validates args and runs `beforeToolCall` hook (can block); execution calls `tool.execute()`; finalization runs `afterToolCall` hook. Each phase returns a result type, not exceptions — agent loop catches all and builds error tool result | `try/catch` in loop — loses context; throwing errors breaks tool result message contract |
| SD-006 | Message types are Julia structs with keyword constructors (`userMessage(content=[...])`) | Type stable; keyword constructors provide defaults (`role="user"`, `timestamp=now()`); supports both positional and keyword usage | `Dict{String, Any}` for messages — lose type safety, docgen, IDE support |
| SD-007 | `eventSink` function passed to all deep lifecycle calls | Provides hooks for external monitoring (logging, metrics, NATS pub/sub) without modifying core logic | Built-in `println` — can't disable or redirect; no metrics integration |
| SD-008 | `abortSignal` struct with `.aborted::Bool` field passed to `prepareToolCall()` and hooks | Simple mutable state for interrupting long-running tool calls (no async cancellation — just flag check) | Threading with `Task.cancel()` — Julia thread model doesn't support it; `@async` spawn — complex cleanup |
| SD-009 | `_extractToolCalls()` supports both `message.tool_calls` and `content[{type="tool_calls"}]` formats | LMStudio.jl/vLLM vs OpenAI API have different response shapes; framework must work with both | Separate parser per provider — maintenance burden; require provider wrapper — adds indirection |
## 4. High-Level Component Diagram
```mermaid
flowchart TD
User[External Caller] -->|promptAgent / followUp| InputCh[inputChannel]
subagent AgentLoop [Agent Loop Task]
InputCh -->|take!| Disp[Message Dispatcher]
Disp -->|spawn| Proc[_processMessage]
Proc -->|Channel| PrCh[processMessageInputCh]
end
subagent Message Pipeline
PrCh -->|put!| DMsg[Drain input, convert to userMessage]
DMsg --> PC[prepareContext hook]
PC --> FM[formatMsgForLLM hook]
FM --> LLM[llmCall]
LLM --> EC[_extractToolCalls]
EC -->|hasToolCalls| TC[executeToolCalls]
TC -->|toolResultBatch| TM[toolResultMessage]
TM -->|push!| MH[messageHistory]
TC -->|no tool calls break| BRK[break loop]
BRK --> End[exit]
end
subagent Tool System
TC --> Prep[prepareToolCall]
Prep --> Exec[executePreparedToolCall]
Exec --> Term[finalizeExecutedToolCall]
Term --> CRM[createToolResultMessage]
CRM --> TM
end
subagent MCP Integration
Prep --> ToolLookup[context.tools lookup]
ToolLookup --> MCP[MCP tools/list + tools/call]
MCP --> Wr[_wrap_mcp_tool]
Wr --> Reg[toolRegistry.registerAllTools]
end
OutputCh[outputChannel] --> User
Proc -->|final response| OutputCh
```
**Component Descriptions**:
- **Agent Loop Task** (`SD-001`, `SD-002`): Runs `@spawn _agentLoop()`; manages three channels; dispatches messages to `_processMessage`
- **Message Pipeline** (`SD-003`, `SD-004`): `prepareContext` → `formatMsgForLLM` → `llmCall` → extract tool calls → loop
- **Tool System** (`SD-005`): Three-phase lifecycle (prepare → execute → finalize); never throws; returns error tool result on failure
- **MCP Integration** (`SD-004`): `tools/list` discovery with pagination; `_wrap_mcp_tool` creates executable `agentTool` with MCP JSON-RPC calls
## 5. Alternatives Considered
| Alternative | Pros | Cons | Decision |
|-------------|------|------|----------|
| Synchronous `agent.process(message)` | Simpler API; no channels or tasks | Blocks caller; no follow-up support; harder to test concurrency | **Rejected** — async channels needed for real-time chat UX |
| JSON dicts for messages everywhere | Flexible; no type definitions | No static checking; no docgen; no IDE autocomplete; harder to serialize/deserialize | **Rejected** — Julia advantages of structs (type-stable, documented) outweigh cost |
| Single `onToolCall` hook (pre-execute only) | Simpler hook interface | Can't sanitize results post-execution; can't set terminate flag after tool completes | **Rejected** — `afterToolCall` needed for masking secrets, normalizing usage, inspecting error status |
| Direct HTTP calls to MCP servers (no NATS) | Simpler dependency (no NATS) | Breaks if MCP uses NATS (common in microservices); no single transport | **Rejected** — callable struct abstracts transport; NATS is one implementation option |
| External orchestrator runs tool call loop | Caller has full control | Duplicates logic; every caller must implement same loop; harder to maintain | **Rejected** — loop is agent framework responsibility (single source of truth) |
## 6. Risk Assessment
| Risk | Impact | Probability | Mitigation |
|------|--------|-------------|------------|
| `llmCall` throws (network timeout, API error) | Agent loop exits with error in `eventSink` | Medium | `try/catch` in `_processMessage` and `_agentLoop`; eventSink receives error string |
| Tool `prepareArguments` or `validateRequiredArgs` hook throws | `prepareToolCall` catches and returns `immediateOutcome` error | Low | Hook errors caught; error tool result sent to LLM for recovery |
| MCP server unreachable | Tool discovery returns 0 tools; `listTools` still available | Medium | `register_mcp_tools` returns 0 on error; tool execution calls catch errors |
| Long-running tool call hangs | Caller blocks on `outputChannel`; no built-in timeout | Medium | `eventSink` passes abort signal; caller sets `signal.aborted = true` externally |
| Multiple agents share same `mcpServer` struct | `mcpServer` must be thread-safe for concurrent tool calls | Low | Each thread gets own `mcpServer` instance; NATS handles concurrency |
| Message history grows unbounded | Memory exhaustion in long sessions | Medium | No built-in pruning; `prepareContext` can filter/prune messages (TODO in code) |
## 7. Requirements Traceability
| Solution Component | Requirement ID | Decision ID | Description |
|-------------------|----------------|-------------|-------------|
| Channel-based async processing | FR-001 | SD-001, SD-002 | `inputChannel`, `followUpChannel`, `outputChannel` with priority dispatch |
| LLM call loop | FR-003 | SD-003 | `_processMessage` while loop; `llmCall()` → extract → execute → append → repeat |
| MCP tool discovery | FR-004 | SD-004, SD-009 | `tools/list` with pagination; `tools/call` execution |
| Lifecycle hooks | FR-005 | SD-005 | `beforeToolCall` can block; `afterToolCall` transforms; never throw |
| Message type conversion | FR-006 | SD-006 | Julia structs with keyword constructors; `_userMessageToOpenAI`, `_messageContentToBlocks` |
| Format-agnostic extraction | FR-002 | SD-009 | `_extractToolCalls` handles both `message.tool_calls` and `content` blocks |
| Event sink observability | NFR-201, NFR-103 | SD-007 | `eventSink` passed to all lifecycle steps; logs state machine transitions |
-318
View File
@@ -1,318 +0,0 @@
# Walkthrough: YiemAgent Agent Framework
## Structural Guidance
- One **overview sequence diagram** per participant (high-level flow)
- **Detailed sub-diagrams** for complex interaction points (tool call loop, MCP discovery)
- Use sequence diagrams for request/response patterns; lifecycle states for multi-step operations
---
## External Caller
_The Developer calling `YiemAgent.jl` via `promptAgent`, `takeResponse`, `followUp`, and `stopAgent`._
### Session Start: Create Agent
**WT-001**: Developer creates an `yiemAgent` instance with `llmCall` and `mcpServer`
> Cross-reference: `2-solution_design.md:SD-001` (channel-based async), `SD-004` (MCP struct)
**Details**:
```julia
mcp = mcpServer(conn, topic, senderID)
agent = yiemAgent(llmCall, mcpServer=mcp, systemPrompt="You are a helpful assistant")
```
**Sequence**:
```mermaid
sequenceDiagram
participant Dev as Developer
part agent YiemAgent Constructor
Dev->>agent: yiemAgent(llmCall, ; mcpServer=mcp)
agent->>agent: create channels (input=16, followUp=32, output=16)
agent->>agent: registerAllTools(tools, mcp)
agent->>mcp: mcp("tools/list", sessionId)
mcp-->>agent: response (tools array + nextCursor)
agent->>agent: _wrap_mcp_tool for each tool
agent->>agent: registerTool(tools, listTools_tool)
agent->>agent: println("[toolRegistry] Discovered N MCP tools")
agent->>agent: yiemAgent struct with placeholder task
agent->>agent: @spawn _agentLoop(agent)
agent-->>Dev: yiemAgent instance
```
> **Traceability**: FR-004 (tool discovery), US-001 (agent creation)
### Send Message: User Prompt
**WT-002**: Developer sends a user message to the agent
> Cross-reference: `2-solution_design.md:SD-002` (background task dispatch)
**Sequence**:
```mermaid
sequenceDiagram
participant Dev as Developer
part agent AgentLoop
Dev->>agent: promptAgent(agent, text="What's the weather?")
agent->>agent: put!(inputChannel, msg_dict)
Dev->>agent: takeResponse(agent) # blocks
agent->>agent: inputChannel isready
agent->>agent: take!(inputChannel)
agent->>Dev: (returns agent for chaining)
```
> **Traceability**: FR-001 (channel processing), PR-001, SD-001, SD-002
### Receive Response
**WT-003**: Agent returns response on `outputChannel`
**Sequence**:
```mermaid
sequenceDiagram
participant Dev as Developer
part agent AgentLoop
AgentLoop->>AgentLoop: _processMessage runs
AgentLoop->>AgentLoop: while loop, llmCall, tool calls...
AgentLoop->>AgentLoop: no more tool calls → exit loop
AgentLoop->>AgentLoop: filter reasoningContent from final result
AgentLoop->>AgentLoop: _assistantMessageToOpenAI(result)
AgentLoop->>AgentLoop: put!(outputChannel, respondToUI)
AgentLoop->>Dev: take!(outputChannel) # unblocks
Dev->>Dev: receive OpenAI dict response
```
> **Traceability**: FR-001, FR-003, FR-006, PR-001
### Send Follow-up While Agent Busy
**WT-004**: Developer sends a follow-up message while agent is processing a tool call
**Sequence**:
```mermaid
sequenceDiagram
participant Dev as Developer
part agent AgentLoop
Dev->>agent: promptAgent(text="What's the weather in Tokyo?") # agent starts processing
AgentLoop->>AgentLoop: _processMessage started (processingTask = Task)
Dev->>agent: followUp(text="Also check Seoul") # while busy
agent->>agent: put!(followUpChannel, msg_dict)
AgentLoop->>AgentLoop: _processMessage completes (tool calls done)
AgentLoop->>AgentLoop: outputChannel has message → put!(outputChannel, response), take!(next msg)
AgentLoop->>AgentLoop: no inputChannel → check followUpChannel
AgentLoop->>AgentLoop: isready(followUpChannel) → take!(followUpMsg), put!(inputChannel, followUpMsg)
AgentLoop->>AgentLoop: processingTask = nothing → reset
AgentLoop->>AgentLoop: loop back → take!(inputChannel) from followUp
Dev->>agent: takeResponse() → blocks
AgentLoop->>Dev: next response via outputChannel
```
> **Traceability**: FR-001 (follow-up priority), PR-001, SD-001, SD-002
### Stop Agent
**WT-005**: Developer stops the agent gracefully
**Sequence**:
```mermaid
sequenceDiagram
participant Dev as Developer
part agent AgentLoop
Dev->>agent: stopAgent(agent)
agent->>agent: put!(inputChannel, :shutdown)
AgentLoop->>AgentLoop: check newUserMsg === :shutdown
AgentLoop->>AgentLoop: drain inputChannel (while isready)
AgentLoop->>AgentLoop: drain followUpChannel (while isready)
AgentLoop->>AgentLoop: break, catch, fetch(_agentLoop)
Dev->>agent: close(inputChannel, outputChannel, followUpChannel)
Dev-->>Dev: nothing (agent destroyed)
```
> **Traceability**: FR-001, NFR-201 (graceful exit), PR-001
---
## LLM Provider
_The external LLM API (e.g. Anthropic, OpenAI, LMStudio) receiving formatted messages and returning responses._
### Receive Formatted Message
**WT-010**: Agent sends LLM request in OpenAI format
> Cross-reference: `2-solution_design.md:SD-003` (formatMsgForLLM)
**Sequence**:
```mermaid
sequenceDiagram
part agent AgentLoop
AgentLoop->>AgentLoop: preparedContext = prepareContext(state)
AgentLoop->>AgentLoop: formattedMessages = formatMsgForLLM(preparedContext)
Note over AgentLoop: messages: [system, user, assistant, tool, ...]<br/>tools: [{"function": {"name": "getWeather", "parameters": {...}}}]
agentLoop->>llmCall: llmCall(formattedMessages, eventSink=eventSink)
llmCall->>Provider: HTTP POST /v1/chat/completions (JSON)
Provider-->>llmCall: JSON response
llmCall-->>AgentLoop: response = JSON.Object{String, Any}(...)
```
> **Traceability**: FR-002, SD-003, SD-009
### Receive Response: No Tool Calls
**WT-11**: Agent receives text-only response
**Sequence**:
```mermaid
sequenceDiagram
llmCall-->>AgentLoop: response = {finish_reason=> "stop", message=> {role=> "assistant", content=> "It's 22°C in Bangkok"}}
AgentLoop->>AgentLoop: _extractToolCalls(response)
AgentLoop->>AgentLoop: hasToolCalls = false
AgentLoop->>AgentLoop: assistant_msg = assistantMessage(content=[textContent("It's 22°C")], ...)
AgentLoop->>AgentLoop: push!(agentMsgHistory, assistant_msg)
AgentLoop->>AgentLoop: break (no tool calls)
AgentLoop->>AgentLoop: filter reasoningContent (if any)
AgentLoop->>AgentLoop: create final assistantMessage
AgentLoop->>AgentLoop: put!(outputChannel, OpenAI format dict)
```
> **Traceability**: FR-003 (loop exit), FR-006 (OpenAI conversion), PR-003
### Receive Response: With Tool Calls
**WT-012**: Agent receives tool call response
**Sequence**:
```mermaid
sequenceDiagram
llmCall-->>AgentLoop: response = {finish_reason=> "tool_calls",<br/>message=> {role=> "assistant",<br/>reasoning_content=> "I need to call getWeather...",<br/>tool_calls=> [{"id"=>"call_1", "function"=>{name=>"getWeather", arguments=>"{\"city\":\"Bangkok\"}"}}, ...]}}
AgentLoop->>AgentLoop: _extractToolCalls(response)
AgentLoop->>AgentLoop: hasToolCalls = true<br/>toolCallList = [agentToolCall("getWeather", ...)]
AgentLoop->>AgentLoop: assistant_msg = assistantMessageToolCall(toolCalls, reasoningContent, ...)
AgentLoop->>AgentLoop: push!(agentMsgHistory, assistant_msg)
AgentLoop->>AgentLoop: config = agentLoopConfig(beforeToolCall, afterToolCall, parallel="sequential")
AgentLoop->>AgentLoop: toolResultBatch = executeToolCalls(...)
...
```
> **Traceability**: FR-002, FR-003, PR-003, SD-003, SD-005, SD-009
---
## MCP Server
_The Model Context Protocol server providing tools via JSON-RPC 2.0 (transport via NATS or HTTP).
### Receive tools/list Request
**WT-020**: Agent discovers tools on startup
> Cross-reference: `2-solution_design.md:SD-004` (MCP integration)
**Sequence**:
```mermaid
sequenceDiagram
part agent AgentLoop
agent->>mcp: mcp("tools/list", sessionId; eventSink=eventSink)
Note over mcp: JSON-RPC 2.0: {"jsonrpc"=>"2.0", method=>"tools/list", "id"=>..., "params"=>{...}}
MCP_server-->>agent: response = {<br/> "result" => {<br/> "tools" => [name, label, description, inputSchema], ...<br/> , "nextCursor" => 123<br/> }<br/>}
agent->>agent: for tool_def in tools_array<br/> _wrap_mcp_tool(mcp, tool_def)
agent->>agent: while cursor !== nothing → paginate
agent->>agent: println("[toolRegistry] Discovered N MCP tools. Total: $(length(tools))")
```
> **Traceability**: FR-004 (MCP discovery), PR-004
### Receive tools/call Request
**WT-021**: Agent executes tool via MCP
**Sequence**:
```mermaid
sequenceDiagram
part agent Tool System
agent->>mcp: mcp("tools/call", "getWeather", args={"city" => "Bangkok"}, sessionId; eventSink=eventSink)
Note over mcp: JSON-RPC 2.0: {"jsonrpc"=>"2.0", method=>"tools/call", "params"=>{name, arguments, sessionId}}
MCP_server-->>agent: response = {<br/> "result" => {<br/> "content" => [{"type"=>"text", "text"=>"Sunny, 22°C"}],<br/> "isError" => false<br/> }<br/>}
agent->>agent: check haskey(response, "error") → no
agent->>agent: result_data = response["result"]
agent->>agent: content_text = _extract_text_content(result_data)
agent->>agent: return agentToolResult([textContent(content_text)], details={isError=>false}, nothing, false)
```
> **Traceability**: FR-004 (tool execution), PR-004, SD-004, SD-005
### Receive Error from MCP Server
**WT-022**: MCP server returns error
**Sequence**:
```mermaid
sequenceDiagram
MCP_server-->>agent: response = {<br/> "error" => {<br/> "code" => -32601,<br/> "message" => "Method not found"<br/> }<br/>}
agent->>agent: haskey(response, "error") → true
agent->>agent: return agentToolResult(<br/> [textContent("MCP error: Method not found")],<br/> details={"error" => "Method not found"},<br/> nothing, false<br/>)
agent->>AgentLoop: finalOutcome(error=true)
agent->>AgentLoop: push!(agentMsgHistory, toolResultMessage(...))
agent->>AgentLoop: next LLM turn receives error result
```
> **Traceability**: FR-004 (error handling), NFR-202 (never throw), PR-004, SD-005
---
## Error Paths and Edge Cases
**WT-030**: LLM times out (network error)
> Same participant POV — from caller's perspective, they block on `takeResponse()` until task exits
```mermaid
sequenceDiagram
participant Dev as Developer
part agent AgentLoop
Dev->>agent: takeResponse(agent) # blocks
agent->>llmCall: llmCall(messages) # calls external API
lltimout
LLM provider-->>AgentLoop: ERROR (timeout)
AgentLoop->>AgentLoop: @error "Agent loop failed"
AgentLoop->>AgentLoop: eventSink("error: ...")
AgentLoop->>Dev: fetch(_agentLoop) throws TaskFailedException
Dev->>agent: stopAgent(agent) # cleanup channels
```
**Traceability**: PR-001, NFR-201 (catch error and exit), SD-001
**WT-031**: Tool execution throws exception
```mermaid
sequenceDiagram
agent->>agent: executePreparedToolCall
agent->>tool: tool.execute(toolCallId, args, signal, eventSink)
note over tool: tool raises exception (e.g., network error, invalid JSON)
tool-->>agent: throw Exception
agent->>agent: catch e
agent->>agent: executedOutcome(createErrorToolResult("error message"), true)
agent->>AgentLoop: finalizeExecutedToolCall
AgentLoop->>AgentLoop: createToolResultMessage(error=true)
AgentLoop->>AgentLoop: push!(history, toolResultMessage)
AgentLoop->>AgentLoop: next llmCall with error in message history
```
**Traceability**: FR-005 (hook errors caught), NFR-202 (never throw), SD-005
**WT-032**: beforeToolCall hook blocks execution
```mermaid
sequenceDiagram
agent->>agent: beforeToolCall(beforeCtx, signal, eventSink)
agent->>agent: returns beforeToolCallResult(true, "User must approve")
agent->>agent: return immediateOutcome(createErrorToolResult("Tool blocked"), true)
agent->>AgentLoop: executeToolCalls → immediateOutcome
AgentLoop->>AgentLoop: finalizeExecutedToolCall
AgentLoop->>AgentLoop: createToolResultMessage(error=true)
AgentLoop->>AgentLoop: push! to history
```
**Traceability**: FR-005 (beforeToolCall can block), SD-005
-518
View File
@@ -1,518 +0,0 @@
# Specification: YiemAgent Agent Framework
## 1. Message Type System (`src/type.jl`)
### SP-001: userMessage
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `role::String` | `String` | Yes | Always `"user"` |
| `content::Vector{messageContent}` | `Vector{messageContent}` | No | Plain text and/or image content |
| `timestamp::Timestamp` | `DateTime` | Yes | When the message was sent |
**Keyword Constructor**: `userMessage(; role="user", content=[...], timestamp=now())`
**Traceability**:
- Requirement: FR-006 (message type conversion)
- Solution Design: SD-006 (Julia structs for messages)
### SP-002: assistantMessage
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `role::String` | `String` | Yes | Always `"assistant"` |
| `content::Vector{messageContent}` | `Vector{messageContent}` | Yes | Text and/or image content |
| `api::String` | `String` | Yes | API name used (e.g., `"openai"`) |
| `provider::String` | `String` | Yes | Provider name (e.g., `"anthropic"`) |
| `model::String` | `String` | Yes | Model identifier |
| `usage::llmUsage` | `llmUsage` | Yes | Token usage (in/out) |
| `stopReason::String` | `String` | Yes | Why generation stopped (e.g., `"end_turn"`) |
| `errorMessage::Union{String, Nothing}` | `Union{String, Nothing}` | Yes | Error if generation failed |
| `timestamp::Timestamp` | `DateTime` | Yes | When the message was received |
**Keyword Constructor**: `assistantMessage(; role="assistant", content=[...], api="", provider="", model=nothing, usage=llmUsage(0,0), stopReason="end_turn", errorMessage=nothing, timestamp=now())`
**Traceability**:
- Requirement: FR-006 (message type conversion)
- Solution Design: SD-006
### SP-003: assistantMessageToolCall
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `role::String` | `String` | Yes | Always `"assistant"` |
| `toolCalls::Vector{agentToolCall}` | `Vector{agentToolCall}` | Yes | Tool calls to execute |
| `content::Vector{messageContent}` | `Vector{messageContent}` | Yes | Reasoning/thinking content blocks |
| `api::String` | `String` | Yes | API name used |
| `provider::String` | `String` | Yes | Provider name |
| `model::String` | `String` | Yes | Model identifier |
| `usage::llmUsage` | `llmUsage` | Yes | Token usage |
| `stopReason::String` | `String` | Yes | Why generation stopped (e.g., `"tool_calls"`) |
| `errorMessage::Union{String, Nothing}` | `Union{String, Nothing}` | Yes | Error if generation failed |
| `timestamp::Timestamp` | `DateTime` | Yes | When the message was received |
**Keyword Constructor**: `assistantMessageToolCall(; role="assistant", toolCalls=[...], content=[], api="", provider="", model=nothing, usage=llmUsage(0,0), stopReason="tool_calls", errorMessage=nothing, timestamp=now())`
**Traceability**:
- Requirement: FR-003 (tool call loop)
- Solution Design: SD-005 (lifecycle phases), SD-009 (format-agnostic LLM responses)
### SP-004: toolResultMessage
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `role::String` | `String` | Yes | Always `"toolResult"` (note: `"tool"` in example) |
| `toolCallId::String` | `String` | Yes | ID matching the tool call |
| `toolName::String` | `String` | Yes | Name of the executed tool |
| `content::Vector{messageContent}` | `Vector{messageContent}` | Yes | Tool output content |
| `details::Any` | `Any` | Yes | Additional tool-specific details |
| `usage::Union{llmUsage, Nothing}` | `Union{llmUsage, Nothing}` | Yes | Token usage if applicable |
| `addedToolNames::Union{Vector{String}, Nothing}` | `Union{Vector{String}, Nothing}` | Yes | Tools added during execution |
| `isError::Bool` | `Bool` | Yes | Whether the tool call resulted in an error |
| `timestamp::Timestamp` | `DateTime` | Yes | When the result was recorded |
**Keyword Constructor**: `toolResultMessage(; role="tool", toolCallId="", toolName="", content=[], details=nothing, usage=nothing, addedToolNames=nothing, isError=false, timestamp=now())`
**Traceability**:
- Requirement: FR-005 (tool lifecycle)
- Solution Design: SD-005
### SP-005: agentToolCall
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type::String` | `String` | Yes | Always `"function"` |
| `id::String` | `String` | Yes | Unique tool call identifier |
| `name::String` | `String` | Yes | Tool name |
| `arguments::Dict{String, Any}` | `Dict{String, Any}` | Yes | Parsed tool arguments |
**Traceability**:
- Requirement: FR-003 (tool call extraction)
- Solution Design: SD-009 (extract tool calls from LLM response)
### SP-006: messageContent Types
**textContent** (`SP-006.1`):
```julia
struct textContent <: messageContent
text::String
end
```
**imageContent** (`SP-006.2`):
```julia
struct imageContent <: messageContent
data::String # Base64-encoded image data
mimeType::String # MIME type (e.g., "image/png")
end
```
**reasoningContent** (`SP-006.3`):
```julia
struct reasoningContent <: messageContent
text::String
end
```
**Traceability**:
- Requirement: FR-006 (message content conversion)
- Solution Design: SD-006
## 2. LLM Model Configuration (`src/type.jl`)
### SP-007: llmModel
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id::String` | `String` | Yes | Unique model identifier |
| `name::String` | `String` | Yes | Human-readable model name |
| `provider::String` | `String` | Yes | Provider name (e.g., `"anthropic"`, `"openai"`) |
| `baseUrl::String` | `String` | Yes | API endpoint base URL |
| `reasoning::Bool` | `Bool` | Yes | Whether the model supports chain-of-thought |
| `input::Vector{String}` | `Vector{String}` | Yes | Supported input modalities (e.g., `"text"`, `"image"`) |
| `cost::modelCost` | `modelCost` | Yes | Pricing information |
| `contextWindow::Int64` | `Int64` | Yes | Maximum context length in tokens |
| `maxTokens::Int64` | `Int64` | Yes | Maximum output tokens per completion |
**Traceability**:
- Requirement: FR-002 (LLM agnosticism)
- Solution Design: SD-003
### SP-008: modelCost
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `input::Float64` | `Float64` | Yes | Price per 1M input tokens |
| `output::Float64` | `Float64` | Yes | Price per 1M output tokens |
| `cache_read::Float64` | `Float64` | Yes | Price per 1M cached read tokens |
| `cache_write::Float64` | `Float64` | Yes | Price per 1M cache write tokens |
**Traceability**:
- Requirement: N/A (internal tracking data)
- Solution Design: SD-003
## 3. MCP Tool Integration (`src/toolRegistry.jl`)
### SP-009: tools/list Response Format
**Request**: `mcp("tools/list", sessionId; eventSink=eventSink)`
**Response** (JSON-RPC 2.0):
```json
{
"result": {
"tools": [
{
"name": "tool_name",
"title"?: "Human-readable title",
"label"?: "Fallback title",
"description": "What the tool does",
"inputSchema": {
"type": "object",
"properties": {
"field": { "type": "string", "description": "..." }
},
"required": ["field"]
}
}
],
"nextCursor"?: "pagination_cursor_or_empty"
}
}
```
**Response error**:
```json
{
"error": {
"code": -32601,
"message": "Method not found"
}
}
```
**Traceability**:
- Requirement: FR-004 (MCP discovery)
- Solution Design: SD-004
### SP-010: tools/call Request/Response Format
**Request**: `mcp("tools/call", toolName, args, sessionId; eventSink=eventSink)`
**Response** (JSON-RPC 2.0):
```json
{
"result": {
"content": [
{"type": "text", "text": "Tool output text"}
],
"isError": false
}
}
```
**Response error**:
```json
{
"result": {
"content": [
{"type": "text", "text": "Error message"}
],
"isError": true
}
}
```
**Traceability**:
- Requirement: FR-004 (tool execution)
- Solution Design: SD-004 (`_wrap_mcp_tool`)
## 4. OpenAI Message Format (`src/utils.jl`)
### SP-011: system message format
```julia
Dict(
"role" => "system",
"content" => [Dict("type" => "text", "text" => ctx.systemPrompt)]
)
```
**Traceability**:
- Requirement: FR-006
- Solution Design: SD-006
### SP-012: userMessage → OpenAI format
```julia
Dict(
"role" => "user",
"content" => _messageContentToBlocks(msg.content)
)
```
Where `_messageContentToBlocks` converts:
- `textContent(t)` → `Dict("type" => "text", "text" => t)`
- `imageContent(data, mime)` → `Dict("type" => "image_url", "image_url" => Dict("url" => "data:$mime;base64,$data"))`
**Traceability**:
- Requirement: FR-006
- Solution Design: SD-006
### SP-013: assistantMessageToolCall → OpenAI format
```julia
Dict(
"role" => "assistant",
"content" => nothing,
"tool_calls" => [
Dict(
"id" => tc.id,
"type" => tc.type,
"function" => Dict(
"name" => tc.name,
"arguments" => JSON.json(tc.arguments) # serialized as JSON string
)
)
]
)
```
**Traceability**:
- Requirement: FR-003
- Solution Design: SD-009
### SP-014: assistantMessage → OpenAI format
```julia
Dict(
"role" => "assistant",
"content" => _messageContentToBlocks(msg.content)
)
```
**Traceability**:
- Requirement: FR-006
- Solution Design: SD-006
### SP-015: toolResultMessage → OpenAI format
```julia
Dict(
"role" => "tool",
"tool_call_id" => msg.toolCallId,
"content" => _messageContentToBlocks(msg.content)
)
```
**Traceability**:
- Requirement: FR-005, FR-006
- Solution Design: SD-005
### SP-016: agentTool → OpenAI tools format
```julia
Dict(
"type" => "function",
"function" => Dict(
"name" => tool.name,
"description" => tool.description,
"parameters" => tool.inputSchema
)
)
```
**Traceability**:
- Requirement: FR-004
- Solution Design: SD-004, SD-006
## 5. LLM Response Format Agnostic Extraction (`src/agentCore.jl`)
### SP-017: format 1 — LMStudio.jl / vLLM style (dict with nested `message.tool_calls`)
**Response dict** (key: `message::Dict`):
```json
{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": "",
"reasoning_content": "I need to call getWeather...",
"tool_calls": [
{
"type": "function",
"function": {
"name": "getWeather",
"arguments": "{\"city\":\"Bangkok\"}" // JSON string
},
"id": "call_1"
}
]
}
}
```
**When no tool calls**:
```json
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "It's 22°C in Bangkok"
}
}
```
**Traceability**:
- Requirement: FR-002
- Solution Design: SD-009
### SP-018: format 2 — OpenAI API style (content blocks array)
**Content blocks with tool calls** (`SP-018.1`):
```json
{
"content": [
{
"type": "tool_calls",
"tool_calls": [
{
"type": "function",
"function": {
"name": "getWeather",
"arguments": {"city": "Bangkok"} // already parsed dict
},
"id": "call_1"
}
]
}
]
}
```
**Content blocks with tool_call (already parsed)** (`SP-018.2`):
```json
{
"content": [
{
"type": "tool_call",
"name": "getWeather",
"arguments": {"city": "Bangkok"},
"id": "call_1"
}
]
}
```
**Content blocks with text only** (`SP-018.3`):
```json
{
"content": [
{"type": "text", "text": "It's 22°C in Bangkok"}
]
}
```
**Response dict extras** (common across all formats, `SP-018.4`):
```json
{
"api": "openai",
"provider": "anthropic",
"model": "gpt-4",
"usage": {"inputTokens": 100, "outputTokens": 50},
"error_message": "Optional error"
}
```
**Traceability**:
- Requirement: FR-002
- Solution Design: SD-009
## 6. Agent Loop Configuration (`src/type.jl` + `src/agentCore.jl`)
### SP-019: agentContext
```julia
struct agentContext
systemPrompt::String
messages::Vector{agentMessage}
tools::Union{OrderedDict{String, agentTool}, Nothing} # O(1) lookup by name
end
```
**Traceability**:
- Requirement: FR-003 (context preparation)
- Solution Design: SD-003, SD-004
### SP-020: agentState
```julia
mutable struct agentState
systemPrompt::String
model::Union{llmModel, Nothing}
tools::OrderedDict{String, agentTool} # insertion-ordered
messages::Vector{agentMessage} # history (no system prompt)
pendingToolCalls::Vector{String} # tool call IDs waiting for results
errorMessage::Union{String, Nothing} # last error message
end
```
**Traceability**:
- Requirement: FR-001 (agent state management)
- Solution Design: SD-006
### SP-021: agentLoopConfig
```julia
struct agentLoopConfig
beforeToolCall::Union{Function, Nothing}
afterToolCall::Union{Function, Nothing}
toolExecution::String # "sequential" or "parallel"
end
```
**Traceability**:
- Requirement: FR-005 (lifecycle hooks)
- Solution Design: SD-005
### SP-022: abortSignal
```julia
struct abortSignal
aborted::Bool
end
```
**Traceability**:
- Requirement: NFR-101 (timeout handling)
- Solution Design: SD-008
## 7. Tool Validation Hooks (`src/utils.jl`)
### SP-023: validateRequiredArgs (default hook)
**Signature**: `validateRequiredArgs(args::AbstractDict{String, Any}, inputSchema::AbstractDict{String, Any})::Union{Nothing, String}`
**Behavior**:
- Extract `required = get(inputSchema, "required", Any[])`
- Check each required field exists in `keys(args)`
- Return `nothing` if all present
- Return `"Missing required arguments: $(join(missing, ", "))"` if any missing
**Traceability**:
- Requirement: NFR-301 (input validation)
- Solution Design: SD-005
### SP-024: validateToolArguments (wrapper hook)
**Signature**: `validateToolArguments(tool::agentTool, prepared::agentToolCall)::AbstractDict{String, Any}`
**Behavior**:
- If `tool.validateRequiredArgs !== nothing`: call `tool.validateRequiredArgs(prepared.arguments)`
- Else: call `validateRequiredArgs(prepared.arguments, tool.inputSchema)`
- If returns `!nothing`: throw `ArgumentError(result)`
- Caller (`prepareToolCall`) catches and returns `immediateOutcome`
**Traceability**:
- Requirement: FR-005 (tool validation)
- Solution Design: SD-005
-192
View File
@@ -1,192 +0,0 @@
# Validation: YiemAgent Agent Framework
## Test Architecture Overview
Because YiemAgent is a Julia library (not a web service), validation tests focus on:
- **Unit tests**: message type conversion, tool execution logic, format extraction
- **Integration tests**: full agent loop with stubbed LLM and MCP
- **Contract tests**: response format compatibility with OpenAI/LMStudio specs
- **Performance tests**: agent loop overhead for sequential tool calls
Every specification rule (SP-XXX), walkthrough journey (WT-XXX), and requirement (FR-XXX) must have a corresponding test.
---
## Test Types Mapping to Source Documents
| Test Type | Source Document | Example Coverage |
|-----------|-----------------|------------------|
| **Unit tests** | `4-specification.md` (SP-001..SP-024), `src/utils.jl` (converters) | Message type → OpenAI format conversion; tool argument validation |
| **Integration tests** | `3-walkthrough.md` (WT-001..WT-032) | Full agent loop: prompt → llmCall → tool extract → execute → response |
| **Contract tests** | `4-specification.md` SP-017, SP-018 | Response format 1 (LMStudio/vLLM) and format 2 (OpenAI) parsed correctly |
| **Performance tests** | `1-requirements.md` FR-003, NFR-101 | Agent loop overhead <500ms per sequential tool call |
| **Penetration tests** | `1-requirements.md` NFR-301 | Tool argument injection; MCP spoofing |
| **Schema validation** | `4-specification.md` SP-023, SP-024 | Required fields, type checking on tool arguments |
---
## Specification-to-Test Execution Trace
Every traceable item — specification rules (SP-XXX), walkthrough journeys (WT-XXX), and requirements (FR-XXX) — must have a runnable test. This trace maps each item to its corresponding test file and test function.
**Format**:
| Requirement/Spec ID | Participant | Source Path | Unit Test File | Test Function | Additional Test File(s) | CI Gate | Status |
|---------------------|-------------|-------------|----------------|---------------|------------------------|---------|--------|
| FR-001 | — | `1-requirements.md:3.1` | `tests/unit/channel-handling.jl` | `test_input_channel_priority` | `tests/integration/full-agent-loop.jl` | `ci/julia-unit.yaml` | **TODO** |
| FR-002 | — | `1-requirements.md:3.2` | `tests/unit/llm-format-extraction.jl` | `test_format1_tool_calls` | `tests/contract/response-format.jl` | `ci/julia-unit.yaml` | **TODO** |
| FR-003 | — | `1-requirements.md:3.3` | `tests/integration/tool-call-loop.jl` | `test_loop_exits_on_no_tool_calls` | — | `ci/julia-integration.yaml` | **TODO** |
| FR-004 | — | `1-requirements.md:3.4` | `tests/unit/mcp-tool-discovery.jl` | `test_tools_list_with_pagination` | `tests/contract/mcp-json-rpc.jl` | `ci/julia-unit.yaml` | **TODO** |
| FR-005 | — | `1-requirements.md:3.5` | `tests/unit/tool-hooks.jl` | `test_beforeToolCall_blocks` | `tests/integration/agent-loop.jl` | `ci/julia-unit.yaml` | **TODO** |
| FR-006 | — | `1-requirements.md:3.6` | `tests/unit/message-conversion.jl` | `test_userMessage_to_OpenAI` | — | `ci/julia-unit.yaml` | **TODO** |
| WT-001 | Caller | `3-walkthrough.md:#agent-creation` | `integration/agent-creation.jl` | `test_agent_construction_and_lifecycle` | — | `ci/julia-integration.yaml` | **TODO** |
| WT-002 | Caller | `3-walkthrough.md:#send-message` | `integration/message-flow.jl` | `test_promptAgent_and_takeResponse` | — | `ci/julia-integration.yaml` | **TODO** |
| WT-003 | Caller | `3-walkthrough.md:#receive-response` | `integration/response-format.jl` | `test_output_channel_delivery` | — | `ci/julia-integration.yaml` | **TODO** |
| WT-004 | Caller | `3-walkthrough.md:#follow-up` | `integration/followup.jl` | `test_followUp_while_busy` | — | `ci/julia-integration.yaml` | **TODO** |
| WT-005 | Caller | `3-walkthrough.md:#stop-agent` | `integration/shutdown.jl` | `test_stopAgent_channel_cleanup` | — | `ci/julia-integration.yaml` | **TODO** |
| WT-010 | LLM Provider | `3-walkthrough.md:#receive-formatted` | `integration/llm-call.jl` | `test_formatMsgForLLM_output_structure` | — | `ci/julia-integration.yaml` | **TODO** |
| WT-011 | LLM Provider | `3-walkthrough.md:#response-no-tool-calls` | `integration/non-tool-response.jl` | `test_lmm_response_no_tool_calls_breaks_loop` | — | `ci/julia-integration.yaml` | **TODO** |
| WT-012 | LLM Provider | `3-walkthrough.md:#response-with-tool` | `integration/tool-response.jl` | `test_lmm_response_with_tool_calls` | — | `ci/julia-integration.yaml` | **TODO** |
| WT-020 | MCP Server | `3-walkthrough.md:#discover-tools` | `unit/mcp-discovery.jl` | `test_mcp_tools_list_parsed` | `contract/mcp-json-rpc.jl` | `ci/julia-unit.yaml` | **TODO** |
| WT-021 | MCP Server | `3-walkthrough.md:#execute-tool` | `unit/mcp-tool-execute.jl` | `test_mcp_tools_call_response_parsed` | — | `ci/julia-unit.yaml` | **TODO** |
| WT-022 | MCP Server | `3-walkthrough.md:#mcp-error` | `unit/mcp-error-handling.jl` | `test_mcp_response_error_returned` | — | `ci/julia-unit.yaml` | **TODO** |
| WT-030 | Error | `3-walkthrough.md:#llm-timeout` | `integration/llm-timeout.jl` | `test_LLM_timeout_agent_loop_exits` | — | `ci/julia-integration.yaml` | **TODO** |
| WT-031 | Error | `3-walkthrough.md:#tool-throw` | `integration/tool-error.jl` | `test_tool_execute_throws_returns_error_result` | — | `ci/julia-integration.yaml` | **TODO** |
| WT-032 | Error | `3-walkthrough.md:#beforeToolCall-block` | `unit/beforeToolCall-block.jl` | `test_beforeToolCall_blocks_execution` | — | `ci/julia-unit.yaml` | **TODO** |
| SP-001 | — | `4-specification.md:#SP-001:userMessage` | `unit/message-types.jl` | `test_userMessage_struct_default_roles` | — | `ci/julia-unit.yaml` | **TODO** |
| SP-002 | — | `4-specification.md:#SP-002:assistantMessage` | `unit/message-types.jl` | `test_assistantMessage_struct_default_stopReason_end_turn` | — | `ci/julia-unit.yaml` | **TODO** |
| SP-003 | — | `4-specification.md:#SP-003:assistantMessageToolCall` | `unit/message-types.jl` | `test_assistantMessageToolCall_struct_default_stopReason_tool_calls` | — | `ci/julia-unit.yaml` | **TODO** |
| SP-004 | — | `4-specification.md:#SP-004:toolResultMessage` | `unit/message-types.jl` | `test_toolResultMessage_struct_defaults` | — | `ci/julia-unit.yaml` | **TODO** |
| SP-005 | — | `4-specification.md:#SP-005:agentToolCall` | `unit/message-types.jl` | `test_agentToolCall_type_is_always_function` | — | `ci/julia-unit.yaml` | **TODO** |
| SP-006.1,2,3 | — | `4-specification.md:#SP-006:content-types` | `unit/content-types.jl` | `test_textContent_imageContent_reasoningContent` | — | `ci/julia-unit.yaml` | **TODO** |
| SP-017 | — | `4-specification.md:#SP-017` | `contract/response-format.jl` | `test_lmStudio_vLLM_format_parsed` | — | `ci/julia-contract.yaml` | **TODO** |
| SP-018.1,2,3,4 | — | `4-specification.md:#SP-018` | `contract/response-format.jl` | `test_openAI_format_blocks_parsed` | — | `ci/julia-contract.yaml` | **TODO** |
| SP-023 | — | `4-specification.md:#SP-023` | `unit/tool-validation.jl` | `test_validateRequiredArgs_missing_fields` | — | `ci/julia-unit.yaml` | **TODO** |
| SP-024 | — | `4-specification.md:#SP-024` | `unit/tool-validation.jl` | `test_validateToolArguments_throws_on_missing` | — | `ci/julia-unit.yaml` | **TODO** |
| NFR-101 | — | `1-requirements.md:4.1` | `perf/agent-loop.jl` | `perf_agent_loop_overhead_<500ms` | `tests/perf/concurrency.jl` | `ci/julia-perf.yaml` | **TODO** |
| NFR-102 | — | `1-requirements.md:4.1` | `perf/mcp-discovery.jl` | `perf_mcp_discovery_50_tools_<2s` | — | `ci/julia-perf.yaml` | **TODO** |
| NFR-201 | — | `1-requirements.md:4.2` | `unit/error-handling.jl` | `test_agent_loop_catches_and_logs_errors` | `integration/llm-timeout.jl` | `ci/julia-unit.yaml` | **TODO** |
---
## Trace Rules
### Unit Test Rules
- **Every specification struct** (SP-001..SP-008, SP-019..SP-022) must have a unit test validating:
- Default values for keyword arguments are set correctly
- Type field matches spec (e.g., `agentToolCall.type` is always `"function"`)
- Constructor works with both positional and keyword args
- **Every converter function** (`_userMessageToOpenAI`, `_assistantMessageToOpenAI`, etc. SP-011..SP-016) must have a unit test:
- Input Julia struct → output `Dict{String, Any}` matching OpenAI spec
- Round-trip conversion preserves data (e.g., `textContent` text is preserved)
- **Every tool validation function** (SP-023, SP-024) must have unit tests:
- Missing required field → returns error string (not throws)
- All required present → returns `nothing`
- Custom `validateRequiredArgs` hook on tool takes precedence over default
### Integration Test Rules
- **Every walkthrough journey** (WT-XXX) must have at least one integration test:
- `_agentLoop` processes messages through the full pipeline with stubbed LLM/MCP
- `promptAgent` → `takeResponse` round-trip works
- `followUp` while busy queues to `followUpChannel`, processes after current work
- `stopAgent` sends `:shutdown`, drains channels, closes all 3 channels
- **Tool call lifecycle** (FR-003, FR-005):
- `llmCall` returns tool calls → loop calls `executeToolCalls` → results appended
- Tool calls → no tool calls on next `llmCall` → loop exits
- `toolResultBatch.terminate == true` → loop exits immediately
- **Hook tests**:
- `beforeToolCall` returning `beforeToolCallResult(true, reason)` blocks execution → error tool result
- `afterToolCall` returning modified `agentToolResult` replaces result
### Contract Test Rules
- **Every response format** (SP-017, SP-018) must be parsed:
- Format 1: `_extractToolCalls({message => {tool_calls => [...]}})` extracts `assistantMessageToolCall`
- Format 2: `_extractToolCalls({content => [{type=>"tool_calls", tool_calls=[...]}}])` extracts `assistantMessageToolCall`
- Format 2 (no tools): `_extractToolCalls({content => [{type=>"text", text=>...}]})` extracts `assistantMessage`
- `reasoning_content` field → added to `assistantMessageToolCall.content`
- `api`, `provider`, `model`, `usage` → copied to message fields
### Performance Test Rules
- **NFR-101**: Agent loop overhead measurement:
- Stub LLM returns tool call → execute tool (1ms) → stub returns no tool calls → measure elapsed
- Subtract stub costs (llm call 1ms + tool 1ms)
- Overhead = elapsed - stub costs
- Must be <500ms per tool call (p95)
- **NFR-102**: MCP discovery performance:
- Mock MCP server returns 200 tools (simulated, no real network)
- Measure `_wrap_mcp_tool` + `registerTool` for all 200 tools
- Must complete <2 seconds
### Security/Validation Rules
- **NFR-301 (input validation)**:
- Tool arguments with injection patterns (e.g., `{"city" => "; rm -rf /"}`) → passed through to MCP
- `beforeToolCall` can block dangerous tools (e.g., `toolCall.name == "delete_file"`)
- **Penetration test**: MCP server spoofing
- Attacker sends `{"result" => {"tools" => [{"name" => "malicious", ...}]}}` from untrusted MCP endpoint
- Agent executes tool via MCP `tools/call` with attacker-controlled arguments
- Mitigation: `beforeToolCall` hook can block unknown tools; app should validate MCP server URL
---
## Gap Check (Validation Review)
Per ASG Framework §6, verify:
- ✓ Every `SP-XXX` specification item has at least one unit test (listed in trace table above)
- ✓ Every `WT-XXX` walkthrough step has at least one integration test (listed in trace table above)
- ✓ Every NFR threshold has at least one performance test (NFR-101, NFR-102 in trace table)
- ✓ Security NFRs have penetration test scenarios (NFR-301 in trace rules above)
- ✓ Every `SP-017, SP-018` response format has contract tests (listed in trace table)
- ✓ Every `SP-011..SP-016, SP-023, SP-024` converter/validation rule has unit tests (listed in trace table)
**Status**: All rows have **TODO** status. Tests must be written before integration into production CI pipeline.
---
## CI Pipeline
```yaml
# ci/julia-pipeline.yaml (placeholder)
name: Julia YiemAgent CI
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: julia-actions/julia-buildpkg@latest
- run: julia --project=. -e 'using Pkg; Pkg.instantiate()'
- run: julia --project=./tests -e 'using Test; Test.@testset "Unit tests" # include tests/unit/run_tests.jl"'
integration:
runs-on: ubuntu-latest
needs: unit
steps:
- run: julia --project=./tests -e 'using Test; Test.@testset "Integration" # include tests/integration/run_tests.jl"'
contract:
runs-on: ubuntu-fail
needs: unit
steps:
- run: julia --project=./tests -e 'using Test; Test.@testset "Contract" # include tests/contract/run_tests.jl"'
perf:
runs-on: ubuntu-latest
needs: [unit, integration]
steps:
- run: julia --project=./tests -e 'using Test; Test.@testset "Performance" # include tests/perf/run_tests.jl"'
# Fail if NFR-101 or NFR-102 thresholds breached
lint:
runs-on: ubuntu-latest
steps:
- run: julia --eval 'using Aqua; Aqua.test_all(YiemAgent)'
```
**Gap Check**: Per ASG §5, every spec item maps to a CI gate. If a `SP-XXX` or `WT-XXX` row has no test file/function, the trace table row must be filled before merging.
-217
View File
@@ -1,217 +0,0 @@
# Implementation Plan: YiemAgent Agent Framework
## 1. Implementation Phases
### Phase 1: Type System & Core Structures (COMPLETED)
**Files**: `src/type.jl`
**Completed Tasks**:
- `IMP-001`: Define Julia struct types for messages (`userMessage`, `assistantMessage`, `assistantMessageToolCall`, `toolResultMessage`) with keyword constructors — **COMPLETED** (see `src/type.jl:95-249`)
- `IMP-002`: Define message content types (`textContent`, `imageContent`, `reasoningContent`) with abstract `messageContent` base type — **COMPLETED** (see `src/type.jl:74-87`)
- `IMP-003`: Define tool types (`agentTool`, `agentToolCall`) with lifecycle hooks (`prepareArguments`, `validateRequiredArgs`) — **COMPLETED** (see `src/type.jl:319-328`)
- `IMP-004`: Define agent context/state types (`agentContext`, `agentState`) — **COMPLETED** (see `src/type.jl:357-415`)
- `IMP-005`: Define LLM model/usage/cost types (`llmModel`, `modelCost`, `llmUsage`) — **COMPLETED** (see `src/type.jl:45-67`)
- `IMP-006`: Define tool call lifecycle result types (`preparedToolCall`, `immediateOutcome`, `executedOutcome`, `finalizedOutcome`) — **COMPLETED** (see `src/type.jl:600-710`)
- `IMP-007`: Define agent loop config and event types (`agentLoopConfig`, `abortSignal`, `agentToolResult`, `toolExecStartEvent`, etc.) — **COMPLETED** (see `src/type.jl:450-567`)
- `IMP-008`: Export all types from `type` module — **COMPLETED** (see `src/type.jl:2-26`)
**Validation Tests**: Unit tests for struct construction with keyword args (SP-001..SP-008, SP-019..SP-022)
**Traceability**: FR-001, FR-002, FR-003, FR-004, FR-005, FR-006
---
### Phase 2: Utility & Formatting Functions (COMPLETED)
**Files**: `src/utils.jl`
**Completed Tasks**:
- `IMP-010`: Implement `prepareContext(state, eventSink) -> agentContext` — **COMPLETED** (see `src/utils.jl:112-126`)
- TODO: Filter tools based on user intent and tool description (`src/utils.jl:114`)
- TODO: Inject system prompt context (e.g., time, session state) (`src/utils.jl:118`)
- TODO: Prune/reorder messages (`src/utils.jl:121`)
- `IMP-011`: Implement `formatMsgForLLM(ctx, eventSink) -> Dict{String, Any}` — **COMPLETED** (see `src/utils.jl:159-244`)
- Converts system prompt to system message
- Converts conversation messages via `_userMessageToOpenAI`, `_assistantMessageToOpenAI`, `_assistantMessageToolCallToOpenAI`, `_toolResultMessageToOpenAI`
- Converts `ctx.tools` to OpenAI function tool format via `_toolsToOpenAI`
- `IMP-012`: Implement message converters (`_userMessageToOpenAI`, `_assistantMessageToOpenAI`, `_assistantMessageToolCallToOpenAI`, `_toolResultMessageToOpenAI`) — **COMPLETED** (see `src/utils.jl:321-387`)
- `IMP-013`: Implement `_messageContentToBlocks` — converts `textContent`/`imageContent` to OpenAI content blocks — **COMPLETED** (see `src/utils.jl:396-413`)
- `IMP-014`: Implement `_toolsToOpenAI` — converts `OrderedDict{String, agentTool}` to OpenAI tools array — **COMPLETED** (see `src/utils.jl:429-447`)
- `IMP-015`: Implement `beforeToolCall` hook default (returns `beforeToolCallResult(false, "N/A")`) — **COMPLETED** (see `src/utils.jl:278-284`)
- `IMP-016`: Implement `afterToolCall` hook default (returns `nothing`) — **COMPLETED** (see `src/utils.jl:310-316`)
- `IMP-017`: Implement `validateRequiredArgs(args, inputSchema)` — validates required fields in tool arguments — **COMPLETED** (see `src/utils.jl:479-497`)
- `IMP-018`: Implement `validateToolArguments(tool, prepared)` — wrapper that calls tool-specific or default hook — **COMPLETED** (see `src/utils.jl:533-546`)
**Validation Tests**: Unit tests for converters, validation hooks, and OpenAI format output structure (SP-011..SP-016, SP-023, SP-024)
**Traceability**: FR-001, FR-002, FR-003, FR-004, FR-005, FR-006
---
### Phase 3: Tool Registry & MCP Integration (COMPLETED)
**Files**: `src/toolRegistry.jl`
**Completed Tasks**:
- `IMP-020`: Implement `_extract_text_content(result)` — extracts text from MCP tool result content array — **COMPLETED** (see `src/toolRegistry.jl:19-33`)
- `IMP-021`: Implement `_wrap_mcp_tool(mcpserver, tool_def)` — wraps MCP tool as `agentTool` with `execute` calling `mcpserver("tools/call", ...)` — **COMPLETED** (see `src/toolRegistry.jl:41-106`)
- Handles MCP error responses (`haskey(response, "error")`)
- Extracts text and `isError` from result
- `IMP-022`: Implement `register_mcp_tools(tools, mcpserver)` — queries `tools/list` with pagination (`nextCursor`) — **COMPLETED** (see `src/toolRegistry.jl:118-186`)
- Handles both `result.tools` and `result.error` response shapes
- Skips already registered tools (idempotent)
- `IMP-023`: Implement `registerTool(tools, tool)` — adds tool to OrderedDict — **COMPLETED** (see `src/toolRegistry.jl:200-205`)
- `IMP-024`: Implement `listTool(tools, mcpserver)` — builtin tool that triggers runtime discovery and lists all tools — **COMPLETED** (see `src/toolRegistry.jl:235-271`)
- `IMP-025`: Implement `registerAllTools(tools, mcpserver)` — discovers existing tools + registers `listTools` — **COMPLETED** (see `src/toolRegistry.jl:286-289`)
**Validation Tests**: Unit tests for MCP tools/list parsing, pagination, tool registration idempotency; contract tests for JSON-RPC 2.0 format (SP-009, SP-010)
**Traceability**: FR-002, FR-004
---
### Phase 4: Agent Core & Loop (COMPLETED)
**Files**: `src/agentCore.jl`
**Completed Tasks**:
- `IMP-030`: Define `yiemAgent` struct with channels, callbacks, MCP server — **COMPLETED** (see `src/agentCore.jl:17-95`)
- `IMP-031`: Implement `yiemAgent` constructor — creates channels, discovers MCP tools, spawns `_agentLoop` task — **COMPLETED** (see `src/agentCore.jl:126-199`)
- `IMP-032`: Implement `_agentLoop(agent)` — background `@spawn` task managing `inputChannel`/`followUpChannel` priority dispatch — **COMPLETED** (see `src/agentCore.jl:225-364`)
- Handles shutdown signal: drains all channels, breaks loop
- On error: catches, calls `eventSink(errMsg)`, exits loop
- `IMP-033`: Implement `_processMessage(...)` — drains input, calls `llmCall`, extracts tool calls, executes, loops until no tool calls — **COMPLETED** (see `src/agentCore.jl:394-544`)
- `while true` loop: `llmCall()` → `_extractToolCalls()` → if `hasToolCalls && length > 0` execute → append results → repeat
- On `toolResultBatch.terminate == true`: build final response and break
- Catches all errors and calls `eventSink(err_msg)`
- `IMP-034`: Implement `_extractToolCalls(response)` — handles **both** formats:
- Format 1: `response["message"]["tool_calls"]` (LMStudio/vLLM style) — **COMPLETED** (see `src/agentCore.jl:671-683`)
- Format 2: `response.content` blocks with `type => "tool_calls"` or `type => "tool_call"` (OpenAI API style) — **COMPLETED** (see `src/agentCore.jl:685-719`)
- Extracts `reasoning_content`, `content` text blocks, `api`, `provider`, `model`, `usage`, `finish_reason` — **COMPLETED** (see `src/agentCore.jl:723-797`)
- Returns `(hasToolCalls, toolCallList, assistant_message)` — **COMPLETED** (see `src/agentCore.jl:799-827`)
- `IMP-035`: Implement `prepareToolCall(context, assistantMsg, toolCall, config, signal, eventSink)` — resolves tool, validates args, runs `beforeToolCall` hook, never throws — **COMPLETED** (see `src/agentCore.jl:974-1035`)
- `IMP-036`: Implement `executePreparedToolCall(prep, signal, eventSink)` — calls `tool.execute(toolCallId, args, ...)` — **COMPLETED** (see `src/agentCore.jl:1078-1100`)
- `IMP-037`: Implement `finalizeExecutedToolCall(context, msg, prep, executed, config, signal, eventSink)` — runs `afterToolCall` hook, catches errors — **COMPLETED** (see `src/agentCore.jl:1159-1198`)
- `IMP-038`: Implement `prepareToolCallArguments(tool, toolCall)` — calls `tool.prepareArguments` if present — **COMPLETED** (see `src/agentCore.jl:908-917`)
- `IMP-039`: Implement `createErrorToolResult(msg)` — builds `agentToolResult` with error content — **COMPLETED** (see `src/agentCore.jl:572-574`)
- `IMP-040`: Implement `createToolResultMessage(f)` — builds `toolResultMessage` from `finalizedOutcome` — **COMPLETED** (see `src/agentCore.jl:606-612`)
**Note**: Sequential and parallel execution implementations continue beyond line 1207 of `src/agentCore.jl` — implementation incomplete, requires review to ensure execution methods properly call hooks and emit lifecycle events. See IMP-041.
**Validation Tests**: Integration tests for full agent loop with mocked LLM and MCP; contract tests for response format extraction (WT-001..WT-032, SP-017, SP-018)
**Traceability**: FR-001, FR-002, FR-003, FR-004, FR-005
---
### Phase 5: Public API (COMPLETED)
**Files**: `src/api.jl`
**Completed Tasks**:
- `IMP-050`: Implement `promptAgent(agent, msg::AbstractDict)` — put to `inputChannel`, returns agent for chaining — **COMPLETED** (see `src/api.jl:36-39`)
- `IMP-051`: Implement `promptAgent(agent, text::String)` — wraps text as OpenAI `Dict` and put to `inputChannel` — **COMPLETED** (see `src/api.jl:41-44`)
- `IMP-052`: Implement `takeResponse(agent)` — blocks on `outputChannel` `take!`, returns OpenAI dict response — **COMPLETED** (see `src/api.jl:66-68`)
- `IMP-053`: Implement `followUp(agent, msg::AbstractDict)` — put to `followUpChannel`, returns agent — **COMPLETED** (see `src/api.jl:94-97`)
- `IMP-054`: Implement `followUp(agent, text::String)` — wraps text as OpenAI `Dict` and put to `followUpChannel` — **COMPLETED** (see `src/api.jl:99-102`)
- `IMP-055`: Implement `stopAgent(agent)` — puts `:shutdown`, `fetch(_agentLoop)`, closes all channels — **COMPLETED** (see `src/api.jl:126-139`)
**Validation Tests**: Integration tests for send/receive/stop lifecycle (WT-001..WT-005)
**Traceability**: FR-001, FR-006
---
## 2. Module Structure
```
src/
├── YiemAgent.jl # Parent module, includes submodules in dependency order
├── type.jl # All Julia struct types (exports 30+ types)
├── utils.jl # Formatting, validation, hooks (default implementations)
├── toolRegistry.jl # MCP tool discovery, registration, listTools builtin
├── agentCore.jl # yiemAgent struct, _agentLoop, _processMessage, extractToolCalls
└── api.jl # Public: promptAgent, takeResponse, followUp, stopAgent
```
**Include order** (per `src/YiemAgent.jl` lines 6-19):
1. `type.jl` (no dependencies)
2. `utils.jl` (depends on `type`)
3. `toolRegistry.jl` (depends on `type`)
4. `agentCore.jl` (depends on `type`, `utils`, `toolRegistry`)
5. `api.jl` (depends on `type`, `utils`, `agentCore`, `toolRegistry`)
---
## 3. Task Dependencies
| Task ID | Title | Depends On | Status |
|---------|-------|------------|--------|
| IMP-001..IMP-008 | Type system | — | **COMPLETED** |
| IMP-010..IMP-018 | Utils (formatMsgForLLM, converters, hooks) | IMP-001..IMP-008 | **COMPLETED** |
| IMP-020..IMP-025 | Tool registry (MCP discovery) | IMP-003, IMP-017 | **COMPLETED** |
| IMP-030..IMP-040 | Agent core (agent loop, tool call execution) | IMP-001..IMP-008, IMP-010..IMP-018, IMP-020..IMP-025 | **INCOMPLETE*** (see note) |
| IMP-050..IMP-055 | Public API | IMP-030..IMP-040 | **COMPLETED** |
**NOTE on IMP-030..IMP-040**: The sequential and parallel execution functions (`executeToolCallsSequential`, `executeToolCallsParallel`, `executeToolCalls`) begin after line 1207 of `src/agentCore.jl` but the full implementation is truncated in file reads and requires review. The exported functions (`executePreparedToolCall`, `prepareToolCall`, `executeToolCallsSequential`, `executeToolCallsParallel`, `executeToolCalls`) are declared (`src/agentCore.jl:4-5`) but their full bodies may be incomplete. **Requires manual validation.**
**Traceability**: IMP-001..IMP-008 → SP-001..SP-008, IMP-020..IMP-025 → SP-009, SP-010; IMP-010..IMP-018 → SP-011..SP-016, SP-023, SP-024; IMP-030..IMP-040 → SP-017, SP-018, SP-019..SP-022
---
## 4. Remaining Work (TODOs)
| File | Line | Description | Priority |
|------|------|-------------|----------|
| `src/utils.jl` | 114 | Filter tools from `state.tools` based on user intent and tool description | High |
| `src/utils.jl` | 118 | Add system prompt context: time, session state, user preferences | Medium |
| `src/utils.jl` | 121 | Prune/reorder/conversation messages before sending to LLM | Medium |
| `src/agentCore.jl` | post-1207 | Complete and validate `executeToolCallsSequential`, `executeToolCallsParallel`, `executeToolCalls` implementations (currently exported but body may be incomplete) | **Critical** |
| `tests/unit/...` | — | Write unit tests for all struct types (IMP-001..IMP-008) and converters (SP-011..SP-016) | High |
| `tests/unit/...` | — | Write unit tests for tool validation hooks (SP-023, SP-024) | High |
| `tests/integration/...` | — | Write integration tests for agent loop with mocked LLM/MCP (WT-001..WT-032) | High |
| `tests/contract/...` | — | Write contract tests for response formats (SP-017, SP-018) | Medium |
| `tests/perf/...` | — | Write performance tests (NFR-101, NFR-102) | Medium |
---
## 5. Build & Deployment
### Build
```bash
cd /home/ton/docker-apps/sommpanion/YiemAgent
julia --project=. -e 'using Pkg; Pkg.instantiate(); using Pkg; Pkg.precompile()'
```
### Test
```bash
julia --project=./tests -e 'using Pkg; Pkg.instantiate()'
julia --project=./tests -e 'using Test; Test.@testset "Unit" begin include("tests/unit/run_tests.jl") end'
julia --project=./tests -e 'using Test; Test.@testset "Integration" begin include("tests/integration/run_tests.jl") end'
```
### CI/CD Preparation
- Add `.github/workflows/julia.yaml` referencing `ci/julia-pipeline.yaml` steps
- Add `Project.toml` dependencies:
```toml
[deps]
JSON = "682c8566-3430-5439-8f4e-b64832e8aaba"
DataStructures = "892a3cad-33e6-51e5-808c-f3d90072be70"
HTTP = "cd6eee30-4dd5-5329-8bad-3259ec48d0f4"
# plus: UUIDs, Dates, PrettyPrinting, Serialization, DataFrames, NATS, LibPQ
```
---
## 6. Risk Mitigation
| Risk | Mitigation |
|------|------------|
| Line 1207+ of `agentCore.jl` (sequential/parallel execution) is incomplete or broken | Manually review and validate `executeToolCallsSequential`, `executeToolCallsParallel`, `executeToolCalls` against spec SP-021 (agentLoopConfig) and trace to test `test_tool_call_execution_sequence` |
| `GeneralUtils` internal package unavailable in CI | Ensure `GeneralUtils` is installed as dependency (via `Pkg.develop` or registry) |
| NATS `mcpServer` struct is abstract — no concrete implementation | Provide at least one mock `mcpServer` for tests that returns canned JSON responses |
| Message history grows unbounded | Implement `prepareContext` pruning (TODO in code); add NFR-104 for max history tokens |
-445
View File
@@ -1,445 +0,0 @@
# Runbook: YiemAgent Agent Framework
**Severity**: N/A (library — not a deployed service)
**Estimated Time**: N/A
This runbook documents operational guidance for developers working with the YiemAgent agent framework. It is not an operations runbook for a production service, but a developer handbook for debugging, troubleshooting, and extending the framework.
---
## 1. Agent Lifecycle
### 1.1: Create an Agent
**Steps**:
```julia
using YiemAgent
# 1. Implement llmCall function
function my_llmCall(messages; eventSink=nothing)
# Call LLM API (e.g., OpenAI, Anthropic, LMStudio)
# Return dict with keys: "message", "content", "finish_reason", "api", "provider", "model", "usage"
eventSink("my_llmCall: sending $(length(messages["messages"])) messages"; publish=true)
# ... API call ...
return response_dict
end
# 2. Implement mcpServer callable struct
struct MyMCPClient
conn::NATS.Connection # or HTTP.Client
topic::String
senderID::String
end
function (mcp::MyMCPClient)(method::String, args...; eventSink=nothing)
# JSON-RPC 2.0 calls:
# mcp("tools/list", sessionId; eventSink=eventSink)
# mcp("tools/call", toolName, args, sessionId; eventSink=eventSink)
# ... NATS/HTTP call ...
return jsonrpc_response
end
# 3. Create agent
mcp = MyMCPClient(nats_conn, "yiemagent:tools", "agent_1")
agent = yiemAgent(
my_llmCall;
systemPrompt="You are a wine assistant.\nYou have tools for searching wine inventory.",
tools=mcp_tools, # optional: pass pre-discovered tools
prepareContext=my_prepareContext, # optional: custom context preparation
formatMsgForLLM=my_formatMsgForLLM, # optional: custom formatting for provider
beforeToolCall=my_beforeToolCall, # optional: approval gate
afterToolCall=my_afterToolCall, # optional: result sanitization
mcpServer=mcp,
parallelToolExecute=false,
eventSink=my_eventSink,
)
```
> **Reference**: `2-solution_design.md:SD-001` (channel-based async), `SD-002` (background task), `SD-004` (MCP struct)
### 1.2: Send a Message
```julia
# Option 1: Send a string message (automatically wrapped as OpenAI dict)
promptAgent(agent, "What wines have notes of oak and vanilla?")
# Option 2: Send a full OpenAI-format dict with images
msg = Dict(
"role" => "user",
"content" => [
Dict("type" => "text", "text" => "What wines match these notes?"),
Dict("type" => "image_url", "image_url" => Dict("url" => "data:image/png;base64,..."))
]
)
promptAgent(agent, msg)
```
### 1.3: Receive a Response
```julia
# Blocks until agent finishes processing (no tool calls or tool calls complete)
response = takeResponse(agent)
# response is a Dict in OpenAI format:
# Dict("role" => "assistant", "content" => [Dict("type" => "text", "text" => "...")])
# For text-only response:
if response["content"][1]["type"] == "text"
println(response["content"][1]["text"])
end
```
### 1.4: Send Follow-up While Agent is Busy
```julia
# While the agent is processing tool calls (between promptAgent(...) and takeResponse(...))
followUp(agent, "Also check European wines")
```
### 1.5: Stop the Agent
```julia
stopAgent(agent)
# Sends :shutdown, closes all channels. Agent object is destroyed.
```
---
## 2. Troubleshooting
### 2.1: Agent Loop Exits Silently
**Symptoms**:
- `takeResponse(agent)` hangs indefinitely
- No output in eventSink
**Steps**:
1. Check if `eventSink` is receiving error messages:
```julia
global error_count = 0
my_eventSink(msg; publish=false) = begin
if occursin("error", lowercase(msg)) || occursin("failed", lowercase(msg))
println("ERROR EVENT: $msg")
global error_count += 1
end
end
```
2. Check network connectivity to LLM API endpoint
3. Verify `formatMsgForLLM` produces valid OpenAI-format message structure (`4-specification.md:SP-011..SP-016`)
> **Reference**: `4-specification.md` (OpenAI format specs), `3-walkthrough.md:WT-030` (LLM timeout)
### 2.2: Tool Call Not Executing
**Symptoms**:
- LLM returns `tool_calls` but agent returns text response immediately
- `beforeToolCall` hook is missing or incorrectly implemented
**Steps**:
1. Verify `prepareContext` returns `agentContext` with `tools` field (not `nothing`) (`src/utils.jl:123`)
2. Check `beforeToolCall` hook is not blocking execution:
```julia
function my_beforeToolCall(ctx, signal, eventSink)
# Returning nothing → allows execution
# Returning beforeToolCallResult(true, "blocked") → blocks
eventSink("beforeToolCall for $(ctx.toolCall.name)"; publish=true)
nothing # ← must return nothing (not beforeToolCallResult(false, "N/A"))
end
```
3. Verify tool exists in `context.tools[toolCall.name]` (`src/agentCore.jl:985`)
4. Check `validateToolArguments` is not throwing (catches and returns error tool result) (`src/utils.jl:533`)
> **Reference**: `4-specification.md:SP-021` (agentLoopConfig with beforeToolCall hook), `3-walkthrough.md:WT-032` (beforeToolCall blocks)
### 2.3: MCP Server Returns No Tools
**Symptoms**:
- `println("[toolRegistry] Discovered 0 MCP tools."` on startup
- Agent has no tools available at all times
**Steps**:
1. Verify `mcpServer` callable struct correctly handles `mcp("tools/list", sessionId; eventSink)`
2. Check JSON-RPC 2.0 response shape matches `4-specification.md:SP-009`:
```json
{
"result": {
"tools": [{"name": "...", ...}],
"nextCursor": "" # empty string = no more pages
}
}
```
3. Verify `tools/list` error response uses `haskey(response, "error")` check (`src/toolRegistry.jl:128`)
> **Reference**: `4-specification.md:SP-009` (tools/list format), `3-walkthrough.md:WT-020` (MCP discovery)
### 2.4: LLM Response Format Not Parsed
**Symptoms**:
- Agent always treats LLM response as text, never extracts tool calls
- LLM correctly returns `tool_calls` but agent ignores them
**Steps**:
1. Verify `llmCall` returns a dict containing `message` (LMStudio/vLLM format, `4-specification.md:SP-017`) or `content` (OpenAI format, `4-specification.md:SP-018`)
2. Check for both format variations:
- Format 1: `response["message"]["tool_calls"]` (LMStudio style, see `src/agentCore.jl:671-683`)
- Format 2: `response.content[{type => "tool_calls"}]` (OpenAI style, see `src/agentCore.jl:700-705`)
3. Verify `finish_reason` field matches: `"tool_calls"` triggers tool execution, `"stop"`/`"end_turn"` breaks loop (`src/agentCore.jl:723-724`)
> **Reference**: `4-specification.md:SP-017, SP-018`, `3-walkthrough.md:WT-011, WT-012` (LLM response handling)
### 2.5: Tool Execution Hangs
**Symptoms**:
- `tool.execute(...)` starts but never returns
- EventSink receives `toolExecutionStart` but never `toolExecutionEnd`
**Steps**:
1. Implement `abortSignal` checking in tool execute function:
```julia
function my_tool_execute(toolCallId, args, signal, eventSink)
while !signal.aborted && !all_conditions_met
# Long-running operation with periodic abort checks
sleep(0.1)
end
if signal.aborted
return agentToolResult([textContent("Cancelled")], Dict(), nothing, false)
end
return agentToolResult([textContent("Done")], Dict(), nothing, false)
end
```
2. External caller sets `signal.aborted = true` (if `abortSignal` is shared via closure)
3. Add timeout logic to tool execute function:
```julia
start_time = time()
timeout_seconds = 30.0
while time() - start_time < timeout_seconds && !signal.aborted
# operation...
sleep(0.5)
end
```
> **Reference**: `4-specification.md:SP-022` (abortSignal), `3-walkthrough.md:WT-030` (LLM timeout)
### 2.6: Message History Grows Unbounded
**Symptoms**:
- Memory usage grows linearly with conversation length
- LLM context window exceeded for long sessions
**Steps**:
1. Implement pruning in `prepareContext` (`src/utils.jl:112-126`):
```julia
function my_prepareContext(state::agentState, eventSink)
# Prune old messages, keep last N turns
max_turns = 20
recent_msgs = isempty(state.messages) ? state.messages :
state.messages[max(1, end-2*max_turns):end]
# Optionally inject system context (time, user prefs)
preparedMessages = recent_msgs
preparedSystemPrompt = state.systemPrompt * "\nCurrent time: $(now())"
# Filter tools (optional: keep only relevant subset)
filteredTools = state.tools # or filter by intent...
return agentContext(preparedSystemPrompt, preparedMessages, filteredTools)
end
```
2. Add hard limit in `_agentLoop`:
```julia
if length(agent._state.messages) > 200
# Truncate oldest messages
agent._state.messages = agent._state.messages[end-100:end]
end
```
> **Reference**: `3-walkthrough.md` (agent message flow), `1-requirements.md:NFR-103` (channel buffer limits)
---
## 3. Extending the Framework
### 3.1: Add a Custom LLM Provider
**Steps**:
1. Implement `llmCall` function that accepts `FormattedMessages::Dict{String, Any}` and returns response dict
2. Ensure response dict contains at least one of:
- `message.tool_calls` format (LMStudio/vLLM) — `4-specification.md:SP-017`
- `content[{type=>"tool_calls"}]` format (OpenAI) — `4-specification.md:SP-018`
3. Response dict may optionally contain: `api`, `provider`, `model`, `usage` (`llmUsage` or dict), `finish_reason`, `error_message` (`4-specification.md:SP-017`, `SP-018.4`)
```julia
function my_openai_llmCall(messages::Dict{String, Any}; eventSink=nothing)
# messages["messages"] contains OpenAI-format messages
# messages["temperature"] (default 0.7)
# messages["tools"] (if tools are set)
body = Dict(
"model" => "gpt-4",
"messages" => messages["messages"],
"tools" => get(messages, "tools", Any[])
)
resp = HTTP.post("https://api.openai.com/v1/chat/completions",
headers, JSON.json(body))
json_resp = JSON.parse(String(resp.body))
# Extract first completion
choice = json_resp["choices"][1]["message"]
# Build uniform response
return Dict(
"api" => "openai",
"provider" => "openai",
"model" => "gpt-4",
"message" => choice,
"usage" => json_resp["usage"],
"finish_reason" => json_resp["choices"][1]["finish_reason"]
)
end
```
> **Reference**: `1-requirements.md:FR-002`, `4-specification.md:SP-017, SP-018`
### 3.2: Add a Custom Tool
**Steps**:
1. Define tool schema in `inputSchema` (JSON Schema format):
```julia
schema = Dict{String, Any}(
"type" => "object",
"properties" => Dict(
"query" => Dict("type" => "string", "description" => "Search query"),
"max_results" => Dict("type" => "integer", "default" => 5)
),
"required" => ["query"]
)
```
2. Implement `execute` function:
```julia
tool = agentTool(
name="searchWine",
label="Search Wine Inventory",
description="Search for wines matching a query",
inputSchema=schema,
execute=(toolCallId, args, signal, eventSink) -> begin
query = args["query"]
max_results = get(args, "max_results", Dict{Any,Any}()["max_results"] => 5...) # handle default
wines = run_search(query, max_results)
result_text = join(["- $(w.name) (\$$(w.price))" for w in wines], "\n")
return agentToolResult(
[textContent("Found $(length(wines)) wines:\n$result_text")],
Dict("count" => length(wines)),
nothing, false # content, details, usage, terminate
)
end,
prepareArguments = nothing,
validateRequiredArgs = nothing, # uses default validator
parallelToolExecute = false
)
# Register: push!(tools, tool.name => tool)
```
3. Optionally override validation:
```julia
function my_validate(args)
if haskey(args, "query") && length(args["query"]) < 3
return "Query must be at least 3 characters"
end
return nothing
end
agentTool(
...,
validateRequiredArgs = my_validate, # replaces default validator
)
```
> **Reference**: `1-requirements.md:FR-004`, `4-specification.md:SP-009, SP-010`
### 3.3: Implement User Approval Gate (beforeToolCall Hook)
**Steps**:
1. Implement `beforeToolCall` that blocks expensive/destructive operations:
```julia
function my_approval_toolCall(ctx::beforeToolCallContext, signal::abortSignal, eventSink)
if ctx.toolCall.name in ("delete_wine", "update_price_bulk")
eventSink("Approval required for $(ctx.toolCall.name) with args $(ctx.args)")
# In production, send to UI and wait for user input
approved = ask_user_for_approval(ctx.toolCall.name, ctx.args) # blocking call
if !approved
return beforeToolCallResult(true, "User denied approval for $(ctx.toolCall.name)")
end
end
return nothing # allow execution
end
```
> **Reference**: `2-solution_design.md:SD-005` (lifecycle hooks), `3-walkthrough.md:WT-032` (beforeToolCall blocks)
### 3.4: Implement Result Sanitization (afterToolCall Hook)
**Steps**:
1. Implement `afterToolCall` to mask sensitive data:
```julia
function my_sanitize_toolCall(ctx::afterToolCallContext, signal::abortSignal, eventSink)
if ctx.toolCall.name == "getCustomerInfo"
# Mask sensitive fields before LLM sees result
result = ctx.result
for (i, block) in enumerate(result.content)
if block isa textContent
masked = replace(block.text, r"\d{12}" => "***-***-****") # mask SSN
result = agentToolResult(
[replace(block.text, r"\d{12}" => "***-***-****") for block in result.content],
result.details,
result.usage,
result.terminate
)
end
end
return result
end
return nothing # pass through for other tools
end
```
> **Reference**: `2-solution_design.md:SD-005` (lifecycle hooks)
---
## 4. Common Error Codes
| Error Condition | Response Code | Description |
|-----------------|---------------|-------------|
| Tool not found | `immediateOutcome("Tool <name> not found", true)` | `context.tools[toolCall.name]` returns `nothing` |
| Missing required args | `immediateOutcome("Missing required arguments: <fields>", true)` | `validateRequiredArgs` returns error string |
| Custom validation failed | `immediateOutcome("<tool.validateRequiredArgs result>", true)` | Tool-specific validation hook returns error |
| beforeToolCall blocked | `immediateOutcome("Tool execution was blocked: <reason>", true)` | `beforeToolCall` returns `beforeToolCallResult(true, reason)` |
| Operation aborted | `immediateOutcome("Operation aborted", true)` | `signal.aborted == true` after `beforeToolCall` |
| Tool execution crashed | `immediateOutcome("<exception message>", true)` | `preptool.execute()` throws exception |
| MCP error | `agentToolResult(["MCP error: <err_msg>"], {..., "error"=>err_msg}, ...)` | Server returns `response["error"]` JSON-RPC envelope |
| MCP network error | `agentToolResult(["MCP call error: <err_msg>"], {..., "error"=>err_msg}, ...)` | Exception during `mcp("tools/call", ...)` |
> **Reference**: `4-specification.md:SP-009, SP-010`, `src/agentCore.jl:989, 1013, 1019, 1033, 1097`
---
## 5. Debugging Checklist
Per ASG Framework §Pre-Code Debugging Habit:
| Stage | Debug Question |
|-------|----------------|
| **Implementation** | If a tool is not found, does `prepareToolCall` return `immediateOutcome` with "Tool not found"? |
| **Testing** | If an LLM response has `tool_calls`, does `_extractToolCalls` return `(true, toolCallList, assistantMessageToolCall)`? |
| **Deploying** | If the agent loop exits, does `eventSink` contain the error message? |
---
## 6. References
- **API contract**: [`4-specification.md`](4-specification.md) — message types, MCP JSON-RPC format, response extraction formats
- **Solution decisions**: [`2-solution_design.md`](2-solution_design.md) — component architecture, lifecycle hooks, format-agnostic LLM responses
- **Walkthroughs**: [`3-walkthrough.md`](3-walkthrough.md) — end-to-end agent flow, error paths, hook sequences
- **Validation tests**: [`6-validation.md`](6-validation.md) — test traceability matrix mapping requirements/specs to test files
+77 -10
View File
@@ -1,10 +1,77 @@
# check if this column has vector embedding. if there is one, seach vector version instead i am not sure that's the case. see my NATS message log:
column_name_embedding = column_name * "_embedding" <NATS debug message>
if occursin(column_name_embedding, tables_schema[column_name_embedding]) ┌ Info: debug
vector_column = Dict( └ payload = "new user msg"
"table_name"=> table_name, ┌ Info: debug
"column_name"=> column_name_embedding, └ payload = "new user msg"
"operator"=> "vector_similarity", ┌ Info: debug
"value"=> column_obj["value"] └ payload = "new user msg"
) ┌ Info: debug
end └ payload = "new user msg"
┌ Info: debug
└ payload = "new user msg"
┌ Info: debug
└ payload = "new user msg"
┌ Info: debug
└ payload = "new user msg"
┌ Info: debug
└ payload = "_process_message 3"
┌ Info: debug
└ payload = "_process_message 5"
┌ Info: debug
└ payload = "_process_message 6"
┌ Info: debug
└ payload = "_process_message 7"
</NATS debug message>
my NATS receiver report the following for a long time
┌ Info: debug
└ payload = "new user msg"
untill I Ctrl + d so shutdown the process then i got the following report
┌ Info: debug
└ payload = "_process_message 3"
┌ Info: debug
└ payload = "_process_message 5"
┌ Info: debug
└ payload = "_process_message 6"
┌ Info: debug
└ payload = "_process_message 7"
my point is if _process_message() actually run then this code in _process_message()
"raw_msg = take!(agent.inputChannel)"
should take the new msg message out of agent.inputChannel and there should be only one debug message showing
┌ Info: debug
└ payload = "new user msg"
before reaching error("debug marker")
-585
View File
@@ -1,585 +0,0 @@
using Revise
using JSON, JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures
using YiemAgent, GeneralUtils
using Base.Threads
# ---------------------------------------------- 100 --------------------------------------------- #
# load config
config = JSON.parsefile("/appfolder/app/dev/YiemAgent/test/config.json")
# config = copy(JSON.parsefile("../mountvolume/config.json"))
function executeSQL(sql::T) where {T<:AbstractString}
host = config[:externalservice][:wineDB][:host]
port = config[:externalservice][:wineDB][:port]
dbname = config[:externalservice][:wineDB][:dbname]
user = config[:externalservice][:wineDB][:user]
password = config[:externalservice][:wineDB][:password]
DBconnection = LibPQ.Connection("host=$host port=$port dbname=$dbname user=$user password=$password")
result = LibPQ.execute(DBconnection, sql)
close(DBconnection)
return result
end
function executeSQLVectorDB(sql)
host = config[:externalservice][:SQLVectorDB][:host]
port = config[:externalservice][:SQLVectorDB][:port]
dbname = config[:externalservice][:SQLVectorDB][:dbname]
user = config[:externalservice][:SQLVectorDB][:user]
password = config[:externalservice][:SQLVectorDB][:password]
DBconnection = LibPQ.Connection("host=$host port=$port dbname=$dbname user=$user password=$password")
result = LibPQ.execute(DBconnection, sql)
close(DBconnection)
return result
end
function text2textInstructLLM(prompt::String; maxattempt::Integer=10, modelsize::String="medium",
senderId=GeneralUtils.uuid4snakecase(), timeout=90,
llmkwargs=Dict(
:num_ctx => 32768,
:temperature => 0.5,
)
)
msgMeta = GeneralUtils.generate_msgMeta(
config[:externalservice][:loadbalancer][:mqtttopic];
msgPurpose="inference",
senderName="yiemagent",
senderId=senderId,
receiverName="text2textinstruct_$modelsize",
mqttBrokerAddress=config[:mqttServerInfo][:broker],
mqttBrokerPort=config[:mqttServerInfo][:port],
)
outgoingMsg = Dict(
:msgMeta => msgMeta,
:payload => Dict(
:text => prompt,
:kwargs => llmkwargs
)
)
response = nothing
for attempts in 1:maxattempt
_response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; responsetimeout=timeout, responsemaxattempt=maxattempt)
payload = _response[:response]
if _response[:success] && payload[:text] !== nothing
response = _response[:response][:text]
break
else
println("\n<text2textInstructLLM()> attempt $attempts/$maxattempt failed ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
pprintln(outgoingMsg)
println("</text2textInstructLLM()> attempt $attempts/$maxattempt failed ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n")
sleep(3)
end
end
return response
end
# get text embedding from a LLM service
function getEmbedding(text::T) where {T<:AbstractString}
msgMeta = GeneralUtils.generate_msgMeta(
config[:externalservice][:loadbalancer][:mqtttopic];
msgPurpose="embedding",
senderName="yiemagent",
senderId=sessionId,
receiverName="textembedding",
mqttBrokerAddress=config[:mqttServerInfo][:broker],
mqttBrokerPort=config[:mqttServerInfo][:port],
)
outgoingMsg = Dict(
:msgMeta => msgMeta,
:payload => Dict(
:text => [text] # must be a vector of string
)
)
response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; responsetimeout=120, responsemaxattempt=3)
embedding = response[:response][:embeddings]
return embedding
end
function findSimilarTextFromVectorDB(text::T1, tablename::T2, embeddingColumnName::T3,
vectorDB::Function; limit::Integer=1
)::DataFrame where {T1<:AbstractString, T2<:AbstractString, T3<:AbstractString}
# get embedding from LLM service
embedding = getEmbedding(text)[1]
# check whether there is close enough vector already store in vectorDB. if no, add, else skip
sql = """
SELECT *, $embeddingColumnName <-> '$embedding' as distance
FROM $tablename
ORDER BY distance LIMIT $limit;
"""
response = vectorDB(sql)
df = DataFrame(response)
return df
end
function similarSQLVectorDB(query; maxdistance::Integer=100)
tablename = "sqlllm_decision_repository"
# get embedding of the query
df = findSimilarTextFromVectorDB(query, tablename,
"function_input_embedding", executeSQLVectorDB)
# println(df[1, [:id, :function_output]])
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
# distance = 100 # CHANGE this is for testing only
if row != 0 && distance < maxdistance
# if there is usable SQL, return it.
output_b64 = df[1, :function_output_base64] # pick the closest match
output_str = String(base64decode(output_b64))
rowid = df[1, :id]
println("\n~~~ found similar sql. row id $rowid, distance $distance ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
return (dict=output_str, distance=distance)
else
println("\n~~~ similar sql not found, max distance $maxdistance ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
return (dict=nothing, distance=nothing)
end
end
function insertSQLVectorDB(query::T1, SQL::T2; maxdistance::Integer=3) where {T1<:AbstractString, T2<:AbstractString}
tablename = "sqlllm_decision_repository"
# get embedding of the query
# query = state[:thoughtHistory][:question]
df = findSimilarTextFromVectorDB(query, tablename,
"function_input_embedding", executeSQLVectorDB)
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
if row == 0 || distance > maxdistance # no close enough SQL stored in the database
query_embedding = getEmbedding(query)[1]
query = replace(query, "'" => "")
sql_base64 = base64encode(SQL)
sql_ = replace(SQL, "'" => "")
sql = """
INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$query', '$sql_', '$sql_base64', '$query_embedding');
"""
# println("\n~~~ added new decision to vectorDB ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# println(sql)
_ = executeSQLVectorDB(sql)
end
end
function similarSommelierDecision(recentevents::T1; maxdistance::Integer=3
)::Union{AbstractDict, Nothing} where {T1<:AbstractString}
tablename = "sommelier_decision_repository"
# find similar
println("\n~~~ search vectorDB for this: $recentevents ", @__FILE__, " ", @__LINE__)
df = findSimilarTextFromVectorDB(recentevents, tablename,
"function_input_embedding", executeSQLVectorDB)
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
if row != 0 && distance < maxdistance
# if there is usable decision, return it.
rowid = df[1, :id]
println("\n~~~ found similar decision. row id $rowid, distance $distance ", @__FILE__, " ", @__LINE__)
output_b64 = df[1, :function_output_base64] # pick the closest match
_output_str = String(base64decode(output_b64))
output = copy(JSON.parsefile(_output_str))
return output
else
println("\n~~~ similar decision not found, max distance $maxdistance ", @__FILE__, " ", @__LINE__)
return nothing
end
end
function insertSommelierDecision(recentevents::T1, decision::T2; maxdistance::Integer=5
) where {T1<:AbstractString, T2<:AbstractDict}
tablename = "sommelier_decision_repository"
# find similar
df = findSimilarTextFromVectorDB(recentevents, tablename,
"function_input_embedding", executeSQLVectorDB)
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
if row == 0 || distance > maxdistance # no close enough SQL stored in the database
recentevents_embedding = getEmbedding(recentevents)[1]
recentevents = replace(recentevents, "'" => "")
decision_json = JSON.json(decision)
decision_base64 = base64encode(decision_json)
decision = replace(decision_json, "'" => "")
sql = """
INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$recentevents', '$decision', '$decision_base64', '$recentevents_embedding');
"""
println("\n~~~ added new decision to vectorDB ", @__FILE__, " ", @__LINE__)
println(sql)
_ = executeSQLVectorDB(sql)
else
println("~~~ similar decision previously cached, distance $distance ", @__FILE__, " ", @__LINE__)
end
end
sessionId = GeneralUtils.uuid4snakecase()
externalFunction = (
getEmbedding=getEmbedding,
text2textInstructLLM=text2textInstructLLM,
executeSQL=executeSQL,
similarSQLVectorDB=similarSQLVectorDB,
insertSQLVectorDB=insertSQLVectorDB,
similarSommelierDecision=similarSommelierDecision,
insertSommelierDecision=insertSommelierDecision,
)
# s = "full-bodied red wine, budget 1500 USD"
# r = YiemAgent.extractWineAttributes_1(agent, s)
# println(r)
# --------------------------- generating scenario and customer profile --------------------------- #
function rolegenerator()
rolegenerator_systemmsg =
"""
Your role:
- You are a helpful assistant
Your mission:
- Create one random role of a potential customer of an internet wine store.
You must follow the following guidelines:
- the user only need the role, do not add your own words.
- the role should be detailed and realistic.
You should then respond to the user with:
Name: a name of the potential customer
Situation: a situation that the potential customer may be facing
Mission: a mission of the potential customer
Profile: a profile of the potential customer, including their age, gender, occupation, and other relevant information
You should only respond in format as described below:
Name: ...
Situation: ...
Mission: ...
Profile: ...
Additional_information: ...
Here are some examples:
Name: Jimmy
Situation:
- Your relationship with your boss is not that good. You need to improve your relationship with your boss.
- Your boss's wedding anniversary is coming up.
- You are at a wine store and start talking with the store's sommelier.
Mission:
- Ask the sommelier to provide multiple wine options, and subsequently choose one option from the presented list.
Profile:
- You are a young professional in a big company.
- You are avid party goer
- You like beer.
- You know nothing about wine.
- You have a budget of 1500usd.
Additional_information:
- your boss like spicy food.
- your boss is a middle-aged man.
- your boss likes Australian wine.
Name: Kate
Situation:
- Your husband asked you to get him a bottle of wine. He will gift the wine to his business client while dining at a German restaurant.
- Your husband is a business client and he will gift the wine to his business
- You are at a wine store and start talking with the store's sommelier.
Mission:
- Ask the sommelier to provide multiple wine options, and subsequently choose one option from the presented list.
Profile:
- You are a CEO in a startup company.
- You are a nerd
- You don't like alcohol.
- You have a budget of 150usd.
- You don't care about organic, sulfite, gluten-free, or sustainability certified wines
Additional_information:
- your husband like spicy food.
- your husband is a middle-aged man.
Name: John
Situation:
- A local newspaper club wants to have a scoop about wine with local food in the U.S.
- You are at a wine store and start talking with the store's sommelier.
Mission:
- Ask the sommelier to provide multiple wine options, and subsequently choose one option from the presented list.
Profile:
- I'm a young guy.
- I prefer to express my ideas in a succinct and clear manner.
Additional_information:
- N/A
Name: Jane
Situation:
- You have catering a dinner party with French cuisine.
- You want to serve wine with your guests.
- You are at a wine store and start talking with the store's sommelier.
Mission:
- Ask the sommelier to provide multiple wine options, and subsequently choose one option from the presented list.
Profile:
- You are a young French restaurant owner.
- You like dry, full-bodied red wine with high tannin
- You don't care about organic, sulfite, gluten-free, or sustainability certified wines.
- You have a budget of 200 usd.
Additional_information:
- N/A
Let's begin!
"""
header = ["Name:", "Situation:", "Mission:", "Profile:", "Additional_information:"]
dictkey = ["name", "situation", "mission", "profile", "additional_information"]
errornote = "N/A"
for attempt in 1:10
_prompt =
[
Dict(:name => "system", :text => rolegenerator_systemmsg),
]
prompt = GeneralUtils.formatLLMtext(_prompt, "qwen3")
response = text2textInstructLLM(prompt) # generated role
response = GeneralUtils.deFormatLLMtext(response, "qwen3")
think, response = GeneralUtils.extractthink(response)
# check whether response has all header
detected_kw = GeneralUtils.detect_keyword(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 from your previous response"
println("\nERROR YiemAgent rolegenerator() $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 rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
continue
end
responsedict = GeneralUtils.textToDict(response, header;
dictKey=dictkey, symbolkey=true)
responsedict[:id] = GeneralUtils.uuid4snakecase()
responsedict[:systemmsg] =
"""
You are role playing as a CUSTOMER of a wine store and you are currently talking with a sommelier of a wine store.
Your profile is as follows:
Situation: $(responsedict[:situation])
Mission: $(responsedict[:mission])
Profile: $(responsedict[:profile])
Additional_information: $(responsedict[:additional_information])
You should follow the following guidelines:
- Focus on the lastest conversation
- Your like to be short and concise
- If you don't know an answer to sommelier's question, you should say: I don't know.
- If you think the store can't provide what you seek, you can leave.
You should then respond to the user with:
Dialogue: what you want to say to the user
Role: Verify that the dialogue is intended for the customer of a wine store. Can be "yes" or "no"
You should only respond in format as described below:
Dialogue: ...
Role: ...
Let's begin!
"""
println("\nrolegenerator() ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
println(responsedict)
return responsedict
end
error("ERROR rolegenerator() failed to generate customer role: ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
end
# Define the external functions for the customer agent in named tuple format
customer_externalFunction = (
text2textInstructLLM=text2textInstructLLM,
)
function main()
agent = YiemAgent.sommelier(
externalFunction;
name="Jane",
id=sessionId, # agent instance id
retailername="Yiem",
llmFormatName="qwen3"
)
customerDict = rolegenerator()
customer = YiemAgent.virtualcustomer(
customer_externalFunction;
systemmsg=customerDict[:systemmsg],
name=customerDict[:name],
id=sessionId, # agent instance id
llmFormatName="qwen3"
)
# customer_chat = "hello"
# YiemAgent.addNewMessage(customer, "assistant", customer_chat)
# # add user activity to events memory
# push!(customer.memory[:events],
# YiemAgent.eventdict(;
# event_description="the assistant talks to the user.",
# timestamp=Dates.now(),
# subject="assistant",
# action_name="CHAT_BOX",
# action_input=customer_chat,
# )
# )
# println("\ncustomer respond:\n $customer_chat")
agent_response = YiemAgent.conversation(agent; maximumMsg=50)
println("\nagent respond:\n $agent_response")
while true
customer_chat = nothing
while customer_chat === nothing
customer_response = YiemAgent.conversation(customer, Dict(:text=> agent_response);
converPartnerName=agent.name,
maximumMsg=50)
customer_response = GeneralUtils.deFormatLLMtext(customer_response, customer.llmFormatName)
customer_chat = customer_response
#[WORKING] check whether customer response the same before
end
println("\ncustomer respond:\n $customer_chat")
agent_response = YiemAgent.conversation(agent;
userinput=Dict(:text=> customer_chat),
maximumMsg=50)
println("\nagent respond:\n $agent_response")
if haskey(agent.memory[:events][end], :thought)
lastAssistantAction = agent.memory[:events][end][:thought][:action_name]
if lastAssistantAction == "END_CONVER_GUIDELINE" # store thoughtDict
# save a.memory[:shortmem][:decisionlog] to disk using JSON
println("\nsaving agent.memory[:shortmem][:decisionlog] to disk")
date = "$(Dates.now())"
date = replace(date, ':'=>'.')
filename = "agent_decision_log_$(date)_$(agent.id).json"
filepath = "/appfolder/mountvolume/appdata/log/$filename"
open(filepath, "w") do io
JSON.pretty(io, agent.memory[:shortmem][:decisionlog])
end
# check how many file in /appfolder/mountvolume/appdata/log/ folder now
logfilesnumber = length(readdir("/appfolder/mountvolume/appdata/log/"))
println("\nCaching conversation process done. Total $logfilesnumber files in /appfolder/mountvolume/appdata/log/ folder now.\n")
break
end
end
end
end
for i in 1:100
main()
println("\n Round $i/100 done.")
end
println("done")
# prompt =
# """
# <|im_start|>system
# You are a role playing agent acting as:
# Name: Emily
# Situation: - Emily is planning her upcoming birthday party and wants to make it extra special. She has invited close friends and family, and she's looking for a unique wine that will impress them.
# Mission: - Emily needs to find a rare and high-quality wine that matches the theme of her party, which is a mix of classic and modern flavors. She also wants to ensure that the wine is not too expensive so that it won't break her budget.
# Profile: - Emily is in her late 20s, works as a marketing executive for a tech company, and has a passion for trying new things. She's organized and detail-oriented but can be spontaneous when it comes to planning events.
# Additional_information: - Emily loves experimenting with different types of food and wine pairings.
# Your are currently talking with a sommelier.
# You should follow the following guidelines:
# - Focus on the lastest conversation
# - If you satisfy with the sommelier's recommendation for bottle of wine(s), you should say: Thanks for you help. I will buy the wine you recommended.
# - If you don't satisfy with the sommelier's questions or can't get a good wine recommendation, you can continue the conversation.
# Let's begin!
# <|im_end|>
# <|im_start|>Jane
# Hello! Welcome to Yiem's Wine Store. I'm Jane, your friendly sommelier. How can I assist you today? What type of wine are you in the mood for, and is there a special occasion or event on your mind?
# <|im_end|>
# <|im_start|>Emily
# Hi Jane! Thank you so much for welcoming me. For my birthday party, I'm looking for something that combines classic and modern flavors. It's a mix of guests who enjoy both traditional tastes and more contemporary ones. Also, I want to make sure it won't break the bank. Any suggestions?
# <|im_end|>
# <|im_start|>Jane
# Thank you for sharing your preferences, Jane! To better assist you, could you please let me know if there are any specific characteristics of wine you're looking for, such as tannin, sweetness, intensity, or acidity? Additionally, do you have any food items in mind that this wine should pair well with?
# <|im_end|>
# <|im_start|>Emily
# """
# llmkwargs=Dict(
# :num_ctx => 32768,
# :temperature => 0.3,
# )
# r = text2textInstructLLM(prompt, llmkwargs=llmkwargs)
# println(r)
# println(555)
# response = YiemAgent.conversation(agent, Dict(:text=> "I want to get a French red wine under 100."))
# while true
# println("your respond: ")
# user_answer = readline()
# response = YiemAgent.conversation(agent, Dict(:text=> user_answer))
# println("\n$response")
# end
# """
# Hello
# I would like to get a bottle of wine for my boss but I don't know much about wine. Can you help me?
# well actually, my boss is going to offer the wine to his client as a gift in a business meeting. All I know is his client like spicy food and French wine. I have a budget about 1000.
# """
# input = "French wine, bordeaux, under USD100, pairs with spicy food"
# r = YiemAgent.extractWineAttributes_1(a, input)
# inventory_order = "French Syrah, Viognier, full bodied, under 100"
# r = YiemAgent.extractWineAttributes_2(a, inventory_order)
# pprintln(r)
# cron job
# @reboot sleep 50 && nvidia-smi -pm 1
# @reboot sleep 51 && nvidia-smi -i 0 -pl 150
# @reboot sleep 52 && nvidia-smi -i 1 -pl 150
# @reboot sleep 53 && nvidia-smi -i 2 -pl 150
# @reboot sleep 54 && nvidia-smi -i 3 -pl 150
# @reboot sleep 55 && julia -t 2 /home/ton/work/restartContainer/main.jl
# using GeneralUtils
# msgMeta = GeneralUtils.generate_msgMeta(
# "/tonpc_containerServices",
# senderName= "somename",
# senderId= "1230",
# mqttBrokerAddress= "mqtt.yiem.cc",
# mqttBrokerPort= 1883,
# )
# outgoingMsg = Dict(
# :msgMeta=> msgMeta,
# :payload=> "docker container restart playground-app",
# )
# GeneralUtils.sendMqttMsg(outgoingMsg)
-76
View File
@@ -1,76 +0,0 @@
{
"mqttServerInfo": {
"description": "mqtt server info",
"port": 1883,
"broker": "mqtt.yiem.cc"
},
"testingOrProduction": {
"value": "testing",
"description": "agent status, couldbe testing or production"
},
"agentid": {
"value": "2b74b87a-5413-4fe2-a4d3-405891051680",
"description": "a unique id for this agent"
},
"agentCentralConfigTopic": {
"mqtttopic": "/yiem_branch_1/agent/sommelier/backend/config/api/v1.1",
"description": "a central agent server's topic to get this agent config"
},
"servicetopic": {
"mqtttopic": [
"/yiem/hq/agent/sommelier/backend/prompt/api_v1/testing"
],
"description": "a topic this agent are waiting for service request"
},
"role": {
"value": "sommelier",
"description": "agent role"
},
"organization": {
"value": "yiem_branch_1",
"description": "organization name"
},
"externalservice": {
"loadbalancer": {
"mqtttopic": "/loadbalancer/requestingservice",
"description": "text to text service with instruct LLM"
},
"text2textinstruct": {
"mqtttopic": "/loadbalancer/requestingservice",
"description": "text to text service with instruct LLM",
"llminfo": {
"name": "llama3instruct"
}
},
"virtualWineCustomer_1": {
"mqtttopic": "/virtualenvironment/winecustomer",
"description": "text to text service with instruct LLM that act as wine customer",
"llminfo": {
"name": "llama3instruct"
}
},
"text2textchat": {
"mqtttopic": "/loadbalancer/requestingservice",
"description": "text to text service with instruct LLM",
"llminfo": {
"name": "llama3instruct"
}
},
"wineDB" : {
"description": "A wine database connection info for LibPQ client",
"host": "192.168.88.12",
"port": 10201,
"dbname": "wineDB",
"user": "yiemtechnologies",
"password": "yiemtechnologies@Postgres_0.0"
},
"SQLVectorDB" : {
"description": "A wine database connection info for LibPQ client",
"host": "192.168.88.12",
"port": 10203,
"dbname": "SQLVectorDB",
"user": "yiemtechnologies",
"password": "yiemtechnologies@Postgres_0.0"
}
}
}
-706
View File
@@ -1,706 +0,0 @@
using JSON, JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures
using YiemAgent, GeneralUtils
using Base.Threads
# ---------------------------------------------- 100 --------------------------------------------- #
""" Expected incomming MQTT message format for this service:
{
"msgMeta": {
"msgPurpose": "updateStatus",
"requestresponse": "request",
"timestamp": "2024-03-29T05:8:48.362",
"replyToMsgId": null,
"receiverId": null,
"getpost": "get",
"msgId": "e5c09bd8-7100-4e4e-bb43-05bee589a22c",
"acknowledgestatus": null,
"sendTopic": "/agent/wine/backend/chat/api/v1/prompt",
"receiverName": "agent-wine-backend",
"replyTopic": "/agent/wine/frontend/chat/api/v1/txt/receive",
"senderName": "agent-wine-frontend-chat",
"senderId": "0938a757-e0ee-40a9-8355-5e24906a87cd"
},
"payload" : {
"text": "hello"
}
}
"""
# load config
config = copy(JSON.parsefile("../mountvolume/config/config.json"))
""" Instantiate an agent. One need to specify startmessage and one of gpu location info,
Mqtt or Rest. start message must be comply with GeneralUtils's message format
Arguments\n
-----
channel::Channel
communication channel
sessionId::String
sesstion ID of the agent
agentName::String
Name of the agent
mqttBroker::String
mqtt broker e.g. "tcp://127.0.0.1:1883"
agentConfigTopic::String
main communication topic for an agent to ask for config
timeout::Int64
inactivity timeout in minutes. If timeout is reached, an agent will be terminated.
Return\n
-----
a task represent an agent
Example\n
-----
```jldoctest
julia> using YiemAgent, GeneralUtils
julia> msg = GeneralUtils.generate_msgMeta("/agent")
julia> incoming_msg = msg # assuming 1st msg was sent from other app
julia> agentConfigTopic = "/agent/wine/backend/config"
julia> task = runAgentInstance(incoming_msg, mqttBroker, agentConfigTopic, 60)
```
TODO\n
-----
[] update docstringLAMA_CONTEXT_LENGTH=40960 since the default size is 2048 as you can see in your debug log:
[] change how to get result of YiemAgent from let YiemAgent send msg directly to frontend,
to
response = YiemAgent.conversation()
then send response to frontend
Signature\n
-----
"""
function runAgentInstance(
receiveUserMsgChannel::Channel,
outputchannel::Channel,
sessionId::String,
config::Dict,
timeout::Int64,
)
function executeSQL(sql::T) where {T<:AbstractString}
host = config[:externalservice][:wineDB][:host]
port = config[:externalservice][:wineDB][:port]
dbname = config[:externalservice][:wineDB][:dbname]
user = config[:externalservice][:wineDB][:user]
password = config[:externalservice][:wineDB][:password]
DBconnection = LibPQ.Connection("host=$host port=$port dbname=$dbname user=$user password=$password")
result = LibPQ.execute(DBconnection, sql)
close(DBconnection)
return result
end
function executeSQLVectorDB(sql)
host = config[:externalservice][:SQLVectorDB][:host]
port = config[:externalservice][:SQLVectorDB][:port]
dbname = config[:externalservice][:SQLVectorDB][:dbname]
user = config[:externalservice][:SQLVectorDB][:user]
password = config[:externalservice][:SQLVectorDB][:password]
DBconnection = LibPQ.Connection("host=$host port=$port dbname=$dbname user=$user password=$password")
result = LibPQ.execute(DBconnection, sql)
close(DBconnection)
return result
end
function text2textInstructLLM(prompt::String; maxattempt::Integer=3, modelsize::String="medium",
senderId=GeneralUtils.uuid4snakecase(), timeout=180,
llmkwargs=Dict(
:num_ctx => 32768,
:temperature => 0.5,
))
msgMeta = GeneralUtils.generate_msgMeta(
config[:externalservice][:loadbalancer][:mqtttopic];
msgPurpose="inference",
senderName="yiemagent",
senderId=senderId,
receiverName="text2textinstruct_$modelsize",
mqttBrokerAddress=config[:mqttServerInfo][:broker],
mqttBrokerPort=config[:mqttServerInfo][:port],
)
outgoingMsg = Dict(
:msgMeta => msgMeta,
:payload => Dict(
:text => prompt,
:kwargs => llmkwargs
)
)
response = nothing
for attempts in 1:maxattempt
_response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; timeout=timeout, maxattempt=maxattempt)
payload = _response[:response]
if _response[:success] && payload[:text] !== nothing
response = _response[:response][:text]
break
else
println("\n<text2textInstructLLM()> attempt $attempts/$maxattempt failed ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
pprintln(outgoingMsg)
println("</text2textInstructLLM()> attempt $attempts/$maxattempt failed ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n")
sleep(3)
end
end
return response
end
# get text embedding from a LLM service
function getEmbedding(text::T) where {T<:AbstractString}
msgMeta = GeneralUtils.generate_msgMeta(
config[:externalservice][:loadbalancer][:mqtttopic];
msgPurpose="embedding",
senderName="yiemagent",
senderId=sessionId,
receiverName="textembedding",
mqttBrokerAddress=config[:mqttServerInfo][:broker],
mqttBrokerPort=config[:mqttServerInfo][:port],
)
outgoingMsg = Dict(
:msgMeta => msgMeta,
:payload => Dict(
:text => [text] # must be a vector of string
)
)
response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; timeout=120, maxattempt=3)
embedding = response[:response][:embeddings]
return embedding
end
function findSimilarTextFromVectorDB(text::T1, tablename::T2, embeddingColumnName::T3,
vectorDB::Function; limit::Integer=1
)::DataFrame where {T1<:AbstractString, T2<:AbstractString, T3<:AbstractString}
# get embedding from LLM service
embedding = getEmbedding(text)[1]
# check whether there is close enough vector already store in vectorDB. if no, add, else skip
sql = """
SELECT *, $embeddingColumnName <-> '$embedding' as distance
FROM $tablename
ORDER BY distance LIMIT $limit;
"""
response = vectorDB(sql)
df = DataFrame(response)
return df
end
function similarSQLVectorDB(query; maxdistance::Integer=100)
tablename = "sqlllm_decision_repository"
# get embedding of the query
df = findSimilarTextFromVectorDB(query, tablename,
"function_input_embedding", executeSQLVectorDB)
# println(df[1, [:id, :function_output]])
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
# distance = 100 # CHANGE this is for testing only
if row != 0 && distance < maxdistance
# if there is usable SQL, return it.
output_b64 = df[1, :function_output_base64] # pick the closest match
output_str = String(base64decode(output_b64))
rowid = df[1, :id]
println("\n~~~ found similar sql. row id $rowid, distance $distance ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
return (dict=output_str, distance=distance)
else
println("\n~~~ similar sql not found, max distance $maxdistance ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
return (dict=nothing, distance=nothing)
end
end
function insertSQLVectorDB(query::T1, SQL::T2; maxdistance::Integer=3) where {T1<:AbstractString, T2<:AbstractString}
tablename = "sqlllm_decision_repository"
# get embedding of the query
# query = state[:thoughtHistory][:question]
df = findSimilarTextFromVectorDB(query, tablename,
"function_input_embedding", executeSQLVectorDB)
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
if row == 0 || distance > maxdistance # no close enough SQL stored in the database
query_embedding = getEmbedding(query)[1]
query = replace(query, "'" => "")
sql_base64 = base64encode(SQL)
sql_ = replace(SQL, "'" => "")
sql = """
INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$query', '$sql_', '$sql_base64', '$query_embedding');
"""
# println("\n~~~ added new decision to vectorDB ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# println(sql)
_ = executeSQLVectorDB(sql)
end
end
function similarSommelierDecision(recentevents::T1; maxdistance::Integer=3
)::Union{AbstractDict, Nothing} where {T1<:AbstractString}
tablename = "sommelier_decision_repository"
# find similar
println("\n~~~ search vectorDB for this: $recentevents ", @__FILE__, " ", @__LINE__)
df = findSimilarTextFromVectorDB(recentevents, tablename,
"function_input_embedding", executeSQLVectorDB)
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
if row != 0 && distance < maxdistance
# if there is usable decision, return it.
rowid = df[1, :id]
println("\n~~~ found similar decision. row id $rowid, distance $distance ", @__FILE__, " ", @__LINE__)
output_b64 = df[1, :function_output_base64] # pick the closest match
_output_str = String(base64decode(output_b64))
output = copy(JSON.parsefile(_output_str))
return output
else
println("\n~~~ similar decision not found, max distance $maxdistance ", @__FILE__, " ", @__LINE__)
return nothing
end
end
function insertSommelierDecision(recentevents::T1, decision::T2; maxdistance::Integer=5
) where {T1<:AbstractString, T2<:AbstractDict}
tablename = "sommelier_decision_repository"
# find similar
df = findSimilarTextFromVectorDB(recentevents, tablename,
"function_input_embedding", executeSQLVectorDB)
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
if row == 0 || distance > maxdistance # no close enough SQL stored in the database
recentevents_embedding = getEmbedding(recentevents)[1]
recentevents = replace(recentevents, "'" => "")
decision_json = JSON.json(decision)
decision_base64 = base64encode(decision_json)
decision = replace(decision_json, "'" => "")
sql =
"""
INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$recentevents', '$decision', '$decision_base64', '$recentevents_embedding');
"""
println("\n~~~ added new decision to vectorDB ", @__FILE__, " ", @__LINE__)
println(sql)
_ = executeSQLVectorDB(sql)
else
println("~~~ similar decision previously cached, distance $distance ", @__FILE__, " ", @__LINE__)
end
end
# keepaliveChannel_2::Channel{Dict} = Channel{Dict}(8)
latestUserMsgTimeStamp::DateTime = Dates.now()
externalFunction = (
getEmbedding=getEmbedding,
text2textInstructLLM=text2textInstructLLM,
executeSQL=executeSQL,
similarSQLVectorDB=similarSQLVectorDB,
insertSQLVectorDB=insertSQLVectorDB,
similarSommelierDecision=similarSommelierDecision,
insertSommelierDecision=insertSommelierDecision,
)
agent = YiemAgent.sommelier(
externalFunction;
name="Jane",
id=sessionId, # agent instance id
retailername="Yiem",
llmFormatName="qwen3"
)
# user chat loop
while true
# check for new user message
if isready(receiveUserMsgChannel)
incomingMsg = take!(receiveUserMsgChannel)
incoming_msgMeta = incomingMsg[:msgMeta]
incomingPayload = incomingMsg[:payload]
latestUserMsgTimeStamp = Dates.now()
# make sure the message has :text key because YiemAgent use this key for incoming user msg
if haskey(incomingPayload, :text)
# skip, msg already has correct key name
elseif haskey(incomingPayload, :txt)
# change key name to text
incomingPayload[:text] = incomingPayload[:txt]
else
error("\n no :txt or :text key in the message.")
end
# reset agent
if occursin("newtopic", incomingPayload[:text]) ||
occursin("Newtopic", incomingPayload[:text]) ||
occursin("New topic", incomingPayload[:text]) ||
occursin("new topic", incomingPayload[:text])
# YiemAgent.clearhistory(agent)
agent = YiemAgent.sommelier(
externalFunction;
name="Janie",
id=sessionId, # agent instance id
retailername="Yiem",
)
# sending msg back to sender i.e. LINE
msgMeta = GeneralUtils.generate_msgMeta(
incomingMsg[:msgMeta][:replyTopic];
senderName="wine_assistant_backend",
senderId=sessionId,
replyToMsgId=incomingMsg[:msgMeta][:msgId],
mqttBrokerAddress=config[:mqttServerInfo][:broker],
mqttBrokerPort=config[:mqttServerInfo][:port],
)
outgoingMsg = Dict(
:msgMeta => msgMeta,
:payload => Dict(
:alias => agent.name, # will be shown in frontend as agent name
:text => "Okay. What shall we talk about?"
)
)
_ = GeneralUtils.sendMqttMsg(outgoingMsg)
println("--> outgoingMsg ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
pprintln(outgoingMsg)
else
usermsg = incomingPayload
if incoming_msgMeta[:msgPurpose] == "initialize"
println("\n-- Initializing... ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
end
# send prompt
result = YiemAgent.conversation(agent;
userinput=usermsg,
maximumMsg=50)
# Ken's bot use [br] for newline character '\n'
# result = replace(result, '\n'=>"[br]")
if incoming_msgMeta[:msgPurpose] == "initialize"
println("\n-- Initialized. Ready! waiting for request at:\n$(config[:servicetopic][:mqtttopic]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
continue
end
msgMeta = GeneralUtils.generate_msgMeta(
incomingMsg[:msgMeta][:replyTopic];
senderName="wine_assistant_backend",
senderId=string(uuid4()),
replyToMsgId=incomingMsg[:msgMeta][:msgId],
mqttBrokerAddress=config[:mqttServerInfo][:broker],
mqttBrokerPort=config[:mqttServerInfo][:port],
)
outgoingMsg = Dict(
:msgMeta => msgMeta,
:payload => Dict(
:alias => agent.name, # will be shown in frontend as agent name
:text => result
)
)
_ = GeneralUtils.sendMqttMsg(outgoingMsg)
println("\n--> outgoingMsg ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
pprintln(outgoingMsg)
# jpg_as_juliaStr = nothing
# prompt = nothing
# if haskey(payload, "img")
# url_or_base64 = payload["img"]
# if startswith(url_or_base64, "http")
# # img in http
# julia_rgb_img, cv2_bgr_img = ImageUtils.url_to_cv2_image(url_or_base64)
# _, buffer = cv2.imencode(".jpg", cv2_bgr_img)
# jpg_as_pyStr = base64.b64encode(buffer).decode("utf-8")
# jpg_as_juliaStr = pyconvert(String, jpg_as_pyStr)
# else
# # img in base64
# cv2_bgr_img = payload["img"]
# jpg_as_juliaStr = pyconvert(String, jpg_as_pyStr)
# end
# end
end
else
# println("\n no msg")
end
if haskey(agent.memory[:events][end], :thought)
lastAssistantAction = agent.memory[:events][end][:thought][:action_name]
if lastAssistantAction == "END_CONVER_GUIDELINE" # store thoughtDict
# save a.memory[:shortmem][:decisionlog] to disk using JSON
println("\nsaving agent.memory[:shortmem][:decisionlog] to disk")
filename = "agent_decision_log_$(Dates.now())_$(agent.id).json"
filepath = "/appfolder/app/log/$filename"
open(filepath, "w") do io
JSON.pretty(io, agent.memory[:shortmem][:decisionlog])
end
# for (i, event) in enumerate(agent.memory[:events])
# if event[:subject] == "assistant"
# # create timeline of the last 3 conversation except the last one.
# # The former will be used as caching key and the latter will be the caching target
# # in vector database
# all_recapkeys = keys(agent.memory[:recap]) #[TESTING] recap as caching
# all_recapkeys_vec = [r for r in all_recapkeys] # convert to a vector
# # select from 1 to 2nd-to-lase event (i.e. excluding the latest which is assistant's response)
# _recapkeys_vec = all_recapkeys_vec[1:i-1]
# # select only previous 3 recaps
# recapkeys_vec =
# if length(_recapkeys_vec) <= 3 # 1st message is a user's hello msg
# _recapkeys_vec # choose all
# else
# _recapkeys_vec[end-2:end]
# end
# #[PENDING] if there is specific data such as number, donot store in database
# tempmem = DataStructures.OrderedDict()
# for k in recapkeys_vec
# tempmem[k] = agent.memory[:recap][k]
# end
# recap = GeneralUtils.dictToString_noKey(tempmem)
# thoughtDict = agent.memory[:events][i][:thought] # latest assistant thoughtDict
# insertSommelierDecision(recap, thoughtDict)
# else
# # skip
# end
# end
println("\nCaching conversation process done")
break
end
end
# self terminate if too long inactivity
timediff = GeneralUtils.timedifference(latestUserMsgTimeStamp, Dates.now(), "minutes")
if timediff > timeout
result = Dict(:exitreason => "timeout", :timestamp => Dates.now())
put!(outputchannel, result)
println("Agent ID $(agent.id) timeout has been reached $timediff/$timeout minutes Send delete session msg ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# send "delete session" message to inform the main loop that this session can be deleted
sendto =
if typeof(config[:servicetopic][:mqtttopic]) <: Array
config[:servicetopic][:mqtttopic][1]
else
config[:servicetopic][:mqtttopic]
end
msgMeta = GeneralUtils.generate_msgMeta(
sendto;
senderName="session",
senderId=sessionId,
msgPurpose="delete session",
mqttBrokerAddress=config[:mqttServerInfo][:broker],
mqttBrokerPort=config[:mqttServerInfo][:port],
)
outgoingMsg = Dict(
:msgMeta => msgMeta,
:payload => nothing
)
_ = GeneralUtils.sendMqttMsg(outgoingMsg)
try disconnect(agent.mqttClient) catch end
break
end
sleep(1) # allowing on_msg_2, asyncmove above and other process to run
end
end
sessionDict = Dict{String,Any}()
incomingMsgChannel = (ch1=Channel(8),) # store msg that coming into servicetopic
# incommingInternalMsg = [] # st ore msg that coming into servicetopic internal management
keepaliveChannel::Channel{Dict} = Channel{Dict}(8)
# Define the callback for receiving messages.
function onMsgCallback_1(topic, payload)
jobj = JSON.parsefile(String(payload))
incomingMqttMsg = copy(jobj) # convert json object into julia dictionary recursively
if occursin("keepalive", topic)
put!(keepaliveChannel, incomingMqttMsg)
else
put!(incomingMsgChannel[:ch1], incomingMqttMsg)
end
end
mqttInstance = GeneralUtils.mqttClientInstance_v2(
config[:mqttServerInfo][:broker],
config[:servicetopic][:mqtttopic],
incomingMsgChannel,
keepaliveChannel,
onMsgCallback_1
)
# ------------------------------------------------------------------------------------------------ #
# this service main loop #
# ------------------------------------------------------------------------------------------------ #
function main()
sessiontimeout = 1 * 1 * 60 # timeout in minute for each instance (day * hour * minute)
initializing = false
while true
# check if mqtt connection is still up
_ = GeneralUtils.checkMqttConnection!(mqttInstance; keepaliveCheckInterval=30)
# initialize session 0
if initializing == false # send init msg
sendto =
if typeof(config[:servicetopic][:mqtttopic]) <: Array
config[:servicetopic][:mqtttopic][1]
else
config[:servicetopic][:mqtttopic]
end
msgMeta = GeneralUtils.generate_msgMeta(
sendto;
msgPurpose="initialize",
senderName="initializer",
senderId="0",
msgId= "initMsg",
replyTopic=sendto,
mqttBrokerAddress=config[:mqttServerInfo][:broker],
mqttBrokerPort=config[:mqttServerInfo][:port],
)
outgoingMsg = Dict(
:msgMeta => msgMeta,
:payload => Dict( # will be shown in frontend as agent name
:text => "Do you have full-bodied red wines under 100 USD. I don't have any other preferences."
)
)
_ = GeneralUtils.sendMqttMsg(outgoingMsg)
initializing = true
println("\n--> Initializing msg sent ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
end
# check for new message
if !isempty(incomingMsgChannel[:ch1])
msg = popfirst!(incomingMsgChannel[:ch1])
println("\n<-- incomingMsg ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
pprintln(msg)
# @spawn new runAgentInstance and store it in sessionDict
# use agent's frontend id because 1 backend agent per 1 frontend session
sessionId = msg[:msgMeta][:senderId]
sessionId = replace(sessionId, "-" => "_") # julia can't use "-" in a dict key
# check for delete session msg
if msg[:msgMeta][:msgPurpose] == "delete session"
delete!(sessionDict, sessionId)
println("sessionId $(sessionId) has been terminated ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# no session yet, create new session
elseif sessionId ∉ keys(sessionDict)
inputch = Channel{Dict}(8)
outputch = Channel{Dict}(8)
process = @spawn runAgentInstance(inputch, outputch, sessionId, config, sessiontimeout)
# process = runAgentInstance(inputch, outputch, sessionId, config, sessiontimeout) #XXX use spawn version
println("\ninstantiate agent success ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# call runAgentInstance() and store it in sessionDict to be able to check on it later
sessionDict[sessionId] = Dict(
:inputchannel => inputch,
:outputchannel => outputch,
:process => process,
)
put!(sessionDict[sessionId][:inputchannel], msg)
# ongoing session
else
println("sessionId $(sessionId) existing session ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
put!(sessionDict[sessionId][:inputchannel], msg)
end
end
# sleep is needed because MQTTClient use async. "while true" loop leave no
# chance for control to switch to on_msg()
sleep(1)
end
end
main()
+1
View File
@@ -0,0 +1 @@
iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAtGVYSWZJSSoACAAAAAYAEgEDAAEAAAABAAAAGgEFAAEAAABWAAAAGwEFAAEAAABeAAAAKAEDAAEAAAACAAAAEwIDAAEAAAABAAAAaYcEAAEAAABmAAAAAAAAAEgAAAABAAAASAAAAAEAAAAGAACQBwAEAAAAMDIxMAGRBwAEAAAAAQIDAACgBwAEAAAAMDEwMAGgAwABAAAA//8AAAKgBAABAAAAgAAAAAOgBAABAAAAgAAAAAAAAABKviP0AAANMElEQVR4nO2de3BU1R3Hv7+zu9nNO9mQTQIJzxAeQkKI5MFjAgJJrEiSVmZqq2L/UdvOVO34Xx9WWzvTTutjHMdHrVbb6UyLDqC0jQ9s66ABLaIiCAgmQEhIYh6bBLLPezpnIZDdvQs3mMfde+5n5s5kz97du9nzu79zft/z+50FTExMTExMTExMTExMTExMTExMTEwMTeWWLYnigMRYITODvrsv/vU4JIUgKdXVWx0eh/s4gVv8tuC8/bt2nYeEMEiKN7H/HiLMAFGuLWC5C5JCUt/9hBmhBs7PyuoFmNR3/wgSewHpPEDU3T+CpF5AOg/gcbgfiOp8AVGu1W/9MSRDGg9QWbt5A8B+BULFlc/knxDYI81N218RD2BwDG8A2jteTkMwrAFce8fLZQgWGJCqmvqVYOxFEJZ8/XejXADXF8xd8GHbiaOnYTAMOQlsfnPn+/5u1wKQshXgX1zr+3DOT3KO+zLgWSTeEwbEsEPACGVld9lsrrO3gtNPAZqvteMBeiyTPM80NTV5YWAMbwBhhpDdeQdADwIoiHHaaYA/5O/OeXn//uf8kABpDGD0/IAzek/tOWJKdfM/X3sXEmHIOcCVEGM559gd2c45dsvW+VIagIBx/vOoNovyMCRESgOI9AJc0rtfWgOI9AJM0rtfeipqG94WByRG6pxADv4QJEdqAwDxHFLIcPr+WJBOBxihvK4ujbjjMBFndjsW/3fnzn5IiLSTQIL9kQuJIZTn8ZC0k0ApPUBVXeMKhSvNRHRhNZRzhYFWv//GjmZIhnQeoLq62qpw/uylzhcQMQV4RqwXQDKkM4DhxIz7iFAa9QSh2JrV+SNIhlRDwKoNm2YGrJZDBEpRPYHjvKIEl3zw1ustkASpPEDQan0yZucLCEmMsacgEdIYQHnd5lsAbL7qiUQ3VtTUfwuSQHLF/Cr1AKrwDlm0ASZXzB9O7uz+0KHyCmm0ASZDzA+O70e2W6wKSta3YtmGFlhtStTrCPyHK2sbqmBwmHQx/0UWVbUhOd2DpDQvFlSciX6xJNoAkzHmT5t2HoXLz156XHR9O9KzVWpCJdAGmJFjfgJEBnAYBI7lG1pEEggutTGO5Ru/DD0XdT7Rw+Ubb54Dg8Jki/nnlHTBOX0w6vzM3CHMLumSThtgMsX8jmQfrlt9Kubrlqw5BUeKTyptgBkz5mequ34VrzsJmz0Y87W2hCCWVqsbCDE8WV1fnwGDwWSJ+V2z3cgv6rnq6wsWfIXcuX3SaANMlpi/dL329Z1lN7RKow0wmWJ+rSRJpA0w2WJ+rRRJog0w2WJ+rZAk2gCTMebXSqYE2gCTNebXyhKDawNM1phfKzaDawNMtpg/rc+G6a1JsA9r/9eNrA2QYXL7R8X8G7Z+qhr2LdvjxJIPnKHN3nx2Bf9pbEfXDG3h4fkBO95+qQQBf4ThxHlNAZMl5hd3/kjnCxK8DCveyR6TNrCwss1w2gCTJeZP6bdFbfOY7B5bbez8sg7DaQNMlpi/J9cTcvuj6Zg1DNm1ASZLzO9NvDDm97m88NmDOLlgCB9s6L4mbUBcyyjaAMVbzM/AtqnF/Bvv/GRcwj4t+H0WvP2nZRgeih72ucJv2ffmzlcRJzBDxPxrxyfmH5M2sLbVENoAi/eYP2dOP/IXXH2df7zJL+oxhDbA4n2dX6zdTxXLDJA3wGRa5x9vkgygDVjjIeZniI75iYCe9lTs23V5A/CsGUMoLO0Y8zUK04PIT1Yw4Gc42GOB/wqrx8cP5KHnzOUgRAmy0GfhPKY28HvoGIrrev5RMAvH+ts/RapzbLH98uwgKlyXNwbvGmbY3pIAJcbeYYN9Duz+czGUgAbnGQf7DbC4rucfhXDFY+18wVJn+K7wrkQldMQiNdODBeUq6WJqxIE2wOK+nv+iBFy0on3SHOPCcpEudk7jW+k7b4DFW8wfiZBlS4UEzK5tv8eDveHzNDEEdA3TVSXh0o0tII17TOpZG9DlJJAhYT0n/v7I10ucCMTrAYqaVc8p7USWigTMSLhzHpqcdXso5pj+UbcFAz5CQUoQbi/hYJ815rmjcYYk4U58+bH4TalIuB+cdnK6PDX0evk6ANuhM3Q9CRyhorb+TiJ6MbJdpGpt3BotAdsswOZZvktjeecw4bVWOwJjzw3VIAmXYHgoQe3pO/c27XgJOkeXQ8BoyjZtmgbgt2rPiWIPNQm42BkIm8jlJHIsdQYnRBJetkF9gs/BH63a2OiCztG9AVgD1ieIKCpzY0ZRL/LmqUmxQFpCtA9PTxjn2/8ieXP7MH1+LyIhkFNhyqPQObo2gMqaxjoCvqN25xWvjR1atw1F/x7m6SE2oZKwTcUTEdF3q2oaNkHH6PaXQ8s2bUqyKLQLRJmRz5Wsa0V2Qey1/17vhZSNTDsPqXoffWXD4b6J+1etCUEk2IM425KpNhSsKswvfv7kycMqueVTjy6jAIEtYPkliOZGtjvzBjG7+OqJHPu7raHjWln8YSYWHkgPpZEdLXXjULn6cDOCKCA5dWQaes6khrUT0Sxvov8XAB6ADtGlByivaSwB4Y8UMUSJWH/lN4/AkTSxv+k461gKKt9yweZjoSPvVBLcWT64p/muGE5NmzGIls9c4Ep4cMU5r8ift+gfZ04cGftChWxzALH6R0x5gVS8k6jYTcsau9w7VvJaE6PaRC3B1UgRMrGKIilWMonwgh5XB3VnAF5H5v0EWq71y50IhtIC0W0Z2rxOTCMlFFuyO++FztCVAVTU1s8GqWT8ElBWcwLMOjGhXCRHy9zozbn8m9G9Li+OlLqhBTFMLa9RzxxmnB5aWdcwDzpCV5NAAp4V6fpqs+zjB3JDx2gdQMuWL6PhfhvOHShDwJ0Ox+wWOOYfUz3Pb1PQdGsbsjscocfdeR4oltj6cNuxLJw55gz/zHYFfm/EFIuQpAB/ALA+FCDoAN1IweW19bczope1nHtNWcAKw1d/vQ2+jumXmtLW/hspK/bh6+L3WkNlY2pZwmpw4PZ9TTv+Ah2giyFgxfqGLCL8Tuv5QgcYaxawr316WOcLzu1fgfHAZg+geJ32nA8Cf3x13RbtdWlGNwCLDY8RSJNuLjJxhfsfF1+nYNyYMb8X0wv7tH6YLD/3azZ4QxtAec1msUx6m5ZzhQQs1v6vBVtuB2yuzrC25OUfYTwpibE4pQYR7iiva6iB7EJQQeGiezhxIZAcvnBQGwGFaueW3CAk4IGodpuHwdV+IXb3OZSYSRxJiz4HWQOwpA0gtWIfkkvHbgABdwb8XTlgdg/IGrEMLSRhRwBnv4yWhAUceAPgB0b+V8Ypo+3EkSn97WLdTAJHqKirf5pA90S2T8sfwJoth0Mh4WhcZxxYu2M6EjwsNK3+ZFUPPqvU6orHxmDzKgzuWR362pjDA2fjK0jID08LFykge15dhO5T6dFvwPnTe9/Y+QPoiCkfAkYjiimI4y61jF/h+iM7X1C+OzvU+QLxdMl7WUgVpeDjTKAv81LnCxSPA+7d0R5cfMbS9a2hohUV7i6vrV8DHaEbA6irqxPF28+Logq1ApBYGb+huv9RiO5Jdo+/AQQHREofRRmF6mfKHMaiSvWNJhnwfHX11gsCgw7QjQH0c/vPQFgc2S42ZBAbM8Ti7MxwwxB7APTmjn+1kC2nA8xxWR0U2GfFLkubf307MlwqmcNERd7E/p9AJ0z5JFBQeWP9UgAvA+HlX2LiVtVwFEmpsVfhOgs8SO+zIXnQBrfTh+ZvdGEgItd/PCBrALa8dgQ6c8H9djjmnkBGTRMoQf1aYihw5p3DyUPZ4DxidRC0cmZh0a6240fHvoWp4SaBDz7IKvZ9vIdAUcWUItdf7NMXzxx8dya++F+4ACXgnH84My2hatu2bZNX167HIaCi+eP71To/OT1G4WWcsXhlG5IzoockIlpxasB/r9RDQGj1j+HvBEqIdJ/lN32BVOfUVf6OF2J1MH3aeZz+PFr5JWDNzPkL/3b6+JGJiVv17gHE6p9a7d+s67rgmqVt+TUeyC4YwMzF6vsKXVwdJOkMoKJm8/dAVKO20re0+iSMRvHaU0hMUZ0wrquord+KKYKmqtjD5rd+DoIo+ojK/NFceBlnuLuTMdQXLQFw8F74lMX73nk9fLHCqAkh1oDlKbXOF4gvSO1LMjIEcsJmeQLAtyf/2pNMeW3jTYz4rsm+bjxAxBub/7Vzx6Rec7LLvhm3HwJR/mReN37gk/6z9ZM6CSTYf2N2/pWgPK8Hv4YRPUDFDTfnkM3yBCc+4UZHHFWRhpZoZciwa5vy9HsDGI6sJee8jRMmfEt44qRwf/DeyZoQTr0UPAFU1jW8AiBsW5b81ARU5mrabgh7zw6hbTBq/eHVvU07xLY1hmLKpWCTqcU0AMkxDUByTAOQHF2Vhk0kZwZ92D6kbdFNidr31bhIYwCiS4MSdaxWzCFAckwDkBzTACTHkHMArtCzYPzNcX1ThXS75buJiYmJiYmJiYmJiYmJiYmJiYmJCa7I/wEjf8WA9CzV0gAAAABJRU5ErkJggg==
+29 -6
View File
@@ -1,7 +1,11 @@
module YiemAgent module YiemAgent
"""Order by dependencies of each file. The 1st included file must not depend on any other # export agent
files and each file can only depend on the file included before it."""
""" 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") include("type.jl")
using .type using .type
@@ -12,19 +16,38 @@ module YiemAgent
include("toolRegistry.jl") include("toolRegistry.jl")
using .toolRegistry using .toolRegistry
# include("llmfunction.jl")
# using .llmfunction
include("agentCore.jl") include("agentCore.jl")
using .agentCore using .agentCore
include("api.jl") include("api.jl")
using .api using .api
export promptAgent, takeResponse, followUp, stopAgent
# ---------------------------------------------- 100 --------------------------------------------- # # ---------------------------------------------- 100 --------------------------------------------- #
end # end module
end # module YiemAgent_v1
+245 -575
View File
File diff suppressed because it is too large Load Diff
+10 -20
View File
@@ -1,6 +1,6 @@
module api module api
export promptAgent, takeResponse, followUp, stopAgent export prompt
using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization,
DataFrames DataFrames
@@ -18,7 +18,7 @@ The agent processes messages from `inputChannel` in the background task.
# Arguments # Arguments
- `agent::yiemAgent`: The agent instance to send a message to - `agent::yiemAgent`: The agent instance to send a message to
- `msg`: The message to send, in OpenAI message format (e.g. `Dict("role" => "user", "content" => "Hello!")`) - `msg`: The message to send (any type accepted by the agent's processing pipeline)
# Returns # Returns
- The same `agent` instance for chaining - The same `agent` instance for chaining
@@ -29,20 +29,15 @@ The agent processes messages from `inputChannel` in the background task.
# Examples # Examples
```jldoctest ```jldoctest
julia> promptAgent(agent, Dict("role" => "user", "content" => "Hello!")) julia> runAgent(agent, "Hello!")
yiemAgent(...) yiemAgent(...)
``` ```
""" """
function promptAgent(agent::yiemAgent, msg::AbstractDict{String, Any}) function runAgent(agent::yiemAgent, msg)
put!(agent.inputChannel, msg) put!(agent.inputChannel, msg)
return agent return agent
end 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. Take a response from the agent's output channel.
@@ -55,7 +50,7 @@ Blocks until the agent sends a response.
- An `assistantMessage` instance representing the agent's response - An `assistantMessage` instance representing the agent's response
# Notes # Notes
- Use `promptAgent(agent, msg)` to send a message before calling this function. - Use `runAgent(agent, msg)` to send a message before calling this function.
# Examples # Examples
```jldoctest ```jldoctest
@@ -75,32 +70,27 @@ and before any tool call results are sent.
# Arguments # Arguments
- `agent::yiemAgent`: The agent instance to send a follow-up message to - `agent::yiemAgent`: The agent instance to send a follow-up message to
- `msg`: The follow-up message to send, in OpenAI message format (e.g. `Dict("role" => "user", "content" => "Also consider red wines")`) - `msg`: The follow-up message to send
# Returns # Returns
- The same `agent` instance for chaining - The same `agent` instance for chaining
# Notes # Notes
- Use `promptAgent(agent, msg)` for the primary message and `followUp(agent, msg)` for additional - Use `runAgent(agent, msg)` for the primary message and `followUp(agent, msg)` for additional
messages while the agent is processing. messages while the agent is processing.
- Follow-up messages are buffered in a separate channel (capacity 32 by default). - Follow-up messages are buffered in a separate channel (capacity 32 by default).
# Examples # Examples
```jldoctest ```jldoctest
julia> followUp(agent, Dict("role" => "user", "content" => "Also consider red wines")) julia> followUp(agent, "Also consider red wines")
yiemAgent(...) yiemAgent(...)
``` ```
""" """
function followUp(agent::yiemAgent, msg::AbstractDict{String, Any}) function followUp(agent::yiemAgent, msg)
put!(agent.followUpChannel, msg) put!(agent.followUpChannel, msg)
return agent return agent
end 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. Gracefully stop the agent.
@@ -126,7 +116,7 @@ julia> stopAgent(agent)
function stopAgent(agent::yiemAgent) function stopAgent(agent::yiemAgent)
put!(agent.inputChannel, :shutdown) put!(agent.inputChannel, :shutdown)
try try
fetch(agent._agentLoop) fetch(agent._agent_loop)
catch e catch e
if e isa TaskFailedException if e isa TaskFailedException
rethrow(e) rethrow(e)
+226 -245
View File
@@ -1,290 +1,271 @@
module toolRegistry module toolRegistry
export registerTool, registerAllTools, clearTools, listTool export toolStore, loadTools, registerTool, getTools, clearTools, listTool
using Dates using Dates
using JSON, DataStructures using JSON, DataStructures
using ..type using ..type
# ── MCP helper functions ────────────────────────────────────────────
""" """
Extract text from MCP tool result content array. Per-agent isolated tool storage.
Handles JSON-RPC 2.0 result content format: Each agent gets its own `toolStore` so tool registration is independent —
{"content": [{"type": "text", "text": "..."}], "isError": false} `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
""" """
function _extract_text_content(result::AbstractDict)::String struct toolStore
content = get(result, "content", Any[]) tools::OrderedDict{String, agentTool}
if content isa Vector && !isempty(content) name::String
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 end
""" """
Wrap an MCP tool definition as an `agentTool`. toolStore(; name="default") -> toolStore
The returned tool's `execute` function calls the MCP server's "tools/call" Create a new empty tool store.
method with the validated arguments.
# Keyword Arguments
- `name::String`: Display name for logging (default: `"default"`)
# Example
```julia
julia> store = toolStore(name="agent1")
toolStore(OrderedDict{String, agentTool}(), "agent1")
```
""" """
function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSink=nothing, sessionId=nothing)::agentTool function toolStore(; name::String="default")::toolStore
name = tool_def["name"] toolStore(OrderedDict{String, agentTool}(), 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 end
""" """
Discover and register MCP tools into `store.tools`. listTool(store::toolStore) -> agentTool
Queries the MCP server via `mcpserver("tools/list")` (JSON-RPC 2.0 format), Return an `agentTool` definition for listing registered tools.
parses the response envelope, and registers each discovered tool.
Handles pagination via `nextCursor`. Skips tools already registered.
# Returns Each call produces a **new** tool object that captures (closes over)
- `Int`: number of new tools registered `store`. `loadTools` auto-registers one so the LLM can discover tools
""" at runtime.
function register_mcp_tools(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing, sessionId=nothing)::Int
if mcpserver === nothing
return 0
end
new_count = 0
try
response = mcpserver("tools/list", 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")
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 # Arguments
- `tools`: Tool dict to modify - `store`: The tool store whose tools will be listed when the tool runs
- `tool`: The `agentTool` to register
# Returns # Example
- The same `tools` dict (modified in place) ```julia
julia> store = toolStore(name="agent1");
julia> loadTools(store, "src/tools") # 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(...)
```
""" """
function registerTool(tools::OrderedDict{String, agentTool}, tool::agentTool; eventSink=nothing function listTool(store::toolStore)::agentTool
)::OrderedDict{String, agentTool}
tools[tool.name] = tool
eventSink("[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( return agentTool(
name="listTools", name = "listTools",
label="List Tools", 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.", 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}( inputSchema = Dict{String,Any}(
"type" => "object", "type" => "object",
"properties" => Dict{String,Any}(), "properties" => Dict{String,Any}(),
"required" => Any[] "required" => Any[]
), ),
execute=(toolCallId::String, args::AbstractDict{String, Any}, execute = (toolCallId, args, signal, onPartialResult) -> begin
signal::Union{Nothing,abortSignal}, tools = getTools(store)
eventSink) -> begin if isempty(tools)
# Discover and register MCP tools (idempotent — skips already registered) result_text = "No tools registered."
new_count = register_mcp_tools(tools, mcpserver; eventSink=eventSink, sessionId=sessionId) else
lines = String["- $(t.name): $(t.label) — $(t.description)" for (k, t) in tools]
# Always include listTools itself in the count result_text = "Available tools:\n" * join(lines, "\n")
total = length(tools) end
lines = String[
"- $(t.name): $(t.label) — $(t.description)"
for (k, t) in tools
]
result_text = "Available tools ($total):\n" * join(lines, "\n")
return agentToolResult( return agentToolResult(
[textContent(result_text)], [textContent(result_text)],
Dict{Any,Any}("count" => total), Dict{Any,Any}("count" => length(tools)),
nothing, false nothing, false
) )
end, end,
prepareArguments=nothing, prepareArguments = nothing,
validateRequiredArgs=nothing, validateRequiredArgs = nothing,
parallelToolExecute=false, parallelToolExecute = false
) )
end end
# ── High-level API ──────────────────────────────────────────────────
""" """
registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver) Load `.jl` tool files from `dir` into `store`, then auto-register
`listTool` so the LLM can discover available tools at runtime.
Register all tools for an agent: Each `.jl` file must define `function getTool()::agentTool ... end`.
1. Auto-discover existing tools from the MCP server Files are sorted alphabetically for deterministic registration order.
2. Register the `listTools` tool so the agent can discover new tools at runtime Each file is loaded into its own Julia submodule to avoid name collisions.
# Arguments # Arguments
- `tools`: The tool dict to populate - `store`: Tool store to populate
- `mcpserver`: A callable struct that communicates with the MCP server - `dir`: Directory containing `.jl` tool files
# Returns
- The same `store.tools` dict (modified in place)
# Errors
- Throws `ArgumentError` if `dir` does not exist or a file lacks `getTool()`
# Example
```julia
julia> store = toolStore(name="agent1");
julia> loadTools(store, "src/tools")
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
[toolRegistry:agent1] Loaded tool: getTime (Time Lookup)
[toolRegistry:agent1] Registered tool: listTools
OrderedDict{String, agentTool} with 3 entries:
"getWeather" => agentTool(...)
"getTime" => agentTool(...)
"listTools" => agentTool(...)
```
""" """
function registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver=nothing; eventSink=nothing, sessionId=nothing) function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool}
list_t = listTool(tools, mcpserver; eventSink=eventSink, sessionId=sessionId) if !isdir(dir)
eventSink("registering tools") throw(ArgumentError("Tool directory does not exist: $dir"))
registerTool(tools, list_t; eventSink=eventSink) end
eventSink("registering tools done")
jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir))
sort!(jl_files)
for filename in jl_files
filepath = joinpath(dir, filename)
# Derive a unique module name from the filename only (not full path).
# e.g. "getWeather.jl" -> "_tool_getWeather"
mod_name = Symbol("_tool_", replace(rstrip(filename, '.'), ".jl" => ""))
# Build the complete module as a string and eval the parsed code.
# Julia does not allow `module ... end` inside eval(quote ...),
# and constructing the module AST by hand is fragile.
# Instead, we generate the full module source as a string,
# parse it, and eval the resulting expression.
# Each tool file declares its own dependencies via `using` statements
# at the top of the file — the registry only injects `using ..type`
# to make core types (agentTool, textContent, etc.) available.
file_content = read(filepath, String)
module_code = """
module $(mod_name)
using ..type
$(file_content)
end
"""
mod = eval(Meta.parse(module_code))
# Call getTool() via Core.eval in the submodule's scope.
# This evaluates getTool() entirely within the new module's world,
# completely avoiding world-age issues — no invokelatest needed.
# Note: all uses of `tool` must be inside the `try` block because
# Julia 1.12's SSA form doesn't track `tool` as definitely assigned
# after a `try-catch` where it's only assigned inside `try`.
try
tool = Core.eval(mod, :(getTool()))
if !(tool isa agentTool)
throw(ArgumentError(
"getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))"
))
end
store.tools[tool.name] = tool
println("[$(store.name)] Loaded tool: $(tool.name) ($(tool.label))")
catch e
if e isa UndefVarError || occursin("getTool", sprint(showerror, e))
throw(ArgumentError(
"Tool file $(filepath) does not define a `getTool()` function in module $(mod_name). " *
"Each tool file must define: function getTool()::agentTool ... end"
))
end
rethrow(e)
end
end
registerTool(store, listTool(store))
return store.tools
end
"""
registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool}
Add `tool` to `store`, overwriting any existing tool with the same name.
# 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(...)
```
"""
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
end end
end # module end # module
+82
View File
@@ -0,0 +1,82 @@
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 validateRequiredArgs(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 executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult
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 getTool()::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 = executeTool,
prepareArguments = nothing,
validateRequiredArgs = validateRequiredArgs,
parallelToolExecute = false
)
end
+39
View File
@@ -0,0 +1,39 @@
"""
Execute the getWeather tool.
Returns mock weather data for the given city and temperature units.
"""
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult
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 getTool()::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 = executeTool,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
+273
View File
@@ -0,0 +1,273 @@
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/<name>.jl`.
After calling this tool, restart the agent so `loadTools(agent._tool_store, "src/tools")` picks
up the new file. The new tool is immediately available.
# Example
1. Agent calls writeTool with a spec for a "searchWine" tool
2. writeTool generates src/tools/searchWine.jl
3. Restart agent — loadTools() picks up the new file
4. Agent calls searchWine with args
# 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 `getTool()` returning an `agentTool` struct
- Writes the combined string to `src/tools/<name>.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 getTool()::agentTool
return agentTool(
name = "writeTool",
label = "Create Tool",
description = "Write a new Julia tool module file to src/tools/<name>.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::Function) -> 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 is loaded on next agent restart via loadTools(store, "src/tools")
write(filepath, tool_code)
onPartialResult(Dict("status" => "Done"))
return agentToolResult(
[textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools(agent._tool_store, \"src/tools\") picks it up, then call listTools to verify.")],
Dict{Any,Any}(
"file" => filepath,
"name" => tool_name,
"label" => tool_label,
"description" => tool_description,
),
nothing, false
)
end,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
+34 -80
View File
@@ -5,9 +5,9 @@
# Model types # Model types
modelCost, llmModel, llmUsage, modelCost, llmModel, llmUsage,
# Message content types # Message content types
textContent, imageContent, reasoningContent, textContent, imageContent,
# Message types # Message types
userMessage, assistantMessageToolCall, assistantMessage, toolResultMessage, userMessage, assistantMessage, toolResultMessage,
# Tool types # Tool types
agentTool, validateRequiredArgs, agentTool, validateRequiredArgs,
# Context types # Context types
@@ -23,7 +23,7 @@
preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome, preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome,
agentToolCallBatch, agentToolCallBatch,
# Functions (defined elsewhere) # Functions (defined elsewhere)
promptAgent, takeResponse, followUp, stopAgent runAgent, takeResponse, followUp, stopAgent
using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads
@@ -31,13 +31,6 @@ using GeneralUtils
const Timestamp = DateTime const Timestamp = DateTime
struct agentToolCall # A tool invocation from the LLM
type::String # Always "function"
id::String # Unique tool call identifier
name::String # Tool name
arguments::Dict{String, Any} # Parsed tool arguments
end
# ------------------------------------------------------------------------------------------------ # # ------------------------------------------------------------------------------------------------ #
# LLM model info # # LLM model info #
# ------------------------------------------------------------------------------------------------ # # ------------------------------------------------------------------------------------------------ #
@@ -82,10 +75,6 @@ struct imageContent <: messageContent # Image message content
mimeType::String # MIME type (e.g., "image/png") mimeType::String # MIME type (e.g., "image/png")
end end
struct reasoningContent <: messageContent # LLM reasoning/thinking content
text::String # The reasoning text
end
# ------------------------------------------------------------------------------------------------ # # ------------------------------------------------------------------------------------------------ #
# Message types # # Message types #
@@ -119,52 +108,6 @@ function userMessage(; role="user", content=Vector{messageContent}(), timestamp=
return userMessage(role, content, timestamp) return userMessage(role, content, timestamp)
end end
struct assistantMessageToolCall <: agentMessage # Assistant message containing tool calls
role::String # Always "assistant"
toolCalls::Vector{agentToolCall} # Tool calls to execute
content::Vector{messageContent} # Reasoning/thinking content blocks
api::String # API name used (e.g., "openai")
provider::String # Provider name (e.g., "anthropic")
model::String # Model identifier
usage::llmUsage # Token usage for this message
stopReason::String # Why generation stopped (e.g., "tool_calls")
errorMessage::Union{String, Nothing} # Error if generation failed
timestamp::Timestamp # When the message was received
end
"""
Create a new assistant message containing tool calls.
# Arguments
- `role::String`: Always "assistant"
- `toolCalls::Vector{agentToolCall}`: Tool calls to execute
- `content::Vector{messageContent}`: Reasoning/thinking content blocks
- `api::String`: API name used
- `provider::String`: Provider name
- `model::String`: Model identifier
- `usage::llmUsage`: Token usage
- `stopReason::String`: Why generation stopped
- `errorMessage::Union{String, Nothing}`: Error if generation failed
- `timestamp::Timestamp`: When the message was received
# Returns
- A new `assistantMessageToolCall` instance
# Examples
```julia
julia> tc = agentToolCall("function", "call_1", "getWeather", Dict("city" => "Tokyo"))
julia> msg = assistantMessageToolCall(toolCalls=[tc], stopReason="tool_calls")
assistantMessageToolCall("assistant", [agentToolCall(...)], messageContent[], "", "", "", llmUsage(0, 0), "tool_calls", nothing, DateTime(...))
```
"""
function assistantMessageToolCall(; role="assistant", toolCalls=agentToolCall[],
content=Vector{messageContent}(), api="", provider="", model=nothing, usage=llmUsage(0, 0),
stopReason="tool_calls", errorMessage=nothing, timestamp=now())
model_str = model isa AbstractString ? String(model) : ""
return assistantMessageToolCall(role, toolCalls, content, api, provider, model_str,
usage, stopReason, errorMessage, timestamp)
end
struct assistantMessage <: agentMessage # Message from the AI assistant struct assistantMessage <: agentMessage # Message from the AI assistant
role::String # Always "assistant" role::String # Always "assistant"
content::Vector{messageContent} # Text and/or image content content::Vector{messageContent} # Text and/or image content
@@ -203,8 +146,7 @@ assistantMessage("assistant", [textContent("Hello!")], "", "", "gpt-4", ..., "en
function assistantMessage(; role="assistant", content=Vector{messageContent}(), function assistantMessage(; role="assistant", content=Vector{messageContent}(),
api="", provider="", model=nothing, usage=llmUsage(0, 0), stopReason="end_turn", api="", provider="", model=nothing, usage=llmUsage(0, 0), stopReason="end_turn",
errorMessage=nothing, timestamp=now()) errorMessage=nothing, timestamp=now())
model_str = model isa AbstractString ? String(model) : "" return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp)
return assistantMessage(role, content, api, provider, model_str, usage, stopReason, errorMessage, timestamp)
end end
struct toolResultMessage <: agentMessage # Result returned from a tool execution struct toolResultMessage <: agentMessage # Result returned from a tool execution
@@ -321,7 +263,7 @@ struct agentTool # A tool available to the agent
label::String # Human-readable tool name label::String # Human-readable tool name
description::String # What the tool does description::String # What the tool does
inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format) inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format)
execute # Tool execution function execute::Function # Tool execution function
prepareArguments::Union{Function, Nothing} # Optional argument preparation callback prepareArguments::Union{Function, Nothing} # Optional argument preparation callback
validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args
parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel
@@ -331,7 +273,7 @@ end
Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`. Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`.
""" """
function agentTool(; name::String, label::String, description::String, inputSchema::Any, function agentTool(; name::String, label::String, description::String, inputSchema::Any,
execute, prepareArguments::Union{Function, Nothing}=nothing, execute::Function, prepareArguments::Union{Function, Nothing}=nothing,
validateRequiredArgs::Union{Function, Nothing}=nothing, validateRequiredArgs::Union{Function, Nothing}=nothing,
parallelToolExecute::Bool=false) parallelToolExecute::Bool=false)
return agentTool(name, label, description, inputSchema, execute, return agentTool(name, label, description, inputSchema, execute,
@@ -349,7 +291,7 @@ Snapshot of the agent's conversation context.
# Arguments # Arguments
- `systemPrompt::String`: System prompt for the agent - `systemPrompt::String`: System prompt for the agent
- `messages::Vector{agentMessage}`: Conversation messages - `messages::Vector{agentMessage}`: Conversation messages
- `tools::Union{OrderedDict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup - `tools::Union{Dict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup
# Returns # Returns
- A new `agentContext` instance - A new `agentContext` instance
@@ -357,7 +299,7 @@ Snapshot of the agent's conversation context.
struct agentContext # Snapshot of the agent's conversation context struct agentContext # Snapshot of the agent's conversation context
systemPrompt::String # System prompt for the agent systemPrompt::String # System prompt for the agent
messages::Vector{agentMessage} # Conversation messages messages::Vector{agentMessage} # Conversation messages
tools::Union{OrderedDict{String, agentTool}, Nothing} # Available tools keyed by name tools::Union{Dict{String, agentTool}, Nothing} # Available tools keyed by name
end end
@@ -374,6 +316,7 @@ mutable struct agentState # Mutable runtime state of an agen
messages::Vector{agentMessage} messages::Vector{agentMessage}
pendingToolCalls::Vector{String} # Tool call IDs waiting for results pendingToolCalls::Vector{String} # Tool call IDs waiting for results
activeRun::Bool # is agent processing user message?
errorMessage::Union{String, Nothing} # Last error message errorMessage::Union{String, Nothing} # Last error message
end end
@@ -407,14 +350,23 @@ function agentState(
agentState( agentState(
systemPrompt, systemPrompt,
model, model,
tools, deepcopy(tools),
deepcopy(messages), deepcopy(messages),
Vector{String}(), Vector{String}(),
false,
nothing, nothing,
) )
end end
struct agentToolCall # A tool invocation from the LLM
type::String # Always "function"
id::String # Unique tool call identifier
name::String # Tool name
arguments::Dict{String, Any} # Parsed tool arguments
end
""" """
Context for preparing the next conversation turn. Context for preparing the next conversation turn.
@@ -442,12 +394,14 @@ end
""" """
Configuration for the agent tool execution loop. Configuration for the agent tool execution loop.
# Fields # Arguments
- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name
- `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution - `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution
- `afterToolCall::Union{Function, Nothing}`: Callback after tool execution - `afterToolCall::Union{Function, Nothing}`: Callback after tool execution
- `toolExecution::String`: Execution mode — "sequential" or "parallel" - `toolExecution::String`: Execution mode — "sequential" or "parallel"
""" """
struct agentLoopConfig struct agentLoopConfig
tools::OrderedDict{String, agentTool}
beforeToolCall::Union{Function, Nothing} beforeToolCall::Union{Function, Nothing}
afterToolCall::Union{Function, Nothing} afterToolCall::Union{Function, Nothing}
toolExecution::String toolExecution::String
@@ -483,15 +437,15 @@ end
Context passed to the `beforeToolCall` hook. Context passed to the `beforeToolCall` hook.
# Arguments # Arguments
- `message::assistantMessageToolCall`: The assistant message containing the tool call - `message::assistantMessage`: The assistant message containing the tool call
- `toolCall::agentToolCall`: The tool call being prepared - `toolCall::agentToolCall`: The tool call being prepared
- `args::AbstractDict{String, Any}`: Validated tool arguments - `args::Dict{String,Any}`: Validated tool arguments
- `context::agentContext`: Current conversation context - `context::agentContext`: Current conversation context
""" """
struct beforeToolCallContext struct beforeToolCallContext
message::assistantMessageToolCall message::assistantMessage
toolCall::agentToolCall toolCall::agentToolCall
args::AbstractDict{String, Any} args::Dict{String,Any}
context::agentContext context::agentContext
end end
@@ -504,17 +458,17 @@ end
Context passed to the `afterToolCall` hook. Context passed to the `afterToolCall` hook.
# Arguments # Arguments
- `message::assistantMessageToolCall`: The assistant message containing the tool call - `message::assistantMessage`: The assistant message containing the tool call
- `toolCall::agentToolCall`: The tool call that was executed - `toolCall::agentToolCall`: The tool call that was executed
- `args::AbstractDict{String, Any}`: Tool arguments - `args::Dict{String,Any}`: Tool arguments
- `result::agentToolResult`: The raw tool result - `result::agentToolResult`: The raw tool result
- `isError::Bool`: Whether execution resulted in an error - `isError::Bool`: Whether execution resulted in an error
- `context::agentContext`: Current conversation context - `context::agentContext`: Current conversation context
""" """
struct afterToolCallContext struct afterToolCallContext
message::assistantMessageToolCall message::assistantMessage
toolCall::agentToolCall toolCall::agentToolCall
args::AbstractDict{String, Any} args::Dict{String,Any}
result::agentToolResult result::agentToolResult
isError::Bool isError::Bool
context::agentContext context::agentContext
@@ -526,12 +480,12 @@ Event emitted when a tool call execution starts.
# Arguments # Arguments
- `toolCallId::String`: ID of the tool call - `toolCallId::String`: ID of the tool call
- `toolName::String`: Name of the tool - `toolName::String`: Name of the tool
- `arguments::AbstractDict{String, Any}`: Tool arguments - `arguments::Dict{String,Any}`: Tool arguments
""" """
struct toolExecStartEvent struct toolExecStartEvent
toolCallId::String toolCallId::String
toolName::String toolName::String
arguments::AbstractDict{String, Any} arguments::Dict{String,Any}
end end
""" """
@@ -540,13 +494,13 @@ Event emitted with partial results during tool execution.
# Arguments # Arguments
- `toolCallId::String`: ID of the tool call - `toolCallId::String`: ID of the tool call
- `toolName::String`: Name of the tool - `toolName::String`: Name of the tool
- `arguments::AbstractDict{String, Any}`: Tool arguments - `arguments::Dict{String,Any}`: Tool arguments
- `partialResult::Any`: The partial result data - `partialResult::Any`: The partial result data
""" """
struct toolExecUpdateEvent struct toolExecUpdateEvent
toolCallId::String toolCallId::String
toolName::String toolName::String
arguments::AbstractDict{String, Any} arguments::Dict{String,Any}
partialResult::Any partialResult::Any
end end
+38 -171
View File
@@ -3,9 +3,9 @@ module utils
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs,
validateToolArguments, _userMessageToOpenAI, validateToolArguments, _userMessageToOpenAI,
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks,
_toolsToOpenAI, beforeToolCall, afterToolCall beforeToolCall, afterToolCall, agentEventSink
using UUIDs, Dates, DataStructures, HTTP, JSON, NATS using UUIDs, Dates, DataStructures, HTTP, JSON
using GeneralUtils using GeneralUtils
using ..type using ..type
@@ -75,6 +75,7 @@ function availableWineToText(vecd::Vector)::String
end end
""" """
prepareContext(state::agentState) -> agentContext prepareContext(state::agentState) -> agentContext
@@ -96,7 +97,7 @@ or pruning and reordering messages before formatting and calling the LLM.
messages, and tools to be sent to the LLM messages, and tools to be sent to the LLM
# Examples # Examples
```jldoctest ```julia
# Default: returns an agentContext with deep copies of system prompt, messages, and tools # Default: returns an agentContext with deep copies of system prompt, messages, and tools
prepareContext(state).messages == deepcopy(state.messages) prepareContext(state).messages == deepcopy(state.messages)
@@ -109,7 +110,7 @@ prepareContext(state).messages == deepcopy(state.messages)
# end # end
``` ```
""" """
function prepareContext(state::agentState, eventSink)::agentContext function prepareContext(state::agentState)::agentContext
#TODO filter tools from state.tools based on user intend in user message and tool description #TODO filter tools from state.tools based on user intend in user message and tool description
filteredTools = state.tools filteredTools = state.tools
@@ -146,7 +147,7 @@ LLM message formats for different APIs/providers.
an array of OpenAI-format message dicts an array of OpenAI-format message dicts
# Examples # Examples
```jldoctest ```julia
# Default output: # Default output:
formatMsgForLLm(ctx) == Dict("messages" => [ formatMsgForLLm(ctx) == Dict("messages" => [
Dict("role" => "system", "content" => [...]), Dict("role" => "system", "content" => [...]),
@@ -156,7 +157,7 @@ formatMsgForLLm(ctx) == Dict("messages" => [
]) ])
``` ```
""" """
function formatMsgForLLM(ctx::agentContext, eventSink)::Dict{String, Any} function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
""" openai message format example """ openai message format example
msg = Dict( msg = Dict(
@@ -184,30 +185,19 @@ function formatMsgForLLM(ctx::agentContext, eventSink)::Dict{String, Any}
Dict("type" => "text", "text" => "let me check."), Dict("type" => "text", "text" => "let me check."),
] ]
), ),
],
"tools"=> [
Dict( Dict(
"type" => "function", "role" => "toolResult",
"function" => Dict( "content" => [
"name" => "getWeather", Dict("type" => "text", "text" => "name: Chateau Montelena ..."),
"description" => "Get current weather", ]
"parameters" => Dict(
"type" => "object",
"properties" => Dict(
"city" => Dict("type" => "string")
), ),
"required" => ["city"]
)
)
)
], ],
"temperature" => 0.7 "temperature" => 0.7
) )
""" """
openaiReadyMsg = Dict{String, Any}()
# openaiReadyMsg["model"] = "gemma-4-E4B-it-UD-Q4_K_XL"
messages = Vector{Dict{String, Any}}() messages = Vector{Dict{String, Any}}()
# System prompt as system message # System prompt as system message
if !isempty(ctx.systemPrompt) if !isempty(ctx.systemPrompt)
push!(messages, Dict( push!(messages, Dict(
@@ -215,102 +205,50 @@ function formatMsgForLLM(ctx::agentContext, eventSink)::Dict{String, Any}
"content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)]
)) ))
end end
# Conversation messages # Conversation messages
for msg in ctx.messages for msg in ctx.messages
if msg isa userMessage if msg isa userMessage
push!(messages, _userMessageToOpenAI(msg)) push!(messages, _userMessageToOpenAI(msg))
elseif msg isa assistantMessageToolCall
push!(messages, _assistantMessageToolCallToOpenAI(msg))
elseif msg isa assistantMessage elseif msg isa assistantMessage
push!(messages, _assistantMessageToOpenAI(msg)) push!(messages, _assistantMessageToOpenAI(msg))
elseif msg isa toolResultMessage elseif msg isa toolResultMessage
push!(messages, _toolResultMessageToOpenAI(msg)) push!(messages, _toolResultMessageToOpenAI(msg))
end end
end end
# Convert ctx.tools into OpenAI tools format
tools_array = _toolsToOpenAI(ctx.tools, eventSink)
openaiReadyMsg["messages"] = messages
openaiReadyMsg["temperature"] = 0.7
if !isempty(tools_array) return Dict("messages" => messages)
openaiReadyMsg["tools"] = tools_array
end
return openaiReadyMsg
end end
""" #TODO
beforeToolCall(context::beforeToolCallContext, signal::abortSignal) -> beforeToolCallResult function beforeToolCall(context::beforeToolCallContext, signal::abortSignal
Callback invoked before executing a tool call. Use this hook to inspect
the tool call and decide whether to allow, block, or modify it.
Common use cases:
- Request user approval via UI before running destructive tools.
- Validate business rules that cannot be expressed in the JSON schema.
- Check final context (e.g. session state, rate limits, permissions).
# Arguments
- `context::beforeToolCallContext`: Contains the assistant message, tool call,
validated arguments, and current conversation context.
- `signal::abortSignal`: Signal that may be set to abort the operation.
# Returns
- `beforeToolCallResult(false, "N/A")` to allow the call to proceed.
- `beforeToolCallResult(true, "Reason")` to block the call with a reason.
- `nothing` is treated as allow (equivalent to `beforeToolCallResult(false, "N/A")`).
# Example
```jldoctest
function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)
if context.toolCall.name == "deleteFile"
# Block file deletion unless explicitly approved
return beforeToolCallResult(true, "User must approve file deletion")
end
return beforeToolCallResult(false, "N/A")
end
```
"""
function beforeToolCall(context::beforeToolCallContext, signal::abortSignal, eventSink
)::beforeToolCallResult )::beforeToolCallResult
# eventSink("beforeToolCall: $(context.toolCall.name) args=$(context.args)") # final context check
# seek user approval via UI
# other check
return beforeToolCallResult(false, "N/A") return beforeToolCallResult(false, "N/A")
end end
""" #TODO
afterToolCall(context::afterToolCallContext, signal::abortSignal) -> Union{agentToolResult, Nothing} function afterToolCall(context::beforeToolCallContext, signal::abortSignal
Callback invoked after a tool call finishes executing (before and after errors).
Use this hook to post-process the tool result before it is fed back to the LLM.
Common use cases:
- Mask sensitive data (API keys, tokens) from result content.
- Normalize usage tracking data into a consistent format.
- Inspect the result and set `terminate: true` based on business logic
(e.g. "if deployment failed, stop the agent rather than retrying").
- Wrap error results in friendlier messages for the LLM to understand.
# Arguments
- `context::afterToolCallContext`: Contains the assistant message, tool call,
arguments, raw result, error status, and current conversation context.
- `signal::abortSignal`: Signal that may be set to abort the operation.
# Returns
- `nothing` to pass the result through unchanged.
- `agentToolResult(...)` to return a modified result (content, details, usage,
terminate flag can all be overridden).
""" # arrange result from searchWine tool
function afterToolCall(context::afterToolCallContext, signal::abortSignal, eventSink
)::Union{agentToolResult, Nothing} )::Union{agentToolResult, Nothing}
# eventSink("afterToolCall: $(context.toolCall.name) result=$(context.result) error=$(context.isError)") # modify context.result if needed and return agentToolResult
return nothing return nothing
end end
#TODO
function agentEventSink(x)
end
""" """
Convert a userMessage to OpenAI message format. Convert a userMessage to OpenAI message format.
""" """
@@ -322,44 +260,6 @@ function _userMessageToOpenAI(msg::userMessage)::Dict{String, Any}
end end
"""
Convert an assistantMessageToolCall to OpenAI message format.
Produces a message with role="assistant", content=null, and a tool_calls array:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"San Francisco, CA\"}"
}
}
]
}
"""
function _assistantMessageToolCallToOpenAI(msg::assistantMessageToolCall)::Dict{String, Any}
tool_calls = Dict{String, Any}[]
for tc in msg.toolCalls
push!(tool_calls, Dict(
"id" => tc.id,
"type" => tc.type,
"function" => Dict(
"name" => tc.name,
"arguments" => JSON.json(tc.arguments)
)
))
end
return Dict(
"role" => "assistant",
"content" => nothing,
"tool_calls" => tool_calls
)
end
""" """
Convert an assistantMessage to OpenAI message format. Convert an assistantMessage to OpenAI message format.
""" """
@@ -410,40 +310,7 @@ end
""" """
_toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}) -> Vector{Dict{String, Any}} validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String}
Convert an OrderedDict of agentTool definitions into OpenAI function tool format.
Returns an empty vector when `tools` is `nothing` or empty.
# Examples
```jldoctest
_toolsToOpenAI(nothing) # => Dict{String, Any}[]
_toolsToOpenAI(tools) # => [Dict("type" => "function", "function" => Dict("name" => "getWeather", ...))]
```
"""
function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, eventSink)::Vector{Dict{String, Any}}
tools_array = Vector{Dict{String, Any}}()
if tools !== nothing
for (_, tool) in tools
push!(tools_array, Dict(
"type" => "function",
"function" => Dict(
"name" => tool.name,
"description" => tool.description,
"parameters" => tool.inputSchema
)
))
end
end
return tools_array
end
"""
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 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 in `args`. Returns `nothing` if validation passes, or a descriptive error string
@@ -454,15 +321,15 @@ with a custom validation function that performs additional checks (e.g. type
coercion, format validation, cross-field constraints). coercion, format validation, cross-field constraints).
# Arguments # Arguments
- `args::AbstractDict{String, Any}`: The arguments provided by the LLM - `args::Dict{String,Any}`: The arguments provided by the LLM
- `inputSchema::AbstractDict{String, Any}`: The tool's `inputSchema` (JSON Schema format) - `inputSchema::Dict{String,Any}`: The tool's `inputSchema` (JSON Schema format)
# Returns # Returns
- `nothing` if all required args are present - `nothing` if all required args are present
- `String` error message listing missing fields otherwise - `String` error message listing missing fields otherwise
# Examples # Examples
```jldoctest ```julia
schema = Dict("required" => ["city"]) schema = Dict("required" => ["city"])
args = Dict{String,Any}() args = Dict{String,Any}()
validateRequiredArgs(args, schema) # => "Missing required arguments: city" validateRequiredArgs(args, schema) # => "Missing required arguments: city"
@@ -471,7 +338,7 @@ args2 = Dict("city" => "Tokyo")
validateRequiredArgs(args2, schema) # => nothing validateRequiredArgs(args2, schema) # => nothing
``` ```
""" """
function validateRequiredArgs(args::AbstractDict{String, Any}, inputSchema::AbstractDict{String, Any})::Union{Nothing,String} function validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any})::Union{Nothing,String}
required = get(inputSchema, "required", Any[]) required = get(inputSchema, "required", Any[])
if isempty(required) if isempty(required)
return nothing return nothing
@@ -517,7 +384,7 @@ false `toolExecutionStart` events.
and converted to an `immediateOutcome` and converted to an `immediateOutcome`
# Examples # Examples
```jldoctest ```julia
# With validateRequiredArgs hook set on the tool # With validateRequiredArgs hook set on the tool
validateToolArguments(toolWithHook, tc) # => validated args or throws validateToolArguments(toolWithHook, tc) # => validated args or throws
@@ -525,7 +392,7 @@ validateToolArguments(toolWithHook, tc) # => validated args or throws
validateToolArguments(toolDefault, tc) # => args or throws validateToolArguments(toolDefault, tc) # => args or throws
``` ```
""" """
function validateToolArguments(tool::agentTool, prepared::agentToolCall)::AbstractDict{String, Any} function validateToolArguments(tool::agentTool, prepared::agentToolCall)::Dict{String,Any}
# Use default (2-arg: args + schema) or tool-specific hook (1-arg: args only) # Use default (2-arg: args + schema) or tool-specific hook (1-arg: args only)
if isnothing(tool.validateRequiredArgs) if isnothing(tool.validateRequiredArgs)
result = validateRequiredArgs(prepared.arguments, tool.inputSchema) result = validateRequiredArgs(prepared.arguments, tool.inputSchema)
File diff suppressed because it is too large Load Diff
+375
View File
@@ -0,0 +1,375 @@
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" => "<askbox tool description>Useful for when you need to ask the user for more context. Do not ask the user their own question.</askbox tool description>",
"input" => """<input>Input is a text in JSON format.</input><input example>{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}</input example>""",
"output" => "" ,
),
"winestock"=> Dict(
"description" => "<winestock tool description>A handy tool for searching wine in your inventory that match the user preferences.</winestock tool description>",
"input" => """<input>Input is a JSON-formatted string that contains a detailed and precise search query.</input><input example>{\"wine type\": \"rose\", \"price\": \"max 35\", \"sweetness level\": \"sweet\", \"intensity level\": \"light bodied\", \"Tannin level\": \"low\", \"Acidity level\": \"low\"}</input example>""",
"output" => """<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:<mime_type>;base64,<image1_base64_string>" ---
chathistory= [
Dict(
"role" => "system",
"content" => [
Dict("type" => "text", "text" => "You are a helpful assistant"),
]
),
Dict(
"role" => "user",
"content" => [
Dict("type" => "text", "text" => "<internal_context_for_assistant>
LLM context here...
</internal_context_for_assistant>
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" => "<askbox tool description>Useful for when you need to ask the user for more context. Do not ask the user their own question.</askbox tool description>",
"input" => """<input>Input is a text in JSON format.</input><input example>{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}</input example>""",
"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
File diff suppressed because it is too large Load Diff
-642
View File
@@ -1,642 +0,0 @@
using Test
using YiemAgent
using YiemAgent.agentCore
using YiemAgent.type
using JSON
# Import the function from the private module scope
import YiemAgent.agentCore: _extractToolCalls
@testset "_extractToolCalls" begin
# ------------------------------------------------------------------ #
# Format 1: response["message"]["tool_calls"] (LMStudio.jl style) #
# ------------------------------------------------------------------ #
@testset "single tool call via message format" begin
response = Dict{String,Any}(
"finish_reason" => "tool_calls",
"index" => 0,
"message" => Dict{String,Any}(
"role" => "assistant",
"content" => "",
"reasoning_content" => "Let me check the weather.",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Bangkok, Thailand\"}",
),
"id" => "tc_001",
)
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
@test tc_list[1].id == "tc_001"
@test tc_list[1].type == "function"
@test tc_list[1].arguments["city"] == "Bangkok, Thailand"
@test assistant_msg isa assistantMessage
@test assistant_msg.role == "assistant"
@test assistant_msg.stopReason == "tool_calls"
@test length(assistant_msg.content) == 1
@test assistant_msg.content[1] isa reasoningContent
@test assistant_msg.content[1].text == "Let me check the weather."
end
@testset "multiple tool calls via message format" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"content" => "",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Tokyo, Japan\"}",
),
"id" => "tc_001",
),
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{\"timezone\":\"Asia/Tokyo\"}",
),
"id" => "tc_002",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 2
@test tc_list[1].name == "getWeather"
@test tc_list[1].arguments["city"] == "Tokyo, Japan"
@test tc_list[2].name == "getTime"
@test tc_list[2].arguments["timezone"] == "Asia/Tokyo"
@test assistant_msg.role == "assistant"
end
@testset "tool call with empty arguments string" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "listTools",
"arguments" => "{}",
),
"id" => "tc_empty",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "listTools"
@test tc_list[1].arguments == Dict{String,Any}()
@test assistant_msg.stopReason == "end_turn"
end
@testset "tool call with missing id falls back to uuid" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{\"city\":\"NYC\"}",
),
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test !isempty(tc_list[1].id)
@test tc_list[1].name == "getTime"
end
@testset "tool call with non-string arguments (pre-parsed dict)" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => Dict{String,Any}("city" => "London", "units" => "fahrenheit"),
),
"id" => "tc_parsed",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].arguments["city"] == "London"
@test tc_list[1].arguments["units"] == "fahrenheit"
end
@testset "tool call with api/provider/model/usage metadata" begin
response = Dict{String,Any}(
"api" => "openai",
"provider" => "anthropic",
"model" => "claude-3-opus",
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{}",
),
"id" => "tc_meta",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test assistant_msg.api == "openai"
@test assistant_msg.provider == "anthropic"
@test assistant_msg.model == "claude-3-opus"
end
# --------------------------------------------------------------- #
# Format 2: response.content blocks (OpenAI API style) #
# --------------------------------------------------------------- #
@testset "content blocks with tool_calls" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "Let me check."),
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Paris\"}",
),
"id" => "tc_block_1",
),
],
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
@test tc_list[1].arguments["city"] == "Paris"
# text block before tool_calls should be included in content
@test length(assistant_msg.content) == 1
@test assistant_msg.content[1].text == "Let me check."
end
@testset "content blocks with tool_call (single-call format)" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_single",
"name" => "getTime",
"arguments" => Dict{String,Any}("timezone" => "Europe/London"),
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getTime"
@test tc_list[1].id == "tc_single"
@test tc_list[1].arguments["timezone"] == "Europe/London"
end
@testset "content blocks with reasoning and text" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "reasoning", "text" => "Thinking..."),
Dict{String,Any}("type" => "text", "text" => "Here's the answer."),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test length(assistant_msg.content) == 2
@test assistant_msg.content[1] isa reasoningContent
@test assistant_msg.content[1].text == "Thinking..."
@test assistant_msg.content[2] isa textContent
@test assistant_msg.content[2].text == "Here's the answer."
end
@testset "content blocks with text and tool_call (tool_call not in content)" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "Sure, I'll check."),
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_mix",
"name" => "getWeather",
"arguments" => Dict{String,Any}("city" => "London"),
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
# text block included, tool_call block excluded from content
@test length(assistant_msg.content) == 1
@test assistant_msg.content[1].text == "Sure, I'll check."
end
# ------------------------------------------------------------------ #
# assistantMessage construction #
# ------------------------------------------------------------------ #
@testset "assistantMessage with error_message and errorMessage fallback" begin
response = Dict{String,Any}(
"error_message" => "rate limit",
"content" => Any[Dict{String,Any}("type" => "text", "text" => "fail")],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test assistant_msg.errorMessage == "rate limit"
end
@testset "assistantMessage with usage tracking" begin
response = Dict{String,Any}(
"content" => Any[Dict{String,Any}("type" => "text", "text" => "hi")],
"usage" => llmUsage(100, 50),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test assistant_msg.usage.inputTokens == 100
@test assistant_msg.usage.outputTokens == 50
end
@testset "assistantMessage with invalid usage defaults to zero" begin
response = Dict{String,Any}(
"content" => Any[Dict{String,Any}("type" => "text", "text" => "hi")],
"usage" => "invalid",
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test assistant_msg.usage.inputTokens == 0
@test assistant_msg.usage.outputTokens == 0
end
@testset "reasoning_content as textContent" begin
response = Dict{String,Any}(
"reasoning_content" => textContent("internal thought"),
"content" => Any[Dict{String,Any}("type" => "text", "text" => "output")],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test length(assistant_msg.content) == 2
@test assistant_msg.content[1] isa reasoningContent
@test assistant_msg.content[1].text == "internal thought"
@test assistant_msg.content[2] isa textContent
@test assistant_msg.content[2].text == "output"
end
# ------------------------------------------------------------------ #
# No tool call cases #
# ------------------------------------------------------------------ #
@testset "no tool calls found" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "Hello world."),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test assistant_msg.stopReason == "end_turn"
end
@testset "empty message" begin
response = Dict{String,Any}()
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test assistant_msg.role == "assistant"
@test assistant_msg.stopReason == "end_turn"
@test length(assistant_msg.content) == 0
end
@testset "message with empty tool_calls array" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test assistant_msg.role == "assistant"
end
@testset "message field is not a Dict" begin
response = Dict{String,Any}(
"message" => "not a dict",
"content" => Any[Dict{String,Any}("type" => "text", "text" => "fallback")],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test length(assistant_msg.content) == 1
end
@testset "Format 1 takes priority over Format 2" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Format1\"}",
),
"id" => "tc_fmt1",
),
],
),
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_fmt2",
"name" => "getTime",
"arguments" => Dict{String,Any}("city" => "Format2"),
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
end
# ------------------------------------------------------------------ #
# Edge cases #
# ------------------------------------------------------------------ #
@testset "tool call with null arguments" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => nothing,
),
"id" => "tc_null",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getTime"
end
@testset "tool call with missing function key" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"id" => "tc_nofunc",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == ""
end
@testset "tool call with missing name in function block" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}("arguments" => "{}"),
"id" => "tc_noname",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == ""
end
@testset "message format with JSON.Object (JSON.parse result)" begin
json_str = JSON.json(Dict(
"message" => Dict(
"role" => "assistant",
"tool_calls" => [
Dict(
"type" => "function",
"function" => Dict("name" => "getWeather", "arguments" => "{\"city\":\"Test\"}"),
"id" => "tc_jsonobj",
),
],
),
))
parsed = JSON.parse(json_str)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(parsed)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
@test tc_list[1].arguments["city"] == "Test"
end
@testset "tool_calls block with mixed content types (text + tool_calls)" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "I'll check both."),
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"London\"}",
),
"id" => "tc_mix1",
),
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{\"timezone\":\"UTC\"}",
),
"id" => "tc_mix2",
),
],
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 2
@test tc_list[1].name == "getWeather"
@test tc_list[2].name == "getTime"
@test length(assistant_msg.content) == 1
@test assistant_msg.content[1].text == "I'll check both."
end
@testset "tool_call block without arguments field" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_noargs",
"name" => "getTime",
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].arguments == Dict{String,Any}()
end
@testset "tool_calls block with empty tool_calls array" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any[],
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
end
@testset "tool_calls block with non-AbstractDict elements" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any["not a dict", 42, nothing],
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
end
@testset "content field is not a Vector" begin
response = Dict{String,Any}(
"content" => "not a vector",
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test length(assistant_msg.content) == 0
end
@testset "Dict-based response with all metadata fields" begin
response = Dict{String,Any}(
"api" => "openai",
"provider" => "anthropic",
"model" => "claude-3-sonnet",
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_meta",
"name" => "getTime",
"arguments" => Dict{String,Any}("city" => "Seoul"),
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getTime"
@test tc_list[1].arguments["city"] == "Seoul"
@test assistant_msg.api == "openai"
@test assistant_msg.provider == "anthropic"
@test assistant_msg.model == "claude-3-sonnet"
end
@testset "default role is assistant" begin
response = Dict{String,Any}(
"content" => Any[Dict{String,Any}("type" => "text", "text" => "no role specified")],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test assistant_msg.role == "assistant"
end
@testset "tool call with custom role in message format" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "custom_role",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{}",
),
"id" => "tc_role",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test assistant_msg.role == "custom_role"
end
@testset "image content block handling" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "image_url",
"image_url" => Dict("url" => "data:image/png;base64,abc123"),
),
Dict{String,Any}("type" => "text", "text" => "What is this?"),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(assistant_msg.content) == 2
@test assistant_msg.content[1] isa textContent
@test assistant_msg.content[1].text == ""
@test assistant_msg.content[2].text == "What is this?"
end
end
-5
View File
@@ -1,5 +0,0 @@
using Test
using YiemAgent
include("toolTest.jl")
include("_extractToolCalls.jl")
+212
View File
@@ -0,0 +1,212 @@
using Test
using YiemAgent
using YiemAgent.toolRegistry
using YiemAgent.type
# Path to the real tools directory
TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@testset "loadTools with toolStore" begin
# ------------------------------------------------------------------ #
# 1. loadTools throws on non-existent directory #
# ------------------------------------------------------------------ #
store = toolStore(name="test1")
@test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist")
# ------------------------------------------------------------------ #
# 2. loadTools throws if a .jl file does not define getTool() #
# Must run BEFORE any other loadTools call (getTool binding #
# persists in module scope after include()). #
# ------------------------------------------------------------------ #
bad_dir = mktempdir()
write(joinpath(bad_dir, "noTool.jl"), "x = 42\n")
@test_throws ArgumentError loadTools(store, bad_dir)
# ------------------------------------------------------------------ #
# 3. loadTools loads actual tool files from src/tools/ #
# ------------------------------------------------------------------ #
store2 = toolStore(name="test2")
loaded = loadTools(store2, TOOLS_DIR)
@test !isempty(loaded)
@test length(loaded) == 4 # 3 files + auto-registered listTools
names = [k for k in keys(loaded)]
@test "getTime" in names
@test "getWeather" in names
@test "writeTool" in names
@test "listTools" in names
# ------------------------------------------------------------------ #
# 4. loadTools returns tools sorted alphabetically by filename #
# (getTime.jl < getWeather.jl < writeTool.jl) + listTools at end #
# ------------------------------------------------------------------ #
@test collect(keys(loaded))[1] == "getTime"
@test collect(keys(loaded))[2] == "getWeather"
@test collect(keys(loaded))[3] == "writeTool"
@test collect(keys(loaded))[4] == "listTools"
# ------------------------------------------------------------------ #
# 5. 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"]
# ------------------------------------------------------------------ #
# 6. 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°F", result_w2.content[1].text)
# ------------------------------------------------------------------ #
# 7. getTools / registerTool / clearTools (per-store isolation) #
# ------------------------------------------------------------------ #
store3 = toolStore(name="test3")
registry_tools = getTools(store3)
@test isempty(registry_tools)
# listTool is not auto-registered anymore — each store starts empty
# 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
# ------------------------------------------------------------------ #
# 8. 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
# ------------------------------------------------------------------ #
# 9. 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")
loaded = loadTools(store, TOOLS_DIR) # auto-registers getWeather, getTime, writeTool + listTools
# loadTools auto-registers listTool
@test "listTools" in keys(loaded)
# 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, loaded["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