Compare commits

...

51 Commits

Author SHA1 Message Date
ton da21790263 update 2026-08-21 13:13:38 +07:00
ton c59f6bfa61 update 2026-08-21 07:13:52 +07:00
ton 3c91222462 update 2026-08-21 07:10:49 +07:00
ton f62b8f14e7 update 2026-08-20 18:45:39 +07:00
ton 7ffb720f86 update mcp definition example 2026-08-20 09:58:55 +07:00
ton 5829c82d05 update 2026-08-17 03:04:10 +07:00
ton c7a98f1710 Merge pull request 'V0.8.0 process message debug' (#44) from v0.8.0-process_message_debug into v0.8.0
Reviewed-on: #44
2026-08-16 13:22:51 +00:00
ton c7abf844ea update 2026-08-16 20:14:54 +07:00
ton 00447e4dde update 2026-08-16 18:25:26 +07:00
ton 25f8468696 text message process works 2026-08-16 17:24:29 +07:00
ton a29a82b74c update 2026-08-16 13:36:01 +07:00
ton fd616409dd update 2026-08-15 20:03:01 +07:00
ton 2543e6cbf1 static tool loading 2026-08-15 16:50:28 +07:00
ton b8067c2d33 update 2026-08-13 18:14:46 +07:00
ton 510cf6126c update 2026-08-13 05:56:08 +07:00
ton 90fb97a4e7 update 2026-08-12 23:47:10 +07:00
ton 77adeb3a6b update 2026-08-12 20:10:51 +07:00
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
25 changed files with 4142 additions and 5829 deletions
+107 -94
View File
@@ -2,7 +2,7 @@
julia_version = "1.12.6" julia_version = "1.12.6"
manifest_format = "2.0" manifest_format = "2.0"
project_hash = "1c1379a2cec320abc347f3acb5ee815ba9855aa6" project_hash = "3ff1783eadf40ccb51801954aa0a8df935689752"
[[deps.Accessors]] [[deps.Accessors]]
deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"]
@@ -44,6 +44,12 @@ 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"
@@ -58,6 +64,12 @@ 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"
@@ -90,16 +102,28 @@ 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 = "962834c22b66e32aa10f7611c08c8ca4e20749a9" git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9"
uuid = "944b1d66-785c-5afd-91f1-9de20f533193" uuid = "944b1d66-785c-5afd-91f1-9de20f533193"
version = "0.7.8" 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]]
git-tree-sha1 = "eeaad7cef88554c2fa56b5a3f71cfd5cb708c662" git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1"
uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
version = "0.2.11" version = "0.2.13"
[[deps.Compat]] [[deps.Compat]]
deps = ["TOML", "UUIDs"] deps = ["TOML", "UUIDs"]
@@ -130,6 +154,12 @@ 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"
@@ -146,9 +176,9 @@ version = "1.6.0"
StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
[[deps.Crayons]] [[deps.Crayons]]
git-tree-sha1 = "249fe38abf76d48563e2f4556bebd215aa317e15" git-tree-sha1 = "54b76cbb40d9a0f5368c880725b2f141da77c94f"
uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f"
version = "4.1.1" version = "4.2.0"
[[deps.DBInterface]] [[deps.DBInterface]]
git-tree-sha1 = "a444404b3f94deaa43ca2a58e18153a82695282b" git-tree-sha1 = "a444404b3f94deaa43ca2a58e18153a82695282b"
@@ -168,9 +198,9 @@ version = "1.8.2"
[[deps.DataStructures]] [[deps.DataStructures]]
deps = ["OrderedCollections"] deps = ["OrderedCollections"]
git-tree-sha1 = "6fb53a69613a0b2b68a0d12671717d307ab8b24e" git-tree-sha1 = "b0bc6d2cad1fed8b7fd59a1551a991cb3d2809e6"
uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"
version = "0.19.5" version = "0.19.6"
[[deps.DataValueInterfaces]] [[deps.DataValueInterfaces]]
git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6" git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6"
@@ -208,9 +238,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 = "cd3c5ac74cd3923c8945c6a81518c46abd0e73a3" git-tree-sha1 = "d2facc77c08c1c2bfb1a77c148edd05b3db5410b"
uuid = "31c24e10-a181-5473-b8eb-7969acd0382f" uuid = "31c24e10-a181-5473-b8eb-7969acd0382f"
version = "0.25.129" version = "0.25.130"
[deps.Distributions.extensions] [deps.Distributions.extensions]
DistributionsChainRulesCoreExt = "ChainRulesCore" DistributionsChainRulesCoreExt = "ChainRulesCore"
@@ -240,15 +270,9 @@ uuid = "4e289a0a-7415-4d19-859d-a7e5c4648b56"
version = "1.0.7" version = "1.0.7"
[[deps.ExprTools]] [[deps.ExprTools]]
git-tree-sha1 = "27415f162e6028e81c72b82ef756bf321213b6ec" git-tree-sha1 = "d2e49e7efd29719d6f28b891b0e0e159daa9d2b4"
uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04"
version = "0.1.10" version = "0.1.11"
[[deps.EzXML]]
deps = ["Printf", "XML2_jll"]
git-tree-sha1 = "7ea1aa5869e2626ccae84480e4f37185bc6f41d3"
uuid = "8f5d6c58-4d21-5cfd-889c-e3ad7ee6a615"
version = "1.2.3"
[[deps.FileIO]] [[deps.FileIO]]
deps = ["Pkg", "Requires", "UUIDs"] deps = ["Pkg", "Requires", "UUIDs"]
@@ -277,9 +301,9 @@ version = "1.11.0"
[[deps.FillArrays]] [[deps.FillArrays]]
deps = ["LinearAlgebra"] deps = ["LinearAlgebra"]
git-tree-sha1 = "2f979084d1e13948a3352cf64a25df6bd3b4dca3" git-tree-sha1 = "5bad39456d9f0166184fce2248783dd9862645c1"
uuid = "1a297f60-69ca-5386-bcde-b61e274b549b" uuid = "1a297f60-69ca-5386-bcde-b61e274b549b"
version = "1.16.0" version = "1.17.0"
weakdeps = ["PDMats", "SparseArrays", "StaticArrays", "Statistics"] weakdeps = ["PDMats", "SparseArrays", "StaticArrays", "Statistics"]
[deps.FillArrays.extensions] [deps.FillArrays.extensions]
@@ -300,11 +324,11 @@ version = "1.1.0"
[[deps.GeneralUtils]] [[deps.GeneralUtils]]
deps = ["CSV", "DataFrames", "DataStructures", "Dates", "Distributions", "Graphs", "HTTP", "JSON", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SHA", "StringDistances", "UUIDs"] deps = ["CSV", "DataFrames", "DataStructures", "Dates", "Distributions", "Graphs", "HTTP", "JSON", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SHA", "StringDistances", "UUIDs"]
git-tree-sha1 = "93293126d24d3929ef6a5067f347bc28c6582c71" 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.5.10" version = "0.5.11"
[[deps.Graphs]] [[deps.Graphs]]
deps = ["ArnoldiMethod", "DataStructures", "Inflate", "LinearAlgebra", "Random", "SimpleTraits", "SparseArrays", "Statistics"] deps = ["ArnoldiMethod", "DataStructures", "Inflate", "LinearAlgebra", "Random", "SimpleTraits", "SparseArrays", "Statistics"]
@@ -321,9 +345,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 = "c2c808326222b6dc4bec295a83b55f79aeec98e0" git-tree-sha1 = "0a58fbbdee93d132a2fb1159b7f5e1b5c2465e71"
uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3"
version = "2.5.5" version = "2.6.4"
[[deps.HashArrayMappedTries]] [[deps.HashArrayMappedTries]]
git-tree-sha1 = "2eaa69a7cab70a52b9687c8bf950a5a93ec895ae" git-tree-sha1 = "2eaa69a7cab70a52b9687c8bf950a5a93ec895ae"
@@ -332,9 +356,9 @@ version = "0.2.0"
[[deps.HypergeometricFunctions]] [[deps.HypergeometricFunctions]]
deps = ["Gamma", "LinearAlgebra"] deps = ["Gamma", "LinearAlgebra"]
git-tree-sha1 = "18d7deab5fb0440dc6a7b6993c5c27b25420de10" git-tree-sha1 = "31bb6c92405c084617facc1d7ed9eb6c402d061e"
uuid = "34004b35-14d8-5ef3-9330-4cdb6864b03a" uuid = "34004b35-14d8-5ef3-9330-4cdb6864b03a"
version = "0.3.29" version = "0.3.30"
[[deps.ICU_jll]] [[deps.ICU_jll]]
deps = ["Artifacts", "JLLWrappers", "Libdl"] deps = ["Artifacts", "JLLWrappers", "Libdl"]
@@ -412,9 +436,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 = "c89d196f5ffb64bfbf80985b699ea913b0d2c211" git-tree-sha1 = "65979512c25a0727f050e6e4be40f0fd9ec893f7"
uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
version = "1.6.1" version = "1.7.0"
weakdeps = ["ArrowTypes"] weakdeps = ["ArrowTypes"]
[deps.JSON.extensions] [deps.JSON.extensions]
@@ -432,9 +456,9 @@ weakdeps = ["ArrowTypes"]
[[deps.JuliaInterpreter]] [[deps.JuliaInterpreter]]
deps = ["CodeTracking", "InteractiveUtils", "Random", "UUIDs"] deps = ["CodeTracking", "InteractiveUtils", "Random", "UUIDs"]
git-tree-sha1 = "58927c485919bf17ea308d9d82156de1adf4b006" git-tree-sha1 = "c3d401f110454b4ea24a76be33f6ee0d7d385103"
uuid = "aa1ae85d-cabe-5617-a682-6adf51b2e16a" uuid = "aa1ae85d-cabe-5617-a682-6adf51b2e16a"
version = "0.10.12" version = "0.11.4"
[[deps.JuliaSyntaxHighlighting]] [[deps.JuliaSyntaxHighlighting]]
deps = ["StyledStrings"] deps = ["StyledStrings"]
@@ -506,12 +530,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"
@@ -539,9 +557,15 @@ version = "1.11.0"
[[deps.LoweredCodeUtils]] [[deps.LoweredCodeUtils]]
deps = ["CodeTracking", "Compiler", "JuliaInterpreter"] deps = ["CodeTracking", "Compiler", "JuliaInterpreter"]
git-tree-sha1 = "3733419e9a71156b389f3e331672d2e95436783f" git-tree-sha1 = "1d4c737ab26f51ceed52ab2019c09b7660eb7440"
uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b" uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b"
version = "3.6.2" 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.MacroTools]] [[deps.MacroTools]]
git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522"
@@ -599,9 +623,9 @@ version = "0.1.1"
[[deps.NanoDates]] [[deps.NanoDates]]
deps = ["Dates", "Parsers"] deps = ["Dates", "Parsers"]
git-tree-sha1 = "850a0557ae5934f6e67ac0dc5ca13d0328422d1f" git-tree-sha1 = "77c7e98ca39aefb481f9b97a2f4f5c5471c08a1d"
uuid = "46f1a544-deae-4307-8689-c12aa3c955c6" uuid = "46f1a544-deae-4307-8689-c12aa3c955c6"
version = "1.0.3" version = "1.1.0"
[[deps.NetworkOptions]] [[deps.NetworkOptions]]
uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908"
@@ -640,15 +664,15 @@ uuid = "efe28fd5-8261-553b-a9e1-b2916fc3738e"
version = "0.5.6+0" version = "0.5.6+0"
[[deps.OrderedCollections]] [[deps.OrderedCollections]]
git-tree-sha1 = "94ba93778373a53bfd5a0caaf7d809c445292ff4" git-tree-sha1 = "05f45c2e0de6259db764adbfd2f1dc6d3f8de13c"
uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
version = "1.8.2" version = "2.0.1"
[[deps.PDMats]] [[deps.PDMats]]
deps = ["LinearAlgebra", "SparseArrays", "SuiteSparse"] deps = ["LinearAlgebra", "SparseArrays", "SuiteSparse"]
git-tree-sha1 = "26766d4b5f1a410c218a19b85a672c6edb693c65" git-tree-sha1 = "123266c25174ef6c8d4718920abc206452cf8de6"
uuid = "90014a1f-27ba-587c-ab20-58faa44d9150" uuid = "90014a1f-27ba-587c-ab20-58faa44d9150"
version = "0.11.40" version = "0.11.41"
weakdeps = ["StatsBase"] weakdeps = ["StatsBase"]
[deps.PDMats.extensions] [deps.PDMats.extensions]
@@ -656,9 +680,9 @@ weakdeps = ["StatsBase"]
[[deps.Parsers]] [[deps.Parsers]]
deps = ["Dates", "PrecompileTools", "UUIDs"] deps = ["Dates", "PrecompileTools", "UUIDs"]
git-tree-sha1 = "32a4e09c5f29402573d673901778a0e03b0807b9" git-tree-sha1 = "3de8f5e6e90ebfa8d6d1f86997d6cdcd6a912ff3"
uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0"
version = "2.8.6" version = "2.8.7"
[[deps.Pkg]] [[deps.Pkg]]
deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "Random", "SHA", "TOML", "Tar", "UUIDs", "p7zip_jll"] deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "Random", "SHA", "TOML", "Tar", "UUIDs", "p7zip_jll"]
@@ -688,15 +712,15 @@ uuid = "21216c6a-2e73-6563-6e65-726566657250"
version = "1.5.2" version = "1.5.2"
[[deps.PrettyPrinting]] [[deps.PrettyPrinting]]
git-tree-sha1 = "142ee93724a9c5d04d78df7006670a93ed1b244e" git-tree-sha1 = "0b7f4ad437e31c51cf5b91fb103579b04025170a"
uuid = "54e16d92-306c-5ea0-a30b-337be88ac337" uuid = "54e16d92-306c-5ea0-a30b-337be88ac337"
version = "0.4.2" 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 = "ebf455bb866ee6737030e3d3816bb6a0683c4325" git-tree-sha1 = "4ac881f5432bd93463a41767a814a45245be22b6"
uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d"
version = "3.4.0" version = "3.4.6"
[deps.PrettyTables.extensions] [deps.PrettyTables.extensions]
PrettyTablesExcelExt = "XLSX" PrettyTablesExcelExt = "XLSX"
@@ -757,15 +781,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 = "0eab6d95ed40c2ef3992255c1c71e4f9748932b5" git-tree-sha1 = "701ef63506992668c0069a4d1ac1540b77258e6c"
uuid = "802f3686-a58f-41ce-bb0c-3c43c75bba36" uuid = "802f3686-a58f-41ce-bb0c-3c43c75bba36"
version = "1.3.1" version = "1.3.6"
[[deps.Revise]] [[deps.Revise]]
deps = ["CRC32c", "CodeTracking", "FileWatching", "InteractiveUtils", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"] deps = ["CRC32c", "CodeTracking", "FileWatching", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"]
git-tree-sha1 = "27e3ee13fc8739a59b380d6163d6a82f52c03bd7" git-tree-sha1 = "ec46aed6a3a8cc6b67839ca361e7b4aa32eaeee1"
uuid = "295af30f-e4ad-537b-8983-00126c2a3abe" uuid = "295af30f-e4ad-537b-8983-00126c2a3abe"
version = "3.15.1" version = "3.16.3"
weakdeps = ["Distributed"] weakdeps = ["Distributed"]
[deps.Revise.extensions] [deps.Revise.extensions]
@@ -779,15 +803,15 @@ version = "0.9.0"
[[deps.Rmath_jll]] [[deps.Rmath_jll]]
deps = ["Artifacts", "JLLWrappers", "Libdl"] deps = ["Artifacts", "JLLWrappers", "Libdl"]
git-tree-sha1 = "58cdd8fb2201a6267e1db87ff148dd6c1dbd8ad8" git-tree-sha1 = "6d40b2fe70437b01397d2a4d5b020008da4e7019"
uuid = "f50d1b31-88e8-58de-be2c-1cc44531875f" uuid = "f50d1b31-88e8-58de-be2c-1cc44531875f"
version = "0.5.1+0" version = "0.5.2+0"
[[deps.Roots]] [[deps.Roots]]
deps = ["Accessors", "CommonSolve", "Printf"] deps = ["Accessors", "CommonSolve", "Printf"]
git-tree-sha1 = "a7caaf7ba8cf307112ca443784d1b56b4a591455" git-tree-sha1 = "7fb25a964849d90a0446366cdefca822e0e84900"
uuid = "f2b01f46-fcfa-551c-844a-d8ac1e96c665" uuid = "f2b01f46-fcfa-551c-844a-d8ac1e96c665"
version = "3.0.5" version = "3.0.6"
[deps.Roots.extensions] [deps.Roots.extensions]
RootsChainRulesCoreExt = "ChainRulesCore" RootsChainRulesCoreExt = "ChainRulesCore"
@@ -840,12 +864,6 @@ git-tree-sha1 = "084c47c7c5ce5cfecefa0a98dff69eb3646b5a80"
uuid = "91c51154-3ec4-41a3-a24f-3f23e20d615c" uuid = "91c51154-3ec4-41a3-a24f-3f23e20d615c"
version = "1.4.10" version = "1.4.10"
[[deps.Serde]]
deps = ["CSV", "Dates", "EzXML", "JSON", "TOML", "UUIDs", "YAML"]
git-tree-sha1 = "f397fc8779cc53e4677c2708f3802c6996f28d00"
uuid = "db9b398d-9517-45f8-9a95-92af99003e0e"
version = "3.7.2"
[[deps.Serialization]] [[deps.Serialization]]
uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
version = "1.11.0" version = "1.11.0"
@@ -879,9 +897,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 = "6547cbdd8ce32efba0d21c5a40fa96d1a3548f9f" git-tree-sha1 = "c3ac026e735264e9bdc6a9bcbd1b1e781b36e3bc"
uuid = "276daf66-3868-5448-9aa4-cd146d93841b" uuid = "276daf66-3868-5448-9aa4-cd146d93841b"
version = "2.8.0" version = "2.8.3"
[deps.SpecialFunctions.extensions] [deps.SpecialFunctions.extensions]
SpecialFunctionsChainRulesCoreExt = "ChainRulesCore" SpecialFunctionsChainRulesCoreExt = "ChainRulesCore"
@@ -932,9 +950,9 @@ version = "0.34.12"
[[deps.StatsFuns]] [[deps.StatsFuns]]
deps = ["HypergeometricFunctions", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"] deps = ["HypergeometricFunctions", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"]
git-tree-sha1 = "770240df9a3b8888065046948f7a09b4e0f997d5" git-tree-sha1 = "91a5737baed20ee31f3faea0e51f57461f6a689e"
uuid = "4c63d2b9-4356-54db-8cca-17b64c39e42c" uuid = "4c63d2b9-4356-54db-8cca-17b64c39e42c"
version = "2.2.0" version = "2.2.1"
[deps.StatsFuns.extensions] [deps.StatsFuns.extensions]
StatsFunsChainRulesCoreExt = "ChainRulesCore" StatsFunsChainRulesCoreExt = "ChainRulesCore"
@@ -950,17 +968,16 @@ git-tree-sha1 = "cd83a04baf746e3b43b83c61b7de77ab0409b80a"
uuid = "88034a9c-02f8-509d-84a9-84ec65e18404" uuid = "88034a9c-02f8-509d-84a9-84ec65e18404"
version = "1.0.0" version = "1.0.0"
[[deps.StringEncodings]]
deps = ["Libiconv_jll"]
git-tree-sha1 = "b765e46ba27ecf6b44faf70df40c57aa3a547dcb"
uuid = "69024149-9ee7-55f6-a4c4-859efe599b68"
version = "0.3.7"
[[deps.StringManipulation]] [[deps.StringManipulation]]
deps = ["PrecompileTools"] deps = ["PrecompileTools"]
git-tree-sha1 = "d05693d339e37d6ab134c5ab53c29fce5ee5d7d5" git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484"
uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e" uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e"
version = "0.4.4" 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"]
@@ -970,9 +987,9 @@ version = "1.11.0"
[[deps.StructUtils]] [[deps.StructUtils]]
deps = ["Dates", "UUIDs"] deps = ["Dates", "UUIDs"]
git-tree-sha1 = "82bee338d650aa515f31866c460cb7e3bcef90b8" git-tree-sha1 = "c65ae4aa47e543c278aea0a3468786d33021a3ff"
uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42"
version = "2.8.2" version = "2.8.4"
[deps.StructUtils.extensions] [deps.StructUtils.extensions]
StructUtilsMeasurementsExt = ["Measurements"] StructUtilsMeasurementsExt = ["Measurements"]
@@ -1046,9 +1063,9 @@ uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa"
version = "0.11.3" version = "0.11.3"
[[deps.URIs]] [[deps.URIs]]
git-tree-sha1 = "bef26fb046d031353ef97a82e3fdb6afe7f21b1a" git-tree-sha1 = "3b0738bd7c5645641845da25cbd99800b8718689"
uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
version = "1.6.1" version = "1.6.2"
[[deps.UTCDateTimes]] [[deps.UTCDateTimes]]
deps = ["Dates", "TimeZones"] deps = ["Dates", "TimeZones"]
@@ -1076,23 +1093,11 @@ 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.YAML]]
deps = ["Base64", "Dates", "Printf", "StringEncodings"]
git-tree-sha1 = "a1c0c7585346251353cddede21f180b96388c403"
uuid = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6"
version = "0.4.16"
[[deps.YiemAgent]] [[deps.YiemAgent]]
deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "HTTP", "JSON", "LLMMCTS", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SQLLLM", "Serde", "Serialization", "URIs", "UUIDs"] 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.7.4" version = "0.8.0"
[[deps.Zlib_jll]] [[deps.Zlib_jll]]
deps = ["Libdl"] deps = ["Libdl"]
@@ -1116,6 +1121,14 @@ 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", "Revise", "UUIDs"]
git-tree-sha1 = "e82a79cf6602541ea25409aded57b2ace4a7c29f"
repo-rev = "main"
repo-url = "https://git.yiem.cc/ton/msghandler"
uuid = "f2724d33-f338-4a57-b9f8-1be882570d10"
version = "1.2.1"
[[deps.nghttp2_jll]] [[deps.nghttp2_jll]]
deps = ["Artifacts", "Libdl"] deps = ["Artifacts", "Libdl"]
uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
+2 -6
View File
@@ -12,17 +12,15 @@ 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"
Serde = "db9b398d-9517-45f8-9a95-92af99003e0e"
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"
@@ -31,7 +29,5 @@ DataFrames = "1.7.0"
GeneralUtils = "0.5.10" 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"
SQLLLM = "0.2.8" msghandler = "1.2.1"
Serde = "3.7.2"
+135 -19
View File
@@ -1,23 +1,139 @@
# YiemAgent # YiemAgent
## TODO Julia framework for building agents with tool use and MCP (Model Context Protocol) support.
- [WORKING] build prompt()
- [ ] build agent runLoop()
- [ ] build MCP server connector
- [ ] executeplan() to execute the plan
- [ ] add comprehensive tests
## Changelog ## Getting Started
### Version 0.8.0 1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, NATS, DataFrames`
- Converted snake_case fields to camelCase: 2. Create a callable `mcpServer` struct that communicates with an MCP server via NATS
- `llmModel`: `base_url``baseUrl`, `context_window``contextWindow`, `max_tokens``maxTokens` 3. Create a `yiemAgent` with an LLM callable and MCP server:
- Converted PascalCase type references to camelCase: ```julia
- `AgentState``agentState` agent = YiemAgent.yiemAgent(llmCall; mcpServer=mcpServer, eventSink=yourSink)
- `AgentTool``agentTool` ```
- `AgentMessage``agentMessage` 4. Call `runAgent(agent, message)` then `takeResponse(agent)`
- `PendingMessageQueue``pendingMessageQueue`
- `ActiveRun``activeRun` ## Architecture
- `StreamFn``streamFn`
- `ThinkingLevel``thinkingLevel` ```
- `ToolExecutionMode``toolExecutionMode` src/
├── YiemAgent.jl # Module entry point
├── type.jl # Core types (messages, tools, agent state)
├── utils.jl # Message formatting, context preparation, validation
├── agentCore.jl # Agent loop, tool execution pipeline
├── api.jl # Public API (runAgent, takeResponse, followUp, stopAgent)
└── toolRegistry.jl # Tool store, MCP discovery, listTools registration
```
## MCP Protocol
YiemAgent uses JSON-RPC 2.0 for MCP communication. The `mcpServer` callable struct must implement:
```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"`:
```julia
Dict("jsonrpc"=>"2.0", "id"=>2, "error"=>Dict("code"=>-32602, "message"=>"..."))
```
### Implementing the MCP Server Client
You must provide a callable struct that communicates with your MCP server. Example using NATS:
```julia
struct mcpServer
natsConn::NATS.Connection
topic::String
senderID::String
fileserver_url::String
end
# tools/list implementation
function (c::mcpServer)(method::String)
if method != "tools/list"
error("mcpServer: unexpected method '$method' (expected 'tools/list')")
end
payload = Dict("jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => Dict{String, Any}())
payloads = [("payload", payload, "dictionary")]
_, msg_envelope_json_str = 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_json_str, timeout=180)
incoming_env = msghandler.smartunpack(String(reply.payload))
return incoming_env["payloads"][1][2]
end
# tools/call implementation
function (c::mcpServer)(method::String, toolName::String, arguments::Dict{String, Any})
if method != "tools/call"
error("mcpServer: unexpected method '$method' (expected 'tools/call')")
end
payload = Dict(
"jsonrpc" => "2.0",
"id" => 2,
"method" => method,
"params" => Dict(
"name" => toolName,
"arguments" => arguments
)
)
payloads = [("payload", payload, "dictionary")]
_, msg_envelope_json_str = 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_json_str, timeout=180)
incoming_env = msghandler.smartunpack(String(reply.payload))
return incoming_env["payloads"][1][2]
end
```
See `test/runtest.jl` for the complete working example.
## Tool Discovery
Tools are discovered dynamically via MCP server:
1. `listTool` is the only pre-registered tool
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
## Agent API
| Function | Description |
|----------|-------------|
| `runAgent(agent, msg)` | Send a message to the agent's input channel |
| `takeResponse(agent)` | Block and take the agent's response from output channel |
| `followUp(agent, msg)` | Send a follow-up message while agent is still processing |
| `stopAgent(agent)` | Gracefully stop the agent and close channels |
## Agent Lifecycle
1. `yiemAgent()` spawns a background task (`_agentLoop`) listening on `inputChannel` and `followUpChannel`
2. User messages enter via `runAgent()` or `followUp()`
3. `_processMessage()` handles LLM calls, tool execution, and conversation history
4. Tool execution follows three phases: `prepareToolCall` → `executePreparedToolCall` → `finalizeExecutedToolCall`
5. `beforeToolCall`/`afterToolCall` hooks allow pre/post-processing of tool calls
6. Tool `execute` functions can set `terminate=true` to stop the agent loop
## Hooks
| Hook | Signature | Purpose |
|------|-----------|---------|
| `prepareContext` | `(state, sink, llmCall) -> ctx` | Transform messages/context before LLM call |
| `formatMsgForLLM` | `(ctx, sink) -> dict` | Convert agent context to LLM API format |
| `beforeToolCall` | `(context, signal) -> result` | Block/allow tool execution |
| `afterToolCall` | `(context, signal) -> result` | Post-process tool results |
| `eventSink` | `(msg) -> nothing` | Callback for agent events/debug messages |
+1599
View File
File diff suppressed because it is too large Load Diff
-92
View File
@@ -1,92 +0,0 @@
# Dynamic Tool Loading
Tools can be loaded dynamically from `.jl` files in the `src/tools/` directory without hardcoding filenames in the main module.
## How It Works
1. `src/tools/registry.jl` defines a `loadTools(dir::String)` function that scans a directory for `.jl` files
2. Each tool file must define a single function: `getTool()::agentTool`
3. `loadTools()` sorts files alphabetically, includes each one, calls `getTool()`, and registers the result
4. Loaded tools are returned as `Vector{agentTool}` for use when constructing a `yiemAgent`
## Directory Structure
```
src/
├── tools/
│ ├── registry.jl # Tool loader (do not edit)
│ ├── getWeather.jl # Your tool
│ └── query_db.jl # Another tool
├── type.jl
├── utils.jl
├── agentCore.jl
├── api.jl
└── YiemAgent.jl
```
## Creating a Tool
Each `.jl` file in `src/tools/` must define `getTool()` returning an `agentTool`:
```julia
# src/tools/getWeather.jl
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"),
"units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius")
),
"required" => ["city"]
),
execute = (toolCallId, args, signal, onPartialResult) -> begin
city = args["city"]
return agentToolResult(
[textContent("Weather in $(city): Sunny, 22C")],
Dict{Any,Any}(), nothing, false
)
end,
prepareArguments = nothing,
parallelToolExecute = false
)
end
```
No `module` wrapper needed — the registry includes each file in the current module scope so all types (`agentTool`, `textContent`, `agentToolResult`, etc.) resolve correctly.
## Loading Tools
```julia
using .YiemAgent
using .YiemAgent: toolRegistry
# Load all tool files from src/tools/
tools = YiemAgent.loadTools(joinpath(@__DIR__, "src", "tools"))
# Create agent with loaded tools
agent = yiemAgent(
systemPrompt = "You are a helpful assistant.",
model = my_model,
tools = tools,
llmCall = my_llm_call,
agentEventSink = my_event_sink
)
```
## Available Functions
| Function | Description |
|----------|-------------|
| `loadTools(dir::String)` | Scan directory and load all `.jl` tool files |
| `registerTool(tool::agentTool)` | Register a single tool into the global registry |
| `getTools()` | Get deep copy of all registered tools |
| `listTools()` | List all registered tools as `(name, label)` pairs |
| `clearTools()` | Clear the global registry |
## File Loading Order
Files are sorted alphabetically before loading, so `01_database.jl` loads before `02_weather.jl`. This ensures deterministic registration order.
-2
View File
@@ -1,2 +0,0 @@
# ── executeToolCalls() Julia pseudo code ──────────────────────────
# Full call stack from runLoop → executeToolCalls → prepare → execute → finalize → emit
+11
View File
@@ -0,0 +1,11 @@
check my understanding
1) if LLM didn't use tool calls, assistantMessage get pushed into agent._state.messages and it will be the latest message in agent._state.messages. then _agentLoop() can pick it as the output to outputChannel
2) if LLM use tool calls, assistantMessageToolCall get pushed into agent._state.messages. then toolResult get pushed into agent._state.messages. if toolResultBatch.terminate is false then _processMessage() loop continue
3) if LLM use tool calls, assistantMessageToolCall get pushed into agent._state.messages. then toolResult get pushed into agent._state.messages. if toolResultBatch.terminate is true then final_response message get pushed into agent._state.messages. _processMessage() loop exit. then _agentLoop() can pick it as the output to outputChannel
Is my understanding correct?
the user can provide NATS connection to MCP server by adding agent.mcpserver (a callable struct) for communication with MCP server just like agent.llmCall (also a callable struct). I think communicating with MCP server is just send/receive JSON text right?
Moreover, for simplicity I want to all tools into an MCP server so an agent can be instantiated with only listTools() in tool store then populate tools from MCP server later.
what do you think?
+4 -26
View File
@@ -1,11 +1,7 @@
module YiemAgent module YiemAgent
# export agent """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."""
""" 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
@@ -13,7 +9,7 @@ module YiemAgent
include("utils.jl") include("utils.jl")
using .utils using .utils
include("tools/registry.jl") include("toolRegistry.jl")
using .toolRegistry using .toolRegistry
# include("llmfunction.jl") # include("llmfunction.jl")
@@ -26,28 +22,10 @@ module YiemAgent
using .api using .api
# ---------------------------------------------- 100 --------------------------------------------- # # ---------------------------------------------- 100 --------------------------------------------- #
end # module YiemAgent_v1 end # module YiemAgent_v1
+823 -339
View File
File diff suppressed because it is too large Load Diff
+16 -17
View File
@@ -3,14 +3,13 @@ module api
export prompt export prompt
using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization,
DataFrames, Serde DataFrames
using GeneralUtils using GeneralUtils
using ..type, ..utils using ..type, ..utils, ..agentCore, ..toolRegistry
# ---------------------------------------------- 100 --------------------------------------------- # # ---------------------------------------------- 100 --------------------------------------------- #
""" """
Send a message to the agent's input channel. Send a message to the agent's input channel.
@@ -25,16 +24,16 @@ The agent processes messages from `inputChannel` in the background task.
- The same `agent` instance for chaining - The same `agent` instance for chaining
# Notes # Notes
- Use `take_response(agent)` to receive the agent's response after sending a message. - Use `takeResponse(agent)` to receive the agent's response after sending a message.
- Use `follow_up(agent, msg)` to send messages while the agent is still processing. - Use `followUp(agent, msg)` to send messages while the agent is still processing.
# Examples # Examples
```jldoctest ```jldoctest
julia> run_agent(agent, "Hello!") julia> runAgent(agent, "Hello!")
yiemAgent(...) yiemAgent(...)
``` ```
""" """
function run_agent(agent::yiemAgent, msg) function runAgent(agent::yiemAgent, msg)
put!(agent.inputChannel, msg) put!(agent.inputChannel, msg)
return agent return agent
end end
@@ -51,15 +50,15 @@ 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 `run_agent(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
julia> response = take_response(agent) julia> response = takeResponse(agent)
assistantMessage(...) assistantMessage(...)
``` ```
""" """
function take_response(agent::yiemAgent) function takeResponse(agent::yiemAgent)
return take!(agent.outputChannel) return take!(agent.outputChannel)
end end
@@ -77,17 +76,17 @@ and before any tool call results are sent.
- The same `agent` instance for chaining - The same `agent` instance for chaining
# Notes # Notes
- Use `run_agent(agent, msg)` for the primary message and `follow_up(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> follow_up(agent, "Also consider red wines") julia> followUp(agent, "Also consider red wines")
yiemAgent(...) yiemAgent(...)
``` ```
""" """
function follow_up(agent::yiemAgent, msg) function followUp(agent::yiemAgent, msg)
put!(agent.followUpChannel, msg) put!(agent.followUpChannel, msg)
return agent return agent
end end
@@ -105,19 +104,19 @@ then closes all channels (`inputChannel`, `outputChannel`, `followUpChannel`).
- `nothing` - `nothing`
# Notes # Notes
- After calling `stop_agent`, the agent is no longer usable. A new agent must be created - After calling `stopAgent`, the agent is no longer usable. A new agent must be created
for further interaction. for further interaction.
- If the background task throws a `TaskFailedException`, it is rethrown. - If the background task throws a `TaskFailedException`, it is rethrown.
# Examples # Examples
```jldoctest ```jldoctest
julia> stop_agent(agent) julia> stopAgent(agent)
``` ```
""" """
function stop_agent(agent::yiemAgent) function stopAgent(agent::yiemAgent)
put!(agent.inputChannel, :shutdown) put!(agent.inputChannel, :shutdown)
try try
fetch(agent._agent_loop) fetch(agent._agentLoop)
catch e catch e
if e isa TaskFailedException if e isa TaskFailedException
rethrow(e) rethrow(e)
+335
View File
@@ -0,0 +1,335 @@
module toolRegistry
export toolStore, registerTool, registerAllTools, getTools, clearTools, listTool
using Dates
using JSON, DataStructures
using ..type
"""
Per-agent isolated tool storage.
Each agent gets its own `toolStore` so tool registration is independent.
# Fields
- `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration
- `name::String` — identifier for debugging/logs
"""
struct toolStore
tools::OrderedDict{String, agentTool}
name::String
end
"""
toolStore(; name="default") -> toolStore
Create a new empty tool store.
# Keyword Arguments
- `name::String`: Display name for logging (default: `"default"`)
"""
function toolStore(; name::String="default")::toolStore
toolStore(OrderedDict{String, agentTool}(), name)
end
# ── MCP helper functions ────────────────────────────────────────────
"""
Extract text from MCP tool result content array.
Handles JSON-RPC 2.0 result content format:
{"content": [{"type": "text", "text": "..."}], "isError": false}
"""
function _extract_text_content(result::Dict)::String
content = get(result, "content", Any[])
if content isa Vector && !isempty(content)
lines = String[]
for block in content
if block isa Dict && get(block, "type", "") == "text"
push!(lines, string(get(block, "text", "")))
end
end
if !isempty(lines)
return join(lines, "\n")
end
end
return JSON.json(result)
end
"""
Wrap an MCP tool definition as an `agentTool`.
The returned tool's `execute` function calls the MCP server's "tools/call"
method with the validated arguments.
"""
function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSink=nothing)::agentTool
name = tool_def["name"]
title = get(tool_def, "title", get(tool_def, "label", name))
desc = get(tool_def, "description", "")
input_schema = get(tool_def, "inputSchema", Dict{String,Any}())
# Normalize inputSchema to OpenAI function format
if haskey(input_schema, "properties") && input_schema["type"] == "object"
params = Dict(
"type" => "object",
"properties" => input_schema["properties"],
"required" => get(input_schema, "required", Any[]),
)
else
params = Dict(
"type" => "object",
"properties" => Dict{String,Any}(),
"required" => Any[],
)
end
return agentTool(
name=name,
label=title,
description=desc,
inputSchema=params,
execute=(toolCallId::String, args::AbstractDict{String, Any},
signal::Union{Nothing,abortSignal},
eventSink,
llmCall=nothing) -> begin
try
response = mcpserver("tools/call", name, args)
# 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
"""
Discover and register MCP tools into `store.tools`.
Queries the MCP server via `mcpserver("tools/list")` (JSON-RPC 2.0 format),
parses the response envelope, and registers each discovered tool.
Handles pagination via `nextCursor`. Skips tools already registered.
# Returns
- `Int`: number of new tools registered
"""
function register_mcp_tools(mcpserver, store::toolStore; eventSink=nothing)::Int
if mcpserver === nothing
return 0
end
new_count = 0
try
response = mcpserver("tools/list")
# 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:$(store.name)] 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(store.tools, name)
continue
end
@show "tool_def $(typeof(tool_def))"
wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink)
store.tools[name] = wrapped
new_count += 1
end
# Paginate: fetch remaining tools if nextCursor is present
while cursor !== nothing && cursor !== ""
response = mcpserver("tools/list")
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(store.tools, name)
continue
end
wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink)
store.tools[name] = wrapped
new_count += 1
end
end
println("[toolRegistry:$(store.name)] Discovered $new_count MCP tools. Total: $(length(store.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:$(store.name)] MCP tools/list failed: $errMsg")
end
return new_count
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)
"""
function registerTool(store::toolStore, tool::agentTool; eventSink=nothing
)::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`.
# Arguments
- `store`: Tool store to query
# Returns
- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order
"""
function getTools(store::toolStore)::OrderedDict{String, agentTool}
return store.tools
end
"""
Remove all tools from `store`.
# Arguments
- `store`: Tool store to clear
# Returns
- `nothing`
"""
function clearTools(store::toolStore)::Nothing
empty!(store.tools)
println("[$(store.name)] Registry cleared")
return nothing
end
# ── listTools tool (auto-discover new MCP tools at runtime) ─────────
"""
listTool(store::toolStore, 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 the shared `store.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(store::toolStore, mcpserver)::agentTool
return agentTool(
name="listTools",
label="List Tools",
description="List all available tools. First call discovers and registers all tools from the MCP server. After discovery, new tools become immediately available for use.",
inputSchema=Dict{String,Any}(
"type" => "object",
"properties" => Dict{String,Any}(),
"required" => Any[]
),
execute=(toolCallId::String, args::AbstractDict{String, Any},
signal::Union{Nothing,abortSignal},
eventSink, llmCall=nothing) -> begin
# Discover and register MCP tools (idempotent — skips already registered)
new_count = register_mcp_tools(mcpserver, store)
# Always include listTools itself in the count
total = length(store.tools)
lines = String[
"- $(t.name): $(t.label)$(t.description)"
for (k, t) in store.tools
]
result_text = "Available tools ($total):\n" * join(lines, "\n")
return agentToolResult(
[textContent(result_text)],
Dict{Any,Any}("count" => total),
nothing, false
)
end,
prepareArguments=nothing,
validateRequiredArgs=nothing,
parallelToolExecute=false,
)
end
# ── High-level API ──────────────────────────────────────────────────
"""
registerAllTools(store::toolStore, mcpserver) -> toolStore
Register all tools for an agent:
1. Auto-discover existing tools from the MCP server
2. Register the `listTools` tool so the agent can discover new tools at runtime
# Arguments
- `store`: The tool store to populate
- `mcpserver`: A callable struct that communicates with the MCP server
# Returns
- The populated `toolStore`
"""
function registerAllTools(store::toolStore, mcpserver=nothing; eventSink=nothing)::toolStore
register_mcp_tools(mcpserver, store; eventSink=eventSink)
registerTool(store, listTool(store, mcpserver); eventSink=eventSink)
return store
end
end # module
-568
View File
@@ -1,568 +0,0 @@
# Tools
Tools allow the agent to perform actions and fetch data. Each tool defines a **schema** (what arguments it accepts) and an **execution function** (what it does).
## Quick Start
Add a new tool by creating a `.jl` file in `src/tools/`. The file must define a `getTool()` function that returns an `agentTool`:
```julia
# src/tools/my_tool.jl
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
city = args["city"]
return agentToolResult(
[textContent("Hello from $(city)!")],
Dict{Any,Any}(), nothing, false
)
end
function getTool()::agentTool
return agentTool(
name = "my_tool",
label = "My Tool",
description = "Says hello to a city.",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict(
"city" => Dict("type" => "string", "description" => "City name")
),
"required" => ["city"]
),
execute = executeTool,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
```
When `loadTools()` or `registerTool()` is called, the tool becomes available to the agent.
## Tool Anatomy
Each tool has 3 main parts:
### 1. Schema (`inputSchema`)
JSON Schema (MCP format) describing the tool's arguments. The `"required"` array lists mandatory fields:
```julia
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict(
"city" => Dict("type" => "string", "description" => "City name"),
"units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius")
),
"required" => ["city"]
)
```
### 2. Execution Function (`execute`)
A function with the signature:
```julia
execute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
```
- **`toolCallId`** — unique ID for this invocation (from the LLM's tool call)
- **`args`** — validated arguments provided by the LLM
- **`signal`** — abort signal for cancellable operations
- **`onPartialResult`** — callback for streaming progress updates
- **Returns** — `agentToolResult` with content, details, usage, and termination flag
```julia
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
# Optional: stream progress updates
onPartialResult(Dict("status" => "Fetching data..."))
# Do work
result = "Weather in $(args["city"]): Sunny, 22°C"
# Return result
return agentToolResult(
[textContent(result)],
Dict{Any,Any}(), # details
nothing, # usage
false # terminate (true to stop agent loop)
)
end
```
### 3. Tool Definition (`getTool()`)
Returns an `agentTool` struct:
| Field | Type | Description |
|---|---|---|
| `name` | `String` | Unique identifier (e.g. `"getWeather"`) |
| `label` | `String` | Human-readable name (e.g. `"Weather Lookup"`) |
| `description` | `String` | What the tool does (shown to the LLM) |
| `inputSchema` | `Any` | JSON Schema (MCP format) |
| `execute` | `Function` | The execution function |
| `prepareArguments` | `Union{Function,Nothing}` | Optional argument transform before validation |
| `validateRequiredArgs` | `Union{Function,Nothing}` | Optional custom validation |
| `parallelToolExecute` | `Bool` | Run this tool in parallel with others |
## Argument Validation
Validation happens **before** tool execution, in the `prepareToolCall` phase. Invalid calls return an error immediately without invoking `execute`, `beforeToolCall`, or logging `toolExecutionStart`.
### Default: JSON Schema Required Fields
Set `validateRequiredArgs = nothing` to use the default validator, which checks that all fields in `inputSchema["required"]` are present:
```julia
# src/tools/getWeather.jl — uses default validation
function getTool()::agentTool
return agentTool(
name = "getWeather",
# ...
validateRequiredArgs = nothing, # uses default
)
end
```
### Custom Validation Hook
Override `validateRequiredArgs` when you need:
- **Cross-field constraints** (e.g. "at least one of X or Y")
- **Format validation** (e.g. regex patterns, date parsing)
- **Domain rules** (e.g. value ranges, business logic)
The hook signature takes only `args`:
```julia
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
tz = get(args, "timezone", nothing)
city = get(args, "city", "")
if !haskey(args, "timezone") && isempty(city)
return "Missing required argument: provide at least one of 'timezone' or 'city'"
end
if tz !== nothing
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'"
end
end
return nothing
end
```
Return `nothing` to pass, or an error `String` to fail. The error is fed back to the LLM so it can retry with corrected arguments.
## Tool Lifecycle — Framework Internals
This section traces the full code path from the moment the LLM returns tool calls to the final result being fed back into the conversation. All code references are to `agentCore.jl`.
### Phase 1: Detect Tool Calls in LLM Response
After the LLM returns an `assistantMessage`, the loop at `agentCore.jl:220-244` inspects each `content` block:
```julia
# agentCore.jl:217-244
has_tool_calls = false
tool_call_list = agentToolCall[]
for content_block in response.content
if content_block isa Dict
# OpenAI-style: type == "tool_calls" with array of tool calls
if get(content_block, :type, "") == "tool_calls"
for tc_data in get(content_block, :tool_calls, [])
tc = agentToolCall(
type="function",
id=get(tc_data, :id, string(uuid4())),
name=get(tc_data, :function, Dict{String,Any}())[:name],
arguments=get(tc_data, :function, Dict{String,Any}())[:arguments],
)
push!(tool_call_list, tc)
end
# Alternative style: type == "tool_call" single dict per block
elseif get(content_block, :type, "") == "tool_call"
tc = agentToolCall(
type="function",
id=get(tc_data, :id, string(uuid4())),
name=get(tc_data, :name, ""),
arguments=get(tc_data, :arguments, Dict{String,Any}()),
)
push!(tool_call_list, tc)
end
end
end
```
Each content block with `type == "tool_calls"` or `type == "tool_call"` extracts an `agentToolCall` (id, name, arguments dict) and collects them into a `Vector{agentToolCall}`.
### Phase 2: Dispatch to Sequential or Parallel Execution
At `agentCore.jl:247`, the framework checks if any tool calls exist and decides execution mode:
```julia
# agentCore.jl:247-265
context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools)
config = agentLoopConfig(
agent._state.tools,
agent.beforeToolCall,
agent.afterToolCall,
agent.parallelToolExecute ? "parallel" : "sequential",
)
batch = executeToolCalls(context, response, tool_call_list, config, signal, emit)
```
`executeToolCalls` (`agentCore.jl:988-1015`) checks:
- `config.toolExecution == "sequential"` → sequential mode
- Any tool has `parallelToolExecute == false` → sequential mode
- Otherwise → parallel mode
### Phase 3: Per-Call Preparation (`prepareToolCall`)
Each tool call goes through `prepareToolCall` (`agentCore.jl:511-547`):
```
1. Look up tool by name: find(t -> t.name == tc.name, context.tools)
2. If not found → immediateOutcome("Tool X not found", true)
3. Run tool.prepareArguments (if defined) → transforms raw LLM args
4. Run validateToolArguments → validateRequiredArgs (hook or default)
→ if fails → throws ArgumentError → caught below
5. Run beforeToolCall hook (if defined) → can block execution
→ if blocked → immediateOutcome("Tool execution was blocked", true)
6. Return preparedToolCall(tool, tc, validatedArgs)
```
If any step throws (validation, prepareArguments, beforeToolCall), the catch block at `agentCore.jl:545` converts it to an `immediateOutcome`:
```julia
catch err
return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true)
end
```
### Phase 4: Execution (`executePreparedToolCall`)
For each `preparedToolCall`, `executePreparedToolCall` (`agentCore.jl:589-617`) runs:
```julia
function executePreparedToolCall(prep::preparedToolCall, signal, emit)::executedOutcome
updateEvents = promise[]
accepting = true
try
result = prep.tool.execute(
prep.toolCall.id, prep.args, signal,
partialResult -> begin
if accepting
push!(updateEvents, emit(toolExecUpdateEvent(..., partialResult)))
end
end
)
accepting = false
wait.(updateEvents)
return executedOutcome(result, false)
catch err
accepting = false
wait.(updateEvents)
return executedOutcome(createErrorToolResult(sprint(showerror, err)), true)
end
end
```
Key behaviors:
- Calls `tool.execute(id, args, signal, onPartialResult)` — your tool's `executeTool` function
- `signal` can be checked inside `executeTool` for cancellation
- `onPartialResult` is called for streaming updates, which are emitted as `toolExecutionUpdate` events
- `accepting` guard prevents emitting updates after the result is already captured
- `wait.(updateEvents)` ensures all streaming updates are delivered before returning
- Execution errors are caught and returned as `executedOutcome(isError=true)` — never thrown
### Phase 5: Finalization (`finalizeExecutedToolCall`)
After execution, `finalizeExecutedToolCall` (`agentCore.jl:675-706`) runs the `afterToolCall` hook:
```julia
function finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)::finalizedOutcome
result = executed.result
isError = executed.isError
if config.afterToolCall !== nothing
try
after = config.afterToolCall(afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal)
if after !== nothing
# Hook can mutate: content, details, usage, terminate, isError
result = merge(result, dict(...))
isError = get(after, :isError, isError)
end
catch err
result = createErrorToolResult(sprint(showerror, err))
isError = true
end
end
return finalizedOutcome(prep.toolCall, result, isError)
end
```
The hook can:
- Mask sensitive data from result content
- Normalize usage tracking
- Flip `terminate: true` based on business logic
- Wrap errors in friendlier messages for the LLM
If the hook itself throws, the error is caught and converted to an error outcome.
### Phase 6: Emit Events and Create Result Message
Each call emits `toolExecutionEnd`:
```julia
function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function)
emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError))
end
```
Then creates the `toolResultMessage` for conversation history (`agentCore.jl:373-379`):
```julia
function createToolResultMessage(f::finalizedOutcome)::toolResultMessage
return toolResultMessage(
"toolResult", f.toolCall.id, f.toolCall.name,
f.result.content, f.result.details, f.result.usage,
get(f.result, :addedToolNames, string[]), f.isError, nowMillis()
)
end
```
### Phase 7: Batch Assembly and Loop Control
In `executeToolCallsSequential` (`agentCore.jl:795-829`) or `executeToolCallsParallel` (`agentCore.jl:888-936`), all results are collected:
```julia
messages = toolResultMessage[]
for finalized in finalizedCalls
push!(messages, createToolResultMessage(finalized))
end
return agentToolCallBatch(messages, shouldTerminate(finalizedCalls))
```
`shouldTerminate` (`agentCore.jl:409`) returns `true` only if ALL tools in the batch set `result.terminate == true`. If `false`, the agent loop at `agentCore.jl:176-308` feeds the tool results back to the LLM for another turn.
### Data Flow Summary
```
response.content (Vector{Any})
└── phase 1: parse content blocks
└── tool_call_list :: Vector{agentToolCall}
└── phase 2: dispatch to sequential/parallel
└── phase 3: prepareToolCall
└── preparedToolCall or immediateOutcome
└── phase 4: executePreparedToolCall
└── executedOutcome
└── phase 5: finalizeExecutedToolCall
└── finalizedOutcome
└── phase 6: createToolResultMessage
└── toolResultMessage
└── phase 7: agentToolCallBatch
└── pushed to agent._state.messages
└── loop back to LLM
```
## Execution Modes
### Sequential
Tools execute one at a time in order. Required when:
- Tools have implicit dependencies
- Tools share state (e.g. writing to the same file)
- Tools have `parallelToolExecute = false`
Set globally via `agentLoopConfig.toolExecution = "sequential"`, or per-tool via `parallelToolExecute = false`.
### Parallel
Tools execute concurrently when all are independent. Reduces wall-clock time. Set `parallelToolExecute = true` on individual tools, or set `agentLoopConfig.toolExecution = "parallel"`.
## Streaming Partial Results
For long-running tools (API calls, file uploads, training), use `onPartialResult` to stream progress:
```julia
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
onPartialResult(Dict("status" => "Step 1: Fetching data..."))
sleep(1)
onPartialResult(Dict("status" => "Step 2: Processing..."))
sleep(1)
return agentToolResult(
[textContent("Done!")],
Dict{Any,Any}(), nothing, false
)
end
```
UI listeners and the TUI consume these events in real time via `toolExecutionUpdate`.
## Loading Tools
### Auto-load from Directory
```julia
using .toolRegistry
tools = loadTools("src/tools") # scans for *.jl files with getTool()
```
Files are loaded alphabetically for deterministic registration order.
### Manual Registration
```julia
tool = getTool() # from your tool module
registerTool(tool)
```
## Using Tools with an Agent
Loading tools only registers them — you must pass them to the `yiemAgent` and provide an `llmCall` function. Here is the complete flow:
```julia
using .YiemAgent
using .toolRegistry
# 1. Load tools from the tools directory
tools = loadTools("src/tools")
# [toolRegistry] Loading tool from: src/tools/getTime.jl
# [toolRegistry] Loaded tool: getTime — Time Lookup
# [toolRegistry] Loading tool from: src/tools/getWeather.jl
# [toolRegistry] Loaded tool: getWeather — Weather Lookup
# 2. Define your LLM call function
function my_llm_call(messages::Dict)::assistantMessage
# Call your LLM API here (OpenAI, Anthropic, local model, etc.)
# Return an assistantMessage with the response content
# If the LLM wants to call a tool, include tool_call content blocks
...
end
# 3. Define your event sink (optional, for logging/debugging)
function my_event_sink(event)
if event isa toolExecStartEvent
println("[EVENT] Tool start: $(event.toolName)")
elseif event isa toolExecEndEvent
status = event.isError ? "ERROR" : "OK"
println("[EVENT] Tool end: $(event.toolName)$status")
end
end
# 4. Create the agent with tools
agent = yiemAgent(
systemPrompt = "You are a helpful assistant that can check weather and time.",
model = my_model,
tools = tools, # pass loaded tools
llmCall = my_llm_call, # your LLM function
agentEventSink = my_event_sink, # event handler
)
# 5. Send a message and get a response
run_agent(agent, "What's the weather in Tokyo?")
response = take_response(agent)
# response.content contains the LLM's reply (with tool results if applicable)
println(response.content)
# 6. When done, stop the agent
stop_agent(agent)
```
### How It Works
1. **User sends a message** via `run_agent(agent, "What's the weather in Tokyo?")`. The message goes into `inputChannel`.
2. **Agent loop** (`_agent_loop`) picks it up, converts it to a `userMessage`, and adds it to `agent._state.messages`.
3. **LLM is called** via `agent.llmCall(formatted_messages)`. The LLM sees the system prompt, conversation history, and the tool definitions in the prompt (via `formatMsgForLLM`).
4. **If the LLM uses a tool**, it returns a response with `tool_call` content blocks. The agent:
- Extracts each tool call (name, arguments)
- Runs validation (`validateRequiredArgs` or default)
- Executes the tool (or returns an error if validation fails)
- Feeds the result back as a `toolResultMessage` in the conversation
5. **LLM is called again** with the tool results. This repeats until the LLM returns a text response with no tool calls.
6. **Final response** is sent to `outputChannel` — retrieve it with `take_response(agent)`.
### Minimal Working Example
```julia
using .YiemAgent
using .toolRegistry
# Load tools
tools = loadTools("src/tools")
# Mock LLM that echoes back a tool call, then a text response
call_count = 0
function mock_llm_call(messages::Dict)::assistantMessage
global call_count += 1
if call_count == 1
# First call: LLM decides to use getWeather
return assistantMessage(
content=[
Dict("type" => "tool_calls",
"tool_calls" => [Dict("id" => "call_1", "name" => "getWeather",
"arguments" => Dict("city" => "Tokyo"))])
],
model = "mock",
usage = llmUsage(0, 0)
)
else
# Second call: LLM returns text (after tool result)
return assistantMessage(
content = [textContent("The weather in Tokyo is sunny, 22°C.")],
model = "mock",
usage = llmUsage(0, 0)
)
end
end
# Create agent
agent = yiemAgent(
systemPrompt = "You are a helpful assistant.",
tools = tools,
llmCall = mock_llm_call,
agentEventSink = e -> nothing, # no events
)
# Run
run_agent(agent, "What's the weather in Tokyo?")
response = take_response(agent)
stop_agent(agent)
```
## Available Tools
| Tool | Description | Validation |
|---|---|---|
| `getWeather` | Fetch weather for a city | Default (JSON Schema required) |
| `getTime` | Get current time for a timezone or city | Custom (cross-field + format) |
## Example: Error Flow
When the LLM calls a tool with invalid arguments:
```
User: "What's the weather?"
└── LLM: call getWeather() with no arguments
└── prepareToolCall → validateRequiredArgs → "Missing required arguments: city"
└── immediateOutcome → error tool result
└── LLM sees: "Missing required arguments: city"
└── LLM retries: call getWeather(city="Tokyo")
└── executeTool → "Weather in Tokyo: Sunny, 22°C"
```
The agent feeds the error back to the LLM as a tool result message, allowing it to self-correct.
-89
View File
@@ -1,89 +0,0 @@
"""
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.
# Arguments
- `toolCallId::String`: Unique identifier for this tool call
- `args::Dict{String,Any}`: Parsed arguments from the LLM
- `signal::Union{Nothing,abortSignal}`: Optional abort signal
- `onPartialResult::Function`: Callback for streaming partial results
# Returns
- `agentToolResult`: Result content with current time data
"""
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
tz = get(args, "timezone", nothing)
city = get(args, "city", "")
# Simulate time lookup — replace with actual timezone API call
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
-52
View File
@@ -1,52 +0,0 @@
"""
Execute the getWeather tool.
# Arguments
- `toolCallId::String`: Unique identifier for this tool call
- `args::Dict{String,Any}`: Parsed arguments from the LLM
- `signal::Union{Nothing,abortSignal}`: Optional abort signal
- `onPartialResult::Function`: Callback for streaming partial results
# Returns
- `agentToolResult`: Result content with weather data
"""
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
city = get(args, "city", "")
units = get(args, "units", "celsius")
# Simulate weather fetch — replace with actual API call
# You can call onPartialResult() here for streaming progress updates:
# onPartialResult(Dict("status" => "Fetching weather data..."))
# onPartialResult(Dict("status" => "Processing..."))
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, # reference the function defined above
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
-142
View File
@@ -1,142 +0,0 @@
module toolRegistry
export loadTools, registerTool, getTools, listTools, clearTools
using ..type
# Global registry — populated at runtime by loadTools() or registerTool()
const _registry = Vector{agentTool}()
"""
Load all tool modules from a directory.
Scans `dir` for `.jl` files. Each file must define a function named
`getTool()::agentTool`. Files are sorted alphabetically so tool
registration order is deterministic.
# Tool file format
Each `.jl` file defines one function `getTool()` that returns an `agentTool`:
```julia
# src/tools/getWeather.jl
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"),
"units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius")
),
"required" => ["city"]
),
execute = (toolCallId, args, signal, onPartialResult) -> begin
city = args["city"]
return agentToolResult(
[textContent("Sunny, 22C in $(city)")],
Dict{Any,Any}(), nothing, false
)
end,
prepareArguments = nothing,
parallelToolExecute = false
)
end
```
# Arguments
- `dir::String`: Directory path to scan for `.jl` tool files
# Returns
- `Vector{agentTool}`: All loaded tools
# Errors
- Throws `ArgumentError` if a tool file does not define a `getTool` function
"""
function loadTools(dir::String)::Vector{agentTool}
if !isdir(dir)
throw(ArgumentError("Tool directory does not exist: $dir"))
end
tools = agentTool[]
jl_files = filter(f -> endswith(f, ".jl"), readdir(dir))
sort!(jl_files)
for filename in jl_files
filepath = joinpath(dir, filename)
println("[toolRegistry] Loading tool from: $filepath")
# Include the file in the current module scope so all types resolve
# (agentTool, textContent, agentToolResult, etc. are all available)
include(filepath)
# Validate that getTool was defined (include() places it in current module scope)
if !isdefined(@__MODULE__, :getTool)
throw(ArgumentError(
"Tool file $(filepath) does not define a `getTool()` function. " *
"Each tool file must define: function getTool()::agentTool ... end"
))
end
# Call getTool() — it runs in current scope where types are visible
tool = getTool()
if !(tool isa agentTool)
throw(ArgumentError(
"getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))"
))
end
push!(_registry, tool)
push!(tools, tool)
println("[toolRegistry] Loaded tool: $(tool.name)$(tool.label)")
end
return tools
end
"""
Register a single agentTool into the global registry.
# Arguments
- `tool::agentTool`: The tool to register
# Returns
- `Vector{agentTool}`: Updated registry
"""
function registerTool(tool::agentTool)::Vector{agentTool}
push!(_registry, tool)
println("[toolRegistry] Registered tool: $(tool.name)")
return _registry
end
"""
Get all registered tools.
# Returns
- `Vector{agentTool}`: Copy of the registry
"""
function getTools()::Vector{agentTool}
return deepcopy(_registry)
end
"""
List all registered tool names and labels.
# Returns
- `Vector{Tuple{String,String}}`: Pairs of (name, label)
"""
function listTools()::Vector{Tuple{String,String}}
return [(t.name, t.label) for t in _registry]
end
"""
Clear all registered tools from the global registry.
"""
function clearTools()::Nothing
empty!(_registry)
println("[toolRegistry] Registry cleared")
return nothing
end
end # module
+122 -190
View File
@@ -4,17 +4,17 @@
messageContent, agentMessage, agent, messageContent, agentMessage, agent,
# Model types # Model types
modelCost, llmModel, llmUsage, modelCost, llmModel, llmUsage,
# Message content types # Message content types
textContent, imageContent, textContent, imageContent, reasoningContent,
# Message types # Message types
userMessage, assistantMessage, toolResultMessage, userMessage, assistantMessageToolCall, assistantMessage, toolResultMessage,
# Tool types # Tool types
agentTool, validateRequiredArgs agentTool, validateRequiredArgs,
# Context types # Context types
agentContext, agentState, agentToolCall, prepareNextTurnContext, agentContext, agentState, agentToolCall, prepareNextTurnContext,
# Loop & execution types # Loop & execution types
agentLoopConfig, abortSignal, agentToolResult, agentLoopConfig, abortSignal, agentToolResult,beforeToolCallContext,
assistantMsgCtx, afterCtx, beforeToolCallResult, afterToolCallContext,
# Event types # Event types
toolExecStartEvent, toolExecUpdateEvent, toolExecEndEvent, toolExecStartEvent, toolExecUpdateEvent, toolExecEndEvent,
# Agent # Agent
@@ -23,7 +23,7 @@
preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome, preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome,
agentToolCallBatch, agentToolCallBatch,
# Functions (defined elsewhere) # Functions (defined elsewhere)
run_agent, take_response, follow_up, stop_agent runAgent, takeResponse, followUp, stopAgent
using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads
@@ -31,6 +31,13 @@ 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 #
# ------------------------------------------------------------------------------------------------ # # ------------------------------------------------------------------------------------------------ #
@@ -75,6 +82,10 @@ 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 #
@@ -108,6 +119,52 @@ 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
@@ -144,9 +201,10 @@ assistantMessage("assistant", [textContent("Hello!")], "", "", "gpt-4", ..., "en
``` ```
""" """
function assistantMessage(; role="assistant", content=Vector{messageContent}(), function assistantMessage(; role="assistant", content=Vector{messageContent}(),
api="", provider="", model="", 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())
return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp) model_str = model isa AbstractString ? String(model) : ""
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
@@ -248,7 +306,7 @@ tool = agentTool(
execute=(toolCallId, args, signal, onPartialResult) -> begin execute=(toolCallId, args, signal, onPartialResult) -> begin
city = args["city"] city = args["city"]
return agentToolResult( return agentToolResult(
[textContent("Sunny, 22C in $(city)")], [textContent("Sunny, 22C in Bangkok")],
Dict{Any,Any}(), nothing, false Dict{Any,Any}(), nothing, false
) )
end, end,
@@ -263,12 +321,23 @@ 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::Function # Tool execution function execute # 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
end end
"""
Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`.
"""
function agentTool(; name::String, label::String, description::String, inputSchema::Any,
execute, prepareArguments::Union{Function, Nothing}=nothing,
validateRequiredArgs::Union{Function, Nothing}=nothing,
parallelToolExecute::Bool=false)
return agentTool(name, label, description, inputSchema, execute,
prepareArguments, validateRequiredArgs, parallelToolExecute)
end
# ------------------------------------------------------------------------------------------------ # # ------------------------------------------------------------------------------------------------ #
# Agent context # # Agent context #
@@ -280,7 +349,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{Vector{agentTool}, Nothing}`: Available tools - `tools::Union{OrderedDict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup
# Returns # Returns
- A new `agentContext` instance - A new `agentContext` instance
@@ -288,7 +357,8 @@ 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{Vector{agentTool}, Nothing} # Available tools tools::Union{OrderedDict{String, agentTool}, Nothing} # Available tools keyed by name
llmCall::Union{Any, Nothing} # LLM call function (for tools that need it)
end end
@@ -297,15 +367,14 @@ end
# ------------------------------------------------------------------------------------------------ # # ------------------------------------------------------------------------------------------------ #
mutable struct agentState # Mutable runtime state of an agent mutable struct agentState # Mutable runtime state of an agent
systemPrompt::String # System prompt text systemPrompt::String # System prompt for the agent
model::llmModel # LLM model to use model::Union{llmModel, Nothing} # LLM model to use
tools::Vector{agentTool} # Available tools tools::OrderedDict{String, agentTool} # Available tools keyed by name, insertion-ordered
# messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt # messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt
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
@@ -318,7 +387,7 @@ new state from external references.
# Arguments # Arguments
- `systemPrompt::String`: System prompt text - `systemPrompt::String`: System prompt text
- `model::llmModel`: LLM model to use (defaults to an unknown model) - `model::llmModel`: LLM model to use (defaults to an unknown model)
- `tools::Vector{agentTool}`: Available tools (deep copied) - `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (deep copied)
- `messages::Vector{agentMessage}`: Conversation messages (deep copied) - `messages::Vector{agentMessage}`: Conversation messages (deep copied)
# Returns # Returns
@@ -327,32 +396,23 @@ new state from external references.
# Examples # Examples
```julia ```julia
julia> state = agentState(systemPrompt="You are a helpful assistant") julia> state = agentState(systemPrompt="You are a helpful assistant")
agentState("You are a helpful assistant", ..., agentTool[], agentMessage[], String[], nothing) agentState("You are a helpful assistant", OrderedDict{String, agentTool}(), agentMessage[], String[], nothing)
```
""" """
function agentState( function agentState(
systemPrompt::String="", systemPrompt::String="",
model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), model=llmModel("model_1", "unknown", "unknown", "", false, String[],
tools::Vector{agentTool}=agentTool[], modelCost(0.0, 0.0, 0.0, 0.0), 0, 0),
messages::Vector{agentMessage}=agentMessage[], tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(),
messages::Vector{agentMessage}=agentMessage[],
) )
agentState( agentState(
systemPrompt, systemPrompt,
model, model,
deepcopy(tools), deepcopy(tools),
deepcopy(messages), deepcopy(messages),
Vector{String}(), Vector{String}(),
false, nothing,
nothing, )
)
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 end
@@ -384,16 +444,15 @@ end
Configuration for the agent tool execution loop. Configuration for the agent tool execution loop.
# Arguments # Arguments
- `tools::Vector{agentTool}`: Available tools
- `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::Vector{agentTool}
beforeToolCall::Union{Function, Nothing} beforeToolCall::Union{Function, Nothing}
afterToolCall::Union{Function, Nothing} afterToolCall::Union{Function, Nothing}
toolExecution::String toolExecution::String
llmCall::Union{Any, Nothing} # LLM call function (for tools like searchWine)
end end
""" """
@@ -426,33 +485,38 @@ end
Context passed to the `beforeToolCall` hook. Context passed to the `beforeToolCall` hook.
# Arguments # Arguments
- `message::assistantMessage`: The assistant message containing the tool call - `message::assistantMessageToolCall`: The assistant message containing the tool call
- `toolCall::agentToolCall`: The tool call being prepared - `toolCall::agentToolCall`: The tool call being prepared
- `args::Dict{String,Any}`: Validated tool arguments - `args::AbstractDict{String, Any}`: Validated tool arguments
- `context::agentContext`: Current conversation context - `context::agentContext`: Current conversation context
""" """
struct assistantMsgCtx struct beforeToolCallContext
message::assistantMessage message::assistantMessageToolCall
toolCall::agentToolCall toolCall::agentToolCall
args::Dict{String,Any} args::AbstractDict{String, Any}
context::agentContext context::agentContext
end end
struct beforeToolCallResult
block::Bool
reason::String
end
""" """
Context passed to the `afterToolCall` hook. Context passed to the `afterToolCall` hook.
# Arguments # Arguments
- `message::assistantMessage`: The assistant message containing the tool call - `message::assistantMessageToolCall`: The assistant message containing the tool call
- `toolCall::agentToolCall`: The tool call that was executed - `toolCall::agentToolCall`: The tool call that was executed
- `args::Dict{String,Any}`: Tool arguments - `args::AbstractDict{String, Any}`: Tool arguments
- `result::agentToolResult`: The raw tool result - `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 afterCtx struct afterToolCallContext
message::assistantMessage message::assistantMessageToolCall
toolCall::agentToolCall toolCall::agentToolCall
args::Dict{String,Any} args::AbstractDict{String, Any}
result::agentToolResult result::agentToolResult
isError::Bool isError::Bool
context::agentContext context::agentContext
@@ -464,12 +528,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::Dict{String,Any}`: Tool arguments - `arguments::AbstractDict{String, Any}`: Tool arguments
""" """
struct toolExecStartEvent struct toolExecStartEvent
toolCallId::String toolCallId::String
toolName::String toolName::String
arguments::Dict{String,Any} arguments::AbstractDict{String, Any}
end end
""" """
@@ -478,13 +542,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::Dict{String,Any}`: Tool arguments - `arguments::AbstractDict{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::Dict{String,Any} arguments::AbstractDict{String, Any}
partialResult::Any partialResult::Any
end end
@@ -510,138 +574,6 @@ end
abstract type agent end abstract type agent end
"""
docstring
"""
mutable struct yiemAgent <: agent # High-level agent wrapper
_state::agentState # Current state (prompt, model, messages, tools, etc.)
# user sends prompt message to agent. if agent is idle, it process user message right away.
# if agent is running, it process user message after the current tool call finished.
inputChannel::Channel
# Buffers messages the user sends while the agent is busy. Processed after all inputChannel
# messages are handled and the agent is idle (not using a tool call).
followUpChannel::Channel
# agent sends response message to user after processing all user messages in inputChannel
# and all followUp messages.
outputChannel::Channel
_agent_loop::Union{Task, Nothing} # agent loop running in the background
# Preprocess/transform messages and context (modify, filter, prune, inject context from memory,
# reorder, ...) for a single LLM call in _process_message()'s loop.
# returns new Vector{agentMessage}
prepareContext ::Union{Function, Nothing}
# Convert prepareContext()'s new Vector{agentMessage} to LLM message format
formatMsgForLLM::Function
# Actually invoke the LLM to get a completion response. The LLM response comes back as an
# assistantMessage whose content is an array of content blocks.
# Each block has a type — "text", "thinking", or "toolCall".
# The code filters for type === "toolCall" blocks, then passes them to executeToolCalls().
llmCall::Function
# Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..)
beforeToolCall::Union{Function, Nothing}
executeToolCalls::Function # execute tool calls ()
# Callback invoked after executing a tool call to sanitize tools output so the output is ready
# to be converted into toolResults message
afterToolCall::Union{Function, Nothing}
# prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn
# prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context
sessionId::Union{String, Nothing} # Optional session identifier
maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms)
parallelToolExecute::Bool # Default: false
agentEventSink::Function # agent emits its status via this function
end
"""
Create a new yiemAgent instance with a background loop task.
Spawns a background `@spawn` task that runs the agent loop, listening
on `inputChannel` and `followUpChannel` channels concurrently.
# Keyword Arguments
- `systemPrompt::String`: System prompt for the agent
- `model`: LLM model to use
- `tools::Vector{agentTool}`: Available tools (default: empty)
- `messages::Vector{agentMessage}`: Initial conversation messages (default: empty)
- `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`)
- `llmCall::Function`: Function to invoke the LLM (required)
- `prepareContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`)
- `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`)
- `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`)
- `prepareNextTurn::Union{Function, Nothing}`: Callback to prepare the next conversation turn (default: `nothing`)
- `prepareNextTurnWithContext::Union{Function, Nothing}`: Same but receives context (default: `nothing`)
- `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`)
- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`)
- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`)
- `agentEventSink::Function`: Callback to receive agent events
# Returns
- A new `yiemAgent` instance with an active background task
# Examples
```julia
julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model)
yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., ...)
```
"""
function yiemAgent(
; systemPrompt::String="You are helpful assistant.",
model=nothing,
tools::Vector{agentTool}=agentTool[],
messages::Vector{agentMessage}=agentMessage[],
prepareContext::Union{Function, Nothing}=nothing,
formatMsgForLLM::Function=defaultformatMsgForLLM,
llmCall::Function,
beforeToolCall::Union{Function, Nothing}=nothing,
afterToolCall::Union{Function, Nothing}=nothing,
# prepareNextTurn::Union{Function, Nothing}=nothing,
# prepareNextTurnWithContext::Union{Function, Nothing}=nothing,
sessionId::Union{String, Nothing}=nothing,
maxRetryDelayMs::Union{Int64, Nothing}=nothing,
parallelToolExecute::Bool=false,
agentEventSink::Function,
)
# Create channels: input (user -> agent), followUp (async queue), output (agent -> user)
inputChannel = Channel(16)
followUp = Channel(32)
outputChannel = Channel(16)
# Create struct with a placeholder task, then spawn and replace it
agent = yiemAgent(
agentState(systemPrompt, model, tools, messages),
inputChannel,
followUp,
outputChannel,
nothing, # placeholder — replaced below
prepareContext,
formatMsgForLLM,
llmCall,
beforeToolCall,
afterToolCall,
# prepareNextTurn,
# prepareNextTurnWithContext,
sessionId,
maxRetryDelayMs,
parallelToolExecute,
agentEventSink,
)
# Spawn the background loop and attach it
agent._agent_loop = @spawn _agent_loop(agent)
return agent
end
""" """
preparedToolCall(tool, toolCall, args) preparedToolCall(tool, toolCall, args)
+200 -21
View File
@@ -1,9 +1,11 @@
module utils module utils
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs,
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks validateToolArguments, _userMessageToOpenAI,
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI,
beforeToolCall, afterToolCall, eventSink
using UUIDs, Dates, DataStructures, HTTP, JSON using UUIDs, Dates, DataStructures, HTTP, JSON, NATS
using GeneralUtils using GeneralUtils
using ..type using ..type
@@ -73,7 +75,6 @@ function availableWineToText(vecd::Vector)::String
end end
""" """
prepareContext(state::agentState) -> agentContext prepareContext(state::agentState) -> agentContext
@@ -108,18 +109,18 @@ prepareContext(state).messages == deepcopy(state.messages)
# end # end
``` ```
""" """
function prepareContext(state::agentState)::agentContext function prepareContext(state::agentState, eventSink, llmCall=nothing)::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
#TODO add tools to current system prompt #TODO add filtered tools to the current system prompt / modify systemPrompt here
preparedSystemPrompt = state.systemPrompt preparedSystemPrompt = state.systemPrompt
#TODO add system prompt, adjust/modify and inject additional context into messages #TODO add system prompt, adjust/modify and inject additional context into messages
preparedMessages = deepcopy(state.messages) # messages that will be send to LLM preparedMessages = deepcopy(state.messages) # messages that will be send to LLM
agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools) agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools, llmCall)
return agentCtx return agentCtx
end end
@@ -155,7 +156,7 @@ formatMsgForLLm(ctx) == Dict("messages" => [
]) ])
``` ```
""" """
function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} function formatMsgForLLM(ctx::agentContext, eventSink)::Dict{String, Any}
""" openai message format example """ openai message format example
msg = Dict( msg = Dict(
@@ -183,19 +184,31 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
Dict("type" => "text", "text" => "let me check."), Dict("type" => "text", "text" => "let me check."),
] ]
), ),
],
"tools"=> [
Dict( Dict(
"role" => "toolResult", "type" => "function",
"content" => [ "function" => Dict(
Dict("type" => "text", "text" => "name: Chateau Montelena ..."), "name" => "getWeather",
] "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}}()
eventSink("formatMsgForLLM 1")
# System prompt as system message # System prompt as system message
if !isempty(ctx.systemPrompt) if !isempty(ctx.systemPrompt)
push!(messages, Dict( push!(messages, Dict(
@@ -203,19 +216,113 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
"content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)]
)) ))
end end
eventSink("formatMsgForLLM 2")
# 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
eventSink("formatMsgForLLM 3")
# Convert ctx.tools into OpenAI tools format
tools_array = _toolsToOpenAI(ctx.tools, eventSink)
eventSink("formatMsgForLLM 4")
openaiReadyMsg["messages"] = messages
openaiReadyMsg["temperature"] = 0.7
if !isempty(tools_array)
openaiReadyMsg["tools"] = tools_array
end
return openaiReadyMsg
end
"""
beforeToolCall(context::beforeToolCallContext, signal::abortSignal) -> beforeToolCallResult
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
```julia
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
)::beforeToolCallResult
# final context check
# seek user approval via UI
# other check
return beforeToolCallResult(false, "N/A")
end
"""
afterToolCall(context::afterToolCallContext, signal::abortSignal) -> Union{agentToolResult, Nothing}
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).
"""
function afterToolCall(context::afterToolCallContext, signal::abortSignal
)::Union{agentToolResult, Nothing}
# modify context.result if needed and return agentToolResult
return nothing
end
#TODO
function eventSink(x)
return Dict("messages" => messages)
end end
@@ -230,6 +337,44 @@ 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.
""" """
@@ -280,7 +425,41 @@ end
""" """
validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String} _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}) -> Vector{Dict{String, Any}}
Convert an OrderedDict of agentTool definitions into OpenAI function tool format.
Returns an empty vector when `tools` is `nothing` or empty.
# Examples
```julia
_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}}()
eventSink("_toolsToOpenAI 1")
eventSink(string(typeof(tools)))
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
eventSink("_toolsToOpenAI 2")
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
@@ -291,8 +470,8 @@ 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::Dict{String,Any}`: The arguments provided by the LLM - `args::AbstractDict{String, Any}`: The arguments provided by the LLM
- `inputSchema::Dict{String,Any}`: The tool's `inputSchema` (JSON Schema format) - `inputSchema::AbstractDict{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
@@ -308,7 +487,7 @@ args2 = Dict("city" => "Tokyo")
validateRequiredArgs(args2, schema) # => nothing validateRequiredArgs(args2, schema) # => nothing
``` ```
""" """
function validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any})::Union{Nothing,String} function validateRequiredArgs(args::AbstractDict{String, Any}, inputSchema::AbstractDict{String, Any})::Union{Nothing,String}
required = get(inputSchema, "required", Any[]) required = get(inputSchema, "required", Any[])
if isempty(required) if isempty(required)
return nothing return nothing
@@ -362,7 +541,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)::Dict{String,Any} function validateToolArguments(tool::agentTool, prepared::agentToolCall)::AbstractDict{String, Any}
# Use default (2-arg: args + schema) or tool-specific hook (1-arg: args only) # 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
@@ -1,375 +0,0 @@
module type
export agent, sommelier, companion, virtualcustomer, agentcontext
using Dates, UUIDs, DataStructures, JSON, NATS
using GeneralUtils
# ---------------------------------------------- 100 --------------------------------------------- #
mutable struct agentcontext
text2textInstructLLM::Function
getTextEmbedding::Function
executeSQL::Function
similarSQLVectorDB::Function
insertSQLVectorDB::Function
similarSommelierDecision::Function
insertSommelierDecision::Function
find_related_tables_for_user_question::Function
pg_conn_str::String
agentconfig::AbstractDict
end
abstract type agent end
mutable struct sommelier <: agent
name::String # agent name
id::String # agent id
retailername::String
retailerid::String
tools::Dict
maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized
chathistory::Vector{Dict{String, Any}}
memory::Dict{String, Any}
context::agentcontext
llmFormatName::String
end
""" A sommelier agent.
# Arguments
- `context::agentcontext`
Application context containing shared functions for LLM, SQL, and vector database operations.
# Keyword Arguments
- `name::String`
Agent's name. Default: `"Assistant"`
- `id::String`
Agent's ID. Default: generated UUID string.
- `retailername::String`
Retailer name associated with the sommelier. Default: `"retailer_name"`
- `maxHistoryMsg::Integer`
Maximum history messages. Default: `20`
- `chathistory::Vector{Dict{String, String}}`
Chat history. Default: empty vector.
- `llmFormatName::String`
LLM format name. Default: `"granite3"`
# Return
- `sommelier`: An instantiated sommelier agent.
# Example
```julia
julia> using YiemAgent
julia> context = agentcontext(
text2textInstructLLM,
getTextEmbedding,
executeSQL,
similarSQLVectorDB,
insertSQLVectorDB,
similarSommelierDecision,
insertSommelierDecision
)
julia> agent = sommelier(context, name="WineExpert", id="123", retailername="MyWineShop")
```
"""
function sommelier(
context::agentcontext, # agent functions, db connect and other context
;
name::String= "Assistant",
id::String= string(uuid4()),
retailername::String= "not specified",
retailerid::String= "not specified",
maxHistoryMsg::Integer= 20,
chathistory::Vector{Dict{String, Any}} = Vector{Dict{String, Any}}(),
llmFormatName::String= "granite3"
)
tools = Dict( # update input format
"chatbox"=> Dict(
"description" => "<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
-41
View File
@@ -1,41 +0,0 @@
# This file is machine-generated - editing it directly is not advised
julia_version = "1.11.4"
manifest_format = "2.0"
project_hash = "71d91126b5a1fb1020e1098d9d492de2a4438fd2"
[[deps.Base64]]
uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
version = "1.11.0"
[[deps.InteractiveUtils]]
deps = ["Markdown"]
uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240"
version = "1.11.0"
[[deps.Logging]]
uuid = "56ddb016-857b-54e1-b83d-db4d58db5568"
version = "1.11.0"
[[deps.Markdown]]
deps = ["Base64"]
uuid = "d6f4376e-aef5-505a-96c1-9c027394607a"
version = "1.11.0"
[[deps.Random]]
deps = ["SHA"]
uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
version = "1.11.0"
[[deps.SHA]]
uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce"
version = "0.7.0"
[[deps.Serialization]]
uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
version = "1.11.0"
[[deps.Test]]
deps = ["InteractiveUtils", "Logging", "Random", "Serialization"]
uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
version = "1.11.0"
-2
View File
@@ -1,2 +0,0 @@
[deps]
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+642
View File
@@ -0,0 +1,642 @@
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
+4 -400
View File
@@ -1,401 +1,5 @@
using JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures, HTTP, Base64, using Test
NATS, Base.Threads using YiemAgent
using YiemAgent, GeneralUtils, msghandler
function text2text_instruct_llm(sender_id::String, openai_msg::Dict{String, Any})
payloads = [("msg", openai_msg, "dictionary")] # List of tuples
_, msg_envelope_json_str = msghandler.smartpack(
config["externalservice"]["servicesloadbalancer"]["nats"],
payloads;
sender_id=sender_id,
msg_purpose="text2text",
broker_url=config["nats_server_info"]["url"],
fileserver_url=config["externalservice"]["fileserver"]["url"])
reply = NATS.request(agent_conn,
config["externalservice"]["servicesloadbalancer"]["nats"],
msg_envelope_json_str, timeout=120)
incoming_env_json_str = String(reply.payload)
incoming_env = msghandler.smartunpack(incoming_env_json_str)
_llm_response = incoming_env["payloads"][1][2]
llm_response = _llm_response["choices"][1]["message"]["content"]
return llm_response
end
""" get a single text embedding from a LLM service
Example
text = ["hello"]
embedding = get_embedding(text)
"""
function get_embedding(text::AbstractArray{String})
documents_dict = Dict("documents" => text)
payloads = [("documents", documents_dict, "dictionary")]
_, msg_envelope_json_str = msghandler.smartpack(
config["externalservice"]["servicesloadbalancer"]["nats"],
payloads;
msg_purpose="embedding",
broker_url=config["nats_server_info"]["url"],
fileserver_url=config["externalservice"]["fileserver"]["url"])
reply = NATS.request(agent_conn,
config["externalservice"]["servicesloadbalancer"]["nats"],
msg_envelope_json_str, timeout=120)
incoming_env_json_str = String(reply.payload)
incoming_env = msghandler.smartunpack(incoming_env_json_str)
embedding_response = incoming_env["payloads"][1][2]
return embedding_response
end
""" sql = "SELECT * FROM wine;"
result = execute_sql_winedb(sql)
"""
function execute_sql_winedb(sql::T) where {T<:AbstractString}
host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':')
port = parse(Int, _port)
dbname = "winedb"
user = config["externalservice"]["sommpanion_db"]["user"]
password = config["externalservice"]["sommpanion_db"]["password"]
db_connection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password")
result = nothing
try
result = LibPQ.execute(db_connection, sql)
catch e
LibPQ.close(db_connection)
end
LibPQ.close(db_connection)
return result
end
""" find similar sql from vector database
sql = "SELECT * FROM wine;"
result, distance = similar_sql_vectordb(sql)
"""
function similar_sql_vectordb(sql::T; maxdistance::Number=0.2) where {T<:AbstractString}
tablename = "sqlllm_decision_repository"
# get embedding of the query
df = find_similar_text_from_vectordb(sql, tablename,
"function_input_embedding", execute_sql_vectordb)
# println(df[1, [:id, :function_output]])
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
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--| similar sql found. row id $rowid, distance $distance ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
pprintln(output_str)
return (result=output_str, distance=distance)
else
println("\n--| similar sql not found, max distance $maxdistance ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
return (result=nothing, distance=nothing)
end
end
""" insert query and sql into vector database
query = "get all wines from wine table"
sql = "SELECT * FROM wine;"
insert_sql_vectordb(query, sql)
"""
function insert_sql_vectordb(query::T1, SQL::T2; maxdistance::Number=3
) where {T1<:AbstractString, T2<:AbstractString}
tablename = "sqlllm_decision_repository"
# get embedding of the query
# query = state[:thoughtHistory][:question]
df = find_similar_text_from_vectordb(query, tablename,
"function_input_embedding", execute_sql_vectordb)
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 = get_embedding([query])
_query_embedding = GeneralUtils.dictify(_query_embedding)
# println("\n--- _query_embedding() ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# println(_query_embedding)
# println("---\n")
query_embedding = _query_embedding["data"][1]["embedding"]
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)
_ = execute_sql_vectordb(sql)
end
end
""" execute sql against vectordb
sql = "SELECT * FROM wine;"
result = execute_sql_vectordb(sql)
"""
function execute_sql_vectordb(sql::T) where {T<:AbstractString}
host_url, _port = split(config["externalservice"]["sommpanion_vectordb"]["url"], ':')
port = parse(Int, _port)
dbname = config["externalservice"]["sommpanion_vectordb"]["dbname"]
user = config["externalservice"]["sommpanion_vectordb"]["user"]
password = config["externalservice"]["sommpanion_vectordb"]["password"]
DBconnection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password")
result = LibPQ.execute(DBconnection, sql)
close(DBconnection)
return result
end
""" search similar decision llm made from vectordb
"""
function similar_sommelier_decision(recentevents::T1; maxdistance::Integer=3
)::Union{AbstractDict, Nothing} where {T1<:AbstractString}
tablename = "sommelier_decision_repository"
# find similar
df = find_similar_text_from_vectordb(recentevents, tablename,
"function_input_embedding", execute_sql_vectordb)
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.read(_output_str))
return output
else
println("\n--| similar decision not found, max distance $maxdistance ", @__FILE__, " ", @__LINE__)
return nothing
end
end
""" search similar text from vectordb
"""
function find_similar_text_from_vectordb(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 = get_embedding([text])
_embedding = _embedding["data"][1]["embedding"]
_embedding = "$_embedding"
embedding = _embedding[4:end]
# 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
""" insert decision llm made to vectordb
"""
function insert_sommelier_decision(recentevents::T1, decision::T2; maxdistance::Integer=5
) where {T1<:AbstractString, T2<:AbstractDict}
tablename = "sommelier_decision_repository"
# find similar
df = find_similar_text_from_vectordb(recentevents, tablename,
"function_input_embedding", execute_sql_vectordb)
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
if row == 0 || distance > maxdistance # no close enough SQL stored in the database
_embedding = get_embedding([recentevents])[1]
recentevents_embedding = _embedding["data"][1]["embedding"]
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)
_ = execute_sql_vectordb(sql)
else
println("--| similar decision previously cached, distance $distance ", @__FILE__, " ", @__LINE__)
end
end
config = JSON.parsefile("./appconfig.json")
sessionId = "0"
backend_session_topic = "sommpanion.testsubject"
agent_ch = Channel(8)
agent_conn = NATS.connect(config["nats_server_info"]["url"])
sub2 = NATS.subscribe(agent_conn, backend_session_topic) do msg
put!(agent_ch, msg)
end
agent_context = YiemAgent.agentcontext(
text2text_instruct_llm,
get_embedding,
execute_sql_winedb,
similar_sql_vectordb,
insert_sql_vectordb,
similar_sommelier_decision,
insert_sommelier_decision
)
# can't instantiate
agent = YiemAgent.sommelier(
agent_context;
name="Janie",
id=sessionId, # agent instance id
retailername="Yiem Wine Ltd.",
llmFormatName=""
)
image1_path = "test/large_image.png"
image1_bytes = read(image1_path)
image1_base64_string = base64encode(image1_bytes)
mime_type = "image/png"
data1_uri = "data:$(mime_type);base64,$(image1_base64_string)"
# 1. Read local file and encode to base64 string
image2_path = "test/small_image.png"
image2_bytes = read(image2_path)
image2_base64_string = base64encode(image2_bytes)
mime_type = "image/png"
data2_uri = "data:$(mime_type);base64,$(image2_base64_string)"
# 3. Construct payload with the Data URI
message = Dict(
"role" => "user",
"content" => [
Dict("type" => "text", "text" => "Do you know type of wine in the image?"),
Dict(
"type" => "image_url",
"image_url" => Dict("url" => data1_uri)
)
]
)
result = YiemAgent.conversation(agent; userinput=message)
println("\n$result")
# message = Dict(
# "role" => "user",
# "content" => [
# Dict("type" => "text", "text" =>
# "
# เป็นงานเลี้ยงทั่วไป
# "),
# ]
# )
# result = YiemAgent.conversation(agent; userinput=message)
# println("\n$result")
# message = Dict(
# "role" => "user",
# "content" => [
# Dict("type" => "text", "text" => "no thanks. that's all"),
# ]
# )
# result = YiemAgent.conversation(agent; userinput=message)
# println("\n$result")
# message = Dict(
# "role" => "user",
# "content" => [
# Dict("type" => "text", "text" => "What about this wine?"),
# Dict(
# "type" => "image_url",
# "image_url" => Dict("url" => data2_uri)
# )
# ]
# )
# result = YiemAgent.conversation(agent; userinput=message)
# println("\n$result")
include("toolTest.jl")
include("_extractToolCalls.jl")
+113
View File
@@ -0,0 +1,113 @@
using Revise, JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures, HTTP, Base64,
NATS, Base.Threads
using YiemAgent, GeneralUtils, msghandler
""" Debug
using JSON, NATS, msghandler
using NATS
conn = NATS.connect("nats.yiem.cc")
sub = NATS.subscribe(conn, "sommanion.debug") do msg
payload = NATS.payload(msg)
@info "debug" payload
open("./log/error.log", "a") do io
println(io, payload)
end
end
NATS.publish(conn, "sommanion.debug", "order-123")
# ---------------------------- inject this code into codebase to debug --------------------------- #
try
batch = someFunction(x, y, z)
catch e
bt = catch_backtrace()
err_msg = sprint() do io
showerror(io, e, bt)
println(io)
end
eventSink(err_msg)
end
"""
struct text2textInstructLLM
natsConn::NATS.Connection
topic::String
senderID::String
fileserver_url::String
end
function (t::text2textInstructLLM)(openai_msg::Dict{String, Any})
payloads = [("msg", openai_msg, "dictionary")] # List of tuples
_, msg_envelope_json_str = msghandler.smartpack(
t.topic,
payloads;
sender_id=t.senderID,
msg_purpose="text2text",
fileserver_url=t.fileserver_url)
reply = NATS.request(t.natsConn, t.topic, msg_envelope_json_str, timeout=180)
incoming_env_json_str = String(reply.payload)
incoming_env = msghandler.smartunpack(incoming_env_json_str)
_llm_response = incoming_env["payloads"][1][2]
llm_response = _llm_response["choices"][1]
return llm_response
end
struct eventSink
natsConn::NATS.Connection
topic::String
senderID::String
end
function (aes::eventSink)(msg::String)
NATS.publish(aes.natsConn, aes.topic, msg)
end
config = JSON.parsefile("./appconfig.json")
agent_conn = NATS.connect(config["nats_server_info"]["url"])
#WORKING load tools
text2text_llm = text2textInstructLLM(agent_conn,
config["externalservice"]["servicesloadbalancer"]["nats"],
"sender",
config["externalservice"]["fileserver"]["url"])
debugNats = eventSink(agent_conn, "sommanion.debug", "sender")
agent = YiemAgent.yiemAgent(
text2text_llm;
eventSink=debugNats
)
msg = Dict(
"role" => "user",
"content" => [
Dict("type" => "text", "text" => "What's the weather in Bangkok?"),
# Dict(
# "type" => "image_url",
# "image_url" => Dict("url" => "data:mime_type;base64,image2_base64_string")
# ),
]
)
push!(agent.inputChannel, msg)